diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d7b86348b..7e482a0a8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
# Pull requests opened with GITHUB_TOKEN start no pull_request run; the
# agent-version workflow dispatches one instead.
workflow_dispatch:
@@ -28,6 +29,7 @@ jobs:
# required check must wait for that platform's Clippy and tests alone.
linux-clippy:
name: Linux x64 / clippy
+ if: !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 60
steps: &clippy-steps
@@ -123,6 +125,7 @@ jobs:
linux-tests:
name: Linux x64 / tests
+ if: !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 60
steps: &test-steps
@@ -178,24 +181,28 @@ jobs:
macos-clippy:
name: macOS arm64 / clippy
+ if: !github.event.pull_request.draft
runs-on: macos-26
timeout-minutes: 60
steps: *clippy-steps
macos-tests:
name: macOS arm64 / tests
+ if: !github.event.pull_request.draft
runs-on: macos-26
timeout-minutes: 60
steps: *test-steps
windows-clippy:
name: Windows x64 / clippy
+ if: !github.event.pull_request.draft
runs-on: windows-2022
timeout-minutes: 60
steps: *clippy-steps
windows-tests:
name: Windows x64 / tests
+ if: !github.event.pull_request.draft
runs-on: windows-2022
timeout-minutes: 60
steps: *test-steps
@@ -204,8 +211,9 @@ jobs:
# must not let its platform's check pass.
linux:
name: Linux x64
+ if: !github.event.pull_request.draft
needs: [linux-clippy, linux-tests]
- if: always()
+ if: always() && !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 5
steps: &require-platform
@@ -216,22 +224,25 @@ jobs:
macos:
name: macOS arm64
+ if: !github.event.pull_request.draft
needs: [macos-clippy, macos-tests]
- if: always()
+ if: always() && !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 5
steps: *require-platform
windows:
name: Windows x64
+ if: !github.event.pull_request.draft
needs: [windows-clippy, windows-tests]
- if: always()
+ if: always() && !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 5
steps: *require-platform
dependencies:
name: Dependency hygiene
+ if: !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
@@ -247,6 +258,7 @@ jobs:
ios:
name: iOS
+ if: !github.event.pull_request.draft
runs-on: macos-26
timeout-minutes: 30
env:
@@ -270,6 +282,7 @@ jobs:
android:
name: Android
+ if: !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
@@ -316,6 +329,7 @@ jobs:
web:
name: Web
+ if: !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b66bf0b0a..826c4e058 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,14 +54,21 @@ is derived from it. **Process** is how to build, check and submit work.
6. **gpui-base is the infrastructure; no house UI principles.** Tcode follows
the recommendations and defaults of gpui-base and gpui-kit, and keeps no
- separate design contract in sync with the code. Everything gpui-base
+ separate design contract in sync with the code: no house-wide UI rules of
+ its own, such as a single duration for every transition or classes of
+ change that must stay instant. Everything gpui-base
provides on top of GPUI — selection, scrolling, positioning, dismissal,
focus, theme tokens, motion, virtual lists, form controls — is used as it
is, defaults included, before anything of the kind is written in Tcode.
Tcode's own components are styled compositions of those primitives; they
- never re-derive a primitive's rules locally. A primitive is reimplemented,
- or a default overridden, only where gpui-base demonstrably cannot meet the
- need; the site names that gap, and it is removed once gpui-base closes it.
+ never re-derive a primitive's rules locally. Where gpui-base demonstrably
+ cannot meet the need, Tcode builds what is missing — a primitive
+ reimplemented, a default overridden, or an element gpui-base does not have
+ — and the site names that gap; it is removed once gpui-base closes it.
+ This principle decides how something is built, never whether it is built:
+ what the interface does is the maintainer's call, and a primitive
+ gpui-base lacks is never a reason to leave an effect out, make it instant,
+ or settle for less.
7. **Code and tests are the only source of truth.** This section is the only
maintainer-authored text; comments, issues and other documents are derived
@@ -69,7 +76,11 @@ is derived from it. **Process** is how to build, check and submit work.
comment is kept only when it states a constraint the code cannot express or
the reason an obvious alternative was rejected; narrated steps, history and
"mirrors X" notes are deleted when touched. A constraint drafted by an AI is
- not a requirement until it appears here. A test, whatever its form, is
+ not a requirement until it appears here. Rules and conventions written into
+ audits, hand-offs, briefs or agent instructions bind no one: they are
+ notes, never cited as requirements or used to send work back, and where
+ one would narrow what the maintainer asked for, the maintainer's request
+ wins. A test, whatever its form, is
truth only for a contract real use needs, and only while it is the test
that proves it by driving the code the way the running app does. The suite
is larger than its contracts: a test met while working on its area is
diff --git a/assets/icons/bundled/columns-2.svg b/assets/icons/bundled/columns-2.svg
new file mode 100644
index 000000000..79d8f66f7
--- /dev/null
+++ b/assets/icons/bundled/columns-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/assets/icons/bundled/monitor.svg b/assets/icons/bundled/monitor.svg
new file mode 100644
index 000000000..e869f2f27
--- /dev/null
+++ b/assets/icons/bundled/monitor.svg
@@ -0,0 +1,15 @@
+
diff --git a/assets/icons/bundled/rows-2.svg b/assets/icons/bundled/rows-2.svg
new file mode 100644
index 000000000..f31fbb55b
--- /dev/null
+++ b/assets/icons/bundled/rows-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/clippy.toml b/clippy.toml
index a822d948e..8a7cb6dde 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -6,5 +6,7 @@
disallowed-methods = [
{ path = "std::process::Command::new", reason = "use services::process::command or agent's process::command so Windows never flashes a console" },
{ path = "smol::process::Command::new", reason = "use services::process::async_command or agent's process::async_command so Windows never flashes a console" },
+ { path = "gpui::StatefulInteractiveElement::tooltip", reason = "use tcode_ui::widgets::TooltipTrigger::with_tooltip so the tooltip shows through the window's tooltip layer and can leave" },
+ { path = "gpui::InteractiveText::tooltip", reason = "track the text's parts with widgets::tooltip::track_parts so the tooltip shows through the window's tooltip layer and can leave" },
{ path = "smol::unblock", reason = "use Host::unblock or the agent crate's process::unblock so blocking work is scheduled in one place" },
]
diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml
index d3eac58df..e2022f811 100644
--- a/crates/agent/Cargo.toml
+++ b/crates/agent/Cargo.toml
@@ -7,7 +7,6 @@ edition = "2024"
default = ["process"]
process = [
"dep:agent-client-protocol",
- "dep:base64",
"dep:dirs",
"dep:sha2",
"dep:smol",
@@ -22,7 +21,7 @@ agent-client-protocol = { version = "2.0", optional = true, features = [
"unstable_end_turn_token_usage",
] }
log = "0.4"
-base64 = { version = "0.23", optional = true }
+base64 = "0.23"
dirs = { version = "7", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
diff --git a/crates/agent/examples/probe.rs b/crates/agent/examples/probe.rs
index 9f1aee7d4..bca985114 100644
--- a/crates/agent/examples/probe.rs
+++ b/crates/agent/examples/probe.rs
@@ -10,7 +10,8 @@
//! Turn mode: `probe [cwd] [acp-command args…] [flags]`.
//! Flags are `--binary `, `--model `, `--option =`, `--effort `,
//! `--resume `, `--fork`, `--leave-questions` (user-input requests
-//! stay unanswered), `--mcp ` (an HTTP MCP server registered
+//! stay unanswered), `--plan-feedback ` (the first plan decision is
+//! answered with these words instead of approved), `--mcp ` (an HTTP MCP server registered
//! as tcode registers its own), `--linger ` (stay open until no turn has
//! run for that long, to see turns the agent starts itself), `--follow-up
//! ` (send another turn that long after the first completes,
@@ -231,6 +232,7 @@ fn main() {
let mut resume = None;
let mut fork = false;
let mut leave_questions = false;
+ let mut plan_feedback = None;
let mut mcp_servers = Vec::new();
let mut continuation = Continuation::default();
let mut probe_mode = ProbeMode::Standard;
@@ -261,6 +263,7 @@ fn main() {
"--fork" => fork = true,
"--goal" => set_mode(&mut probe_mode, ProbeMode::Goal),
"--leave-questions" => leave_questions = true,
+ "--plan-feedback" => plan_feedback = args.next(),
"--linger" => {
let seconds = args.next().and_then(|value| value.parse().ok());
continuation.linger = Some(Duration::from_secs(seconds.unwrap_or_else(|| usage())));
@@ -350,6 +353,7 @@ fn main() {
resume,
fork,
leave_questions,
+ plan_feedback,
},
binary,
model,
@@ -361,6 +365,7 @@ struct Resumption {
resume: Option,
fork: bool,
leave_questions: bool,
+ plan_feedback: Option,
}
#[derive(Default)]
@@ -380,7 +385,7 @@ async fn run_probe(
acp: Option,
mcp_servers: Vec,
continuation: Continuation,
- resumption: Resumption,
+ mut resumption: Resumption,
binary_path: Option,
model: Option,
) -> i32 {
@@ -539,16 +544,21 @@ async fn run_probe(
}
match &event {
AgentEvent::ApprovalRequested(request) => {
+ let feedback = matches!(request.kind, agent::ApprovalKind::Plan { .. })
+ .then(|| resumption.plan_feedback.take())
+ .flatten();
handle
.commands
.send(SessionCommand::RespondApproval {
request_id: request.id.clone(),
- decision: request
- .options
- .iter()
- .find(|option| option.kind == agent::ApprovalOptionKind::AllowOnce)
- .map(|option| ApprovalDecision::Option(option.id.clone()))
- .unwrap_or(ApprovalDecision::Cancel),
+ decision: feedback.map(ApprovalDecision::Feedback).unwrap_or_else(|| {
+ request
+ .options
+ .iter()
+ .find(|option| option.kind == agent::ApprovalOptionKind::AllowOnce)
+ .map(|option| ApprovalDecision::Option(option.id.clone()))
+ .unwrap_or(ApprovalDecision::Cancel)
+ }),
})
.await
.ok();
diff --git a/crates/agent/src/acp_session.rs b/crates/agent/src/acp_session.rs
index 165bfc9df..54c41c62d 100644
--- a/crates/agent/src/acp_session.rs
+++ b/crates/agent/src/acp_session.rs
@@ -36,7 +36,8 @@ use crate::{
ApprovalRequest, Attachment, DeltaKind, FileChange, FileChangeKind, ItemContent, ItemStatus,
McpRegistration, OptionDescriptor, OptionSelection, PlanStep, PlanStepStatus, ProviderCommand,
ProviderCommandKind, ProviderKind, ResumeCursor, SelectOption, SessionCommand, SessionHandle,
- SessionOptions, ThreadItem, TokenUsage, TurnStatus, UserInputDelivery, UserInputQuestion,
+ SessionOptions, ThreadItem, TokenUsage, ToolOutput, TurnStatus, UserInputDelivery,
+ UserInputQuestion,
};
/// Option-descriptor ids. The composer renders an ACP agent's own
@@ -330,6 +331,74 @@ impl Session {
f(&mut self.state.lock_recover())
}
+ /// Ask for a decision and wait for [`SessionCommand::RespondApproval`]; an
+ /// interrupt or shutdown decides [`ApprovalDecision::Cancel`].
+ pub(crate) async fn ask_approval(
+ &self,
+ kind: ApprovalKind,
+ options: Vec,
+ ) -> ApprovalDecision {
+ let (responder, decided) = smol::channel::bounded(1);
+ let (id, turn_id) = self.with_state(|state| {
+ state.approval_seq += 1;
+ let id = format!("acp-approval-{}", state.approval_seq);
+ state.approvals.insert(id.clone(), responder);
+ (id, state.turn().map(str::to_owned))
+ });
+ self.emit(AgentEvent::ApprovalRequested(ApprovalRequest {
+ id: id.clone(),
+ turn_id,
+ parent_item_id: None,
+ kind,
+ options,
+ }))
+ .await;
+ let decision = decided.recv().await.unwrap_or(ApprovalDecision::Cancel);
+ self.with_state(|state| state.approvals.remove(&id));
+ decision
+ }
+
+ /// Show the plan a dialect's plan request carries in the timeline, then
+ /// ask for its decision among the dialect's own `options`. `params` holds
+ /// the plan under `field` and the agent's `toolCallId`.
+ pub(crate) async fn ask_plan(
+ &self,
+ params: &Value,
+ field: &str,
+ options: [(&str, &str, ApprovalOptionKind); N],
+ ) -> ApprovalDecision {
+ let markdown = params
+ .get(field)
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_owned();
+ let item_id = match params.get("toolCallId").and_then(Value::as_str) {
+ Some(id) => format!("plan:{id}"),
+ None => self.with_state(|state| {
+ state.approval_seq += 1;
+ format!("plan:{}", state.approval_seq)
+ }),
+ };
+ self.emit(AgentEvent::ItemCompleted(ThreadItem {
+ id: item_id.clone(),
+ parent_item_id: None,
+ content: ItemContent::Plan {
+ markdown: markdown.clone(),
+ },
+ }))
+ .await;
+ let options = options
+ .into_iter()
+ .map(|(id, label, kind)| ApprovalOption {
+ id: id.into(),
+ label: label.into(),
+ kind,
+ })
+ .collect();
+ self.ask_approval(ApprovalKind::Plan { item_id, markdown }, options)
+ .await
+ }
+
/// Ask the user and wait for [`SessionCommand::RespondUserInput`]. `None`
/// when the request was cancelled by an interrupt or shutdown.
pub(crate) async fn ask_user(
@@ -1089,24 +1158,11 @@ async fn handle_command(
decision,
} => {
let approval = state.lock_recover().approvals.remove(&request_id);
- let Some((responder, options)) = approval else {
+ let Some(responder) = approval else {
log::warn!("acp: no pending approval {request_id}");
return;
};
- let outcome = match approval_outcome(&decision, &options) {
- Some(outcome) => outcome,
- None => {
- let _ = events
- .send(AgentEvent::Warning {
- message:
- "the agent offered no matching permission option; cancelling instead"
- .into(),
- })
- .await;
- acp::RequestPermissionOutcome::Cancelled
- }
- };
- let _ = responder.send(outcome).await;
+ let _ = responder.send(decision.clone()).await;
let _ = events
.send(AgentEvent::ApprovalResolved {
request_id,
@@ -1200,7 +1256,7 @@ async fn cancel_pending(state: &Arc>, events: &Sender)
state
.approvals
.drain()
- .map(|(_, (responder, _))| responder)
+ .map(|(_, responder)| responder)
.collect(),
state
.inputs
@@ -1210,9 +1266,7 @@ async fn cancel_pending(state: &Arc>, events: &Sender)
)
};
for responder in approvals {
- let _ = responder
- .send(acp::RequestPermissionOutcome::Cancelled)
- .await;
+ let _ = responder.send(ApprovalDecision::Cancel).await;
}
for request_id in inputs {
let _ = events
@@ -1451,6 +1505,10 @@ fn approval_outcome(
.iter()
.any(|option| &option.id == id)
.then(|| id.clone()),
+ ApprovalDecision::Feedback(_) => options
+ .iter()
+ .find(|option| option.kind == ApprovalOptionKind::RejectOnce)
+ .map(|option| option.id.clone()),
}?;
Some(acp::RequestPermissionOutcome::Selected(
acp::SelectedPermissionOutcome::new(acp::PermissionOptionId::new(selected)),
@@ -1700,6 +1758,9 @@ fn descriptor_id(descriptor: &OptionDescriptor) -> &str {
/// merged state lives here and every update re-renders the whole item.
#[derive(Debug, Clone, Default)]
struct ToolState {
+ /// The tool's own name when the wire carries one; `title` is the agent's
+ /// human description of the call.
+ name: Option,
title: String,
kind: acp::ToolKind,
status: acp::ToolCallStatus,
@@ -1727,7 +1788,7 @@ pub(crate) struct State {
loading: bool,
tools: HashMap,
text: Option,
- approvals: HashMap, Vec)>,
+ approvals: HashMap>,
approval_seq: u64,
inputs: HashMap>>,
input_seq: u64,
@@ -1938,6 +1999,7 @@ impl State {
let mut events = self.flush_text();
let id = call.tool_call_id.0.to_string();
let tool = ToolState {
+ name: tool_name(call.meta.as_ref(), call.raw_input.as_ref()),
title: call.title,
kind: call.kind,
status: call.status,
@@ -1986,6 +2048,9 @@ impl State {
if let Some(raw_output) = fields.raw_output {
entry.raw_output = Some(raw_output);
}
+ if let Some(name) = tool_name(update.meta.as_ref(), entry.raw_input.as_ref()) {
+ entry.name = Some(name);
+ }
let announced = std::mem::replace(&mut entry.announced, true);
let status = entry.status;
let tool = entry.clone();
@@ -2090,6 +2155,9 @@ impl State {
for content in &tool.content {
match content {
acp::ToolCallContent::Content(block) => {
+ if matches!(block.content, acp::ContentBlock::Image(_)) {
+ continue;
+ }
let text = content_text(&block.content);
if !text.is_empty() {
parts.push(text);
@@ -2193,43 +2261,55 @@ fn command_of(tool: &ToolState) -> String {
}
fn tool_call_content(tool: &ToolState, status: ItemStatus, output: String) -> ItemContent {
+ let mut image_reads: Vec<_> = tool
+ .content
+ .iter()
+ .filter_map(|content| match content {
+ acp::ToolCallContent::Content(block) => match &block.content {
+ acp::ContentBlock::Image(image) => {
+ crate::tool_output::image(Some(&image.mime_type), &image.data)
+ }
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect();
+ if image_reads.is_empty()
+ && let Some(raw) = &tool.raw_output
+ {
+ image_reads = ToolOutput::from_content(raw.get("content").unwrap_or(raw)).images;
+ }
ItemContent::ToolCall {
- image_reads: if tool.kind == acp::ToolKind::Read && status == ItemStatus::Completed {
- let images: Vec<_> = tool
- .content
- .iter()
- .filter_map(|content| match content {
- acp::ToolCallContent::Content(block) => match &block.content {
- acp::ContentBlock::Image(image)
- if image.mime_type.starts_with("image/") && !image.data.is_empty() =>
- {
- Some(Attachment {
- media_type: image.mime_type.clone(),
- data_base64: image.data.clone(),
- source_path: None,
- })
- }
- _ => None,
- },
- _ => None,
- })
- .collect();
- if images.is_empty() {
- let raw = tool.raw_output.as_ref().unwrap_or(&Value::Null);
- crate::image_reads::content_images(raw.get("content").unwrap_or(raw))
- } else {
- images
- }
- } else {
- Vec::new()
- },
- name: tool.title.clone(),
+ image_reads,
+ name: tool.name.clone().unwrap_or_else(|| tool.title.clone()),
input: tool.raw_input.clone().unwrap_or(Value::Null),
output: (!output.is_empty()).then_some(output),
status,
}
}
+/// The tool's own name, where the wire has one: Grok's `_meta["x.ai/tool"]`
+/// (whose `use_tool` dispatches the MCP tool named in its input), Cursor's
+/// `rawInput._toolName`.
+fn tool_name(meta: Option<&acp::Meta>, raw_input: Option<&Value>) -> Option {
+ let input = |key: &str| {
+ raw_input
+ .and_then(|input| input.get(key))
+ .and_then(Value::as_str)
+ .filter(|name| !name.is_empty())
+ };
+ let name = match meta
+ .and_then(|meta| meta.get("x.ai/tool"))
+ .and_then(|tool| tool.get("name"))
+ .and_then(Value::as_str)
+ {
+ Some("use_tool") => input("tool_name").or(Some("use_tool")),
+ Some(name) => Some(name),
+ None => input("_toolName"),
+ };
+ name.map(str::to_owned)
+}
+
/// `diff` content blocks → canonical [`FileChange`]s (with a unified diff, so
/// the diff panel renders them like every other provider's edits).
fn file_changes(tool: &ToolState) -> Vec {
@@ -2250,6 +2330,7 @@ fn file_changes(tool: &ToolState) -> Vec {
&path,
diff.old_text.as_deref().unwrap_or(""),
&diff.new_text,
+ hunk_start(diff.meta.as_ref()),
)),
path,
})
@@ -2275,10 +2356,38 @@ fn file_changes(tool: &ToolState) -> Vec {
changes
}
-/// A whole-file unified diff. ACP hands us before/after text rather than a
-/// patch, and the diff panel wants `@@` hunks; a single hunk covering the file
+/// Where a diff's texts begin in the old and new file, when the agent says;
+/// ACP's own diff is whole-file text. Grok sends hunk-sized texts with
+/// `_meta.old_line` / `new_line` while asking, and with one
+/// `_meta.details[]` entry per replacement once applied.
+fn hunk_start(meta: Option<&acp::Meta>) -> (usize, usize) {
+ let lines = meta.and_then(|meta| match meta.get("details").and_then(Value::as_array) {
+ Some(details) => match details.as_slice() {
+ [only] => only.as_object(),
+ _ => None,
+ },
+ None => Some(meta),
+ });
+ let line = |key: &str| {
+ lines
+ .and_then(|lines| lines.get(key))
+ .and_then(Value::as_u64)
+ .and_then(|line| usize::try_from(line).ok())
+ .filter(|line| *line > 0)
+ .unwrap_or(1)
+ };
+ (line("old_line"), line("new_line"))
+}
+
+/// A unified diff of one before/after pair. ACP hands us texts rather than a
+/// patch, and the diff panel wants `@@` hunks; a single hunk covering the pair
/// is exactly what the panel's full-file path already renders.
-fn unified_diff(path: &str, old: &str, new: &str) -> String {
+fn unified_diff(
+ path: &str,
+ old: &str,
+ new: &str,
+ (old_start, new_start): (usize, usize),
+) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut out = String::new();
@@ -2295,9 +2404,9 @@ fn unified_diff(path: &str, old: &str, new: &str) -> String {
let _ = writeln!(
out,
"@@ -{},{} +{},{} @@",
- usize::from(!old_lines.is_empty()),
+ if old_lines.is_empty() { 0 } else { old_start },
old_lines.len(),
- usize::from(!new_lines.is_empty()),
+ if new_lines.is_empty() { 0 } else { new_start },
new_lines.len()
);
// Trim the common prefix/suffix so the (very common) single-line edit does
@@ -2412,7 +2521,8 @@ fn approval_request(
ApprovalKind::FileRead { detail: title }
}
_ => ApprovalKind::ToolUse {
- name: title.clone(),
+ name: tool_name(tool.meta.as_ref(), fields.raw_input.as_ref())
+ .unwrap_or_else(|| title.clone()),
input: raw_input,
detail: title,
},
@@ -2473,12 +2583,11 @@ impl Client {
)
};
let request = approval_request(request_id.clone(), turn, &args.tool_call, &args.options);
+ let options = request.options.clone();
let (responder, decided) = smol::channel::bounded(1);
{
let mut state = self.state.lock_recover();
- state
- .approvals
- .insert(request_id.clone(), (responder, request.options.clone()));
+ state.approvals.insert(request_id.clone(), responder);
// Keep the tool card in step with what we are asking about.
let id = args.tool_call.tool_call_id.0.to_string();
let entry = state.tools.entry(id).or_default();
@@ -2497,11 +2606,22 @@ impl Client {
.send(AgentEvent::ApprovalRequested(request))
.await;
- let outcome = decided
- .recv()
- .await
- .unwrap_or(acp::RequestPermissionOutcome::Cancelled);
+ let decision = decided.recv().await.unwrap_or(ApprovalDecision::Cancel);
self.state.lock_recover().approvals.remove(&request_id);
+ let outcome = match approval_outcome(&decision, &options) {
+ Some(outcome) => outcome,
+ None => {
+ let _ = self
+ .events
+ .send(AgentEvent::Warning {
+ message:
+ "the agent offered no matching permission option; cancelling instead"
+ .into(),
+ })
+ .await;
+ acp::RequestPermissionOutcome::Cancelled
+ }
+ };
Ok(acp::RequestPermissionResponse::new(outcome))
}
@@ -3091,27 +3211,34 @@ mod tests {
);
}
+ /// ACP carries a tool's images as `image` content blocks: they become
+ /// the item's images whatever the tool's kind, never text.
#[test]
- fn image_read_results_follow_partial_updates_and_exclude_generation() {
- for (kind, name, status, expected) in [
- ("read", "file", "completed", 1),
- ("read", "file", "failed", 0),
- ("other", "generate_image", "completed", 0),
- ] {
+ fn image_content_blocks_become_image_reads() {
+ for kind in ["read", "other"] {
let mut mapper = state();
- mapper.apply_update(update(json!({"sessionUpdate":"tool_call","toolCallId":"read","title":"Image operation","kind":kind,"name":name,"status":"in_progress"})));
- let events = mapper.apply_update(update(json!({"sessionUpdate":"tool_call_update","toolCallId":"read","status":status,"content":[{"type":"content","content":{"type":"image","mimeType":"image/png","data":"AQID"}}]})));
- let AgentEvent::ItemCompleted(ThreadItem {
- content: ItemContent::ToolCall { image_reads, .. },
- ..
- }) = &events[0]
+ mapper.apply_update(update(json!({"sessionUpdate":"tool_call","toolCallId":"shot","title":"Screenshot","kind":kind,"status":"in_progress"})));
+ let events = mapper.apply_update(update(json!({"sessionUpdate":"tool_call_update","toolCallId":"shot","status":"completed","content":[
+ {"type":"content","content":{"type":"text","text":"captured"}},
+ {"type":"content","content":{"type":"image","mimeType":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEElEQVR4nGNgYPiPAw0pCQCpcD/BFMrqcwAAAABJRU5ErkJggg=="}}
+ ]})));
+ let [
+ AgentEvent::ItemCompleted(ThreadItem {
+ content:
+ ItemContent::ToolCall {
+ image_reads,
+ output,
+ ..
+ },
+ ..
+ }),
+ ] = &events[..]
else {
panic!("tool completion: {events:?}")
};
- assert_eq!(image_reads.len(), expected, "{kind}, {name}, {status}");
- if expected != 0 {
- assert_eq!(image_reads[0].data_base64, "AQID");
- }
+ assert_eq!(output.as_deref(), Some("captured"), "{kind}");
+ assert_eq!(image_reads.len(), 1, "{kind}");
+ assert_eq!(image_reads[0].media_type, "image/png");
}
}
diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs
index 87dd3def9..42c539347 100644
--- a/crates/agent/src/claude.rs
+++ b/crates/agent/src/claude.rs
@@ -40,8 +40,8 @@ use crate::{
FileChangeKind, GoalStatus, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor,
OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind,
ResumeCursor, RewindMode, SessionCommand, SessionHandle, SessionOptions, ThreadItem,
- TokenUsage, TurnStatus, UserInputDelivery, UserInputOption, UserInputQuestion, selection_bool,
- selection_str,
+ TokenUsage, ToolOutput, TurnStatus, UserInputDelivery, UserInputOption, UserInputQuestion,
+ selection_bool, selection_str,
};
/// First Claude Code build whose headless control protocol is verified to
@@ -1205,7 +1205,7 @@ fn user_message(text: &str, attachments: &[Attachment]) -> Value {
/// Remembers what kind of tool-use item a `tool_use_id` refers to, so that when
/// the matching `tool_result` arrives we can emit the right `ItemCompleted`.
-enum ToolItem {
+pub(crate) enum ToolItem {
Command {
command: String,
output: String,
@@ -1217,6 +1217,9 @@ enum ToolItem {
name: String,
input: Value,
},
+ Plan {
+ markdown: String,
+ },
Subagent {
agent_type: String,
description: String,
@@ -1230,6 +1233,124 @@ enum ToolItem {
}
impl ToolItem {
+ /// The in-progress item for a call that is not an `Agent` spawn,
+ /// `TodoWrite` or `ExitPlanMode`, and the state its result completes.
+ /// Calls made at the top level and inside a subagent map alike.
+ pub(crate) fn started(name: String, input: Value) -> (Self, ItemContent) {
+ if name == "Bash" {
+ let command = input
+ .get("command")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .to_string();
+ (
+ ToolItem::Command {
+ command: command.clone(),
+ output: String::new(),
+ },
+ ItemContent::CommandExecution {
+ command,
+ output: String::new(),
+ exit_code: None,
+ status: ItemStatus::InProgress,
+ },
+ )
+ } else if is_file_tool(&name) {
+ let changes = file_changes(&name, &input);
+ (
+ ToolItem::File {
+ changes: changes.clone(),
+ },
+ ItemContent::FileChange {
+ changes,
+ status: ItemStatus::InProgress,
+ },
+ )
+ } else {
+ (
+ ToolItem::Tool {
+ name: name.clone(),
+ input: input.clone(),
+ },
+ ItemContent::ToolCall {
+ image_reads: Vec::new(),
+ name,
+ input,
+ output: None,
+ status: ItemStatus::InProgress,
+ },
+ )
+ }
+ }
+
+ /// The finished item for a call from its `tool_result` block and the
+ /// message's `tool_use_result` (`toolUseResult` in transcript files).
+ pub(crate) fn finished(self, block: &Value, tool_use_result: Option<&Value>) -> ItemContent {
+ let is_error = block
+ .get("is_error")
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+ let status = if is_error {
+ ItemStatus::Failed
+ } else {
+ ItemStatus::Completed
+ };
+ let ToolOutput { text, images } =
+ ToolOutput::from_content(block.get("content").unwrap_or(&Value::Null));
+ match self {
+ ToolItem::Command { command, .. } => ItemContent::CommandExecution {
+ command,
+ output: text,
+ exit_code: Some(i32::from(is_error)),
+ status,
+ },
+ ToolItem::File { mut changes } => {
+ if let Some(structured_patch) =
+ tool_use_result.and_then(|result| result.get("structuredPatch"))
+ {
+ let diff = render_structured_patch(structured_patch);
+ let path = tool_use_result
+ .and_then(|result| result.get("filePath"))
+ .and_then(Value::as_str);
+ for change in &mut changes {
+ if let Some(path) = path {
+ change.path = path.to_owned();
+ }
+ // Successful writes can report `structuredPatch: []`;
+ // that omission must not erase the input-derived diff.
+ if let Some(diff) = &diff {
+ change.diff = Some(diff.clone());
+ }
+ }
+ }
+ ItemContent::FileChange { changes, status }
+ }
+ ToolItem::Tool { name, input } => ItemContent::ToolCall {
+ image_reads: images,
+ name,
+ input,
+ output: Some(text),
+ status,
+ },
+ ToolItem::Plan { markdown } => ItemContent::Plan { markdown },
+ ToolItem::Subagent {
+ agent_type,
+ description,
+ summary,
+ model,
+ effort,
+ ..
+ } => ItemContent::Subagent {
+ agent_type,
+ description,
+ status,
+ summary,
+ model,
+ effort,
+ },
+ }
+ }
+
fn subagent(content: &ItemContent, parent_item_id: Option) -> Option {
let ItemContent::Subagent {
agent_type,
@@ -1559,6 +1680,9 @@ impl Mapper {
let suggestions = pending.suggestions.get(&id)?;
json!({ "behavior": "allow", "updatedInput": pending.input, "updatedPermissions": suggestions })
}
+ ApprovalDecision::Feedback(text) => json!({
+ "behavior": "deny", "message": text,
+ }),
ApprovalDecision::Cancel => json!({
"behavior": "deny", "message": "User cancelled tool execution.",
}),
@@ -2561,49 +2685,16 @@ impl Mapper {
let content = spawned_subagent(&input);
let item = ToolItem::subagent(&content, None).expect("a spawn snapshot is a Subagent");
(item, content)
- } else if name == "Bash" {
- let command = input
- .get("command")
- .and_then(Value::as_str)
- .unwrap_or("")
- .to_string();
+ } else if name == EXIT_PLAN_MODE {
+ let markdown = plan_markdown(&input);
(
- ToolItem::Command {
- command: command.clone(),
- output: String::new(),
- },
- ItemContent::CommandExecution {
- command,
- output: String::new(),
- exit_code: None,
- status: ItemStatus::InProgress,
- },
- )
- } else if is_file_tool(&name) {
- let changes = file_changes(&name, &input);
- (
- ToolItem::File {
- changes: changes.clone(),
- },
- ItemContent::FileChange {
- changes,
- status: ItemStatus::InProgress,
+ ToolItem::Plan {
+ markdown: markdown.clone(),
},
+ ItemContent::Plan { markdown },
)
} else {
- (
- ToolItem::Tool {
- name: name.clone(),
- input: input.clone(),
- },
- ItemContent::ToolCall {
- image_reads: Vec::new(),
- name,
- input,
- output: None,
- status: ItemStatus::InProgress,
- },
- )
+ ToolItem::started(name, input)
};
self.tool_items.insert(tool_use_id.clone(), item);
@@ -2674,7 +2765,8 @@ impl Mapper {
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
- let output = tool_result_text(block.get("content"));
+ let output =
+ ToolOutput::from_content(block.get("content").unwrap_or(&Value::Null)).text;
let status = if is_error {
ItemStatus::Failed
} else {
@@ -2697,45 +2789,10 @@ impl Mapper {
status: ItemStatus::InProgress,
}
}
- ToolItem::Command { command, .. } => ItemContent::CommandExecution {
- command,
- output,
- exit_code: if is_error { Some(1) } else { Some(0) },
- status,
- },
- ToolItem::File { mut changes } => {
- if let Some(structured_patch) = msg.pointer("/tool_use_result/structuredPatch")
- {
- let diff = render_structured_patch(structured_patch);
- let path = msg
- .pointer("/tool_use_result/filePath")
- .and_then(Value::as_str);
- for change in &mut changes {
- if let Some(path) = path {
- change.path = path.to_owned();
- }
- // Successful writes can report `structuredPatch: []`;
- // that omission must not erase the input-derived diff.
- if let Some(diff) = &diff {
- change.diff = Some(diff.clone());
- }
- }
- }
- ItemContent::FileChange { changes, status }
- }
- ToolItem::Tool { name, input } => ItemContent::ToolCall {
- image_reads: if !is_error && crate::image_reads::is_read_tool(&name) {
- crate::image_reads::content_images(
- block.get("content").unwrap_or(&Value::Null),
- )
- } else {
- Vec::new()
- },
- name,
- input,
- output: Some(output),
- status,
- },
+ item @ (ToolItem::Command { .. }
+ | ToolItem::File { .. }
+ | ToolItem::Tool { .. }
+ | ToolItem::Plan { .. }) => item.finished(block, msg.get("tool_use_result")),
ToolItem::Subagent {
agent_type,
description,
@@ -2924,6 +2981,10 @@ impl Mapper {
}];
}
+ if tool_name == EXIT_PLAN_MODE {
+ return self.plan_approval(request_id, request, input);
+ }
+
let request_type = classify_claude_tool(&tool_name);
let detail = approval_detail(&tool_name, &input);
@@ -3012,6 +3073,79 @@ impl Mapper {
})]
}
+ /// `ExitPlanMode`'s permission prompt: the plan item it decides on (from
+ /// the tool's own item, emitted here when the prompt arrives first), then
+ /// the decision with Claude Code's allow and deny under plan names.
+ fn plan_approval(
+ &mut self,
+ request_id: String,
+ request: &Value,
+ input: Value,
+ ) -> Vec {
+ let markdown = plan_markdown(&input);
+ let item_id = request
+ .get("tool_use_id")
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .unwrap_or_else(|| format!("plan-{request_id}"));
+ let mut events = Vec::new();
+ let known = match self.tool_items.get(&item_id) {
+ Some(ToolItem::Plan { markdown: shown }) => Some(*shown == markdown),
+ _ => None,
+ };
+ if known != Some(true) {
+ self.tool_items.insert(
+ item_id.clone(),
+ ToolItem::Plan {
+ markdown: markdown.clone(),
+ },
+ );
+ let item = ThreadItem {
+ id: item_id.clone(),
+ parent_item_id: None,
+ content: ItemContent::Plan {
+ markdown: markdown.clone(),
+ },
+ };
+ events.push(if known.is_some() {
+ AgentEvent::ItemUpdated(item)
+ } else {
+ AgentEvent::ItemStarted(item)
+ });
+ }
+ self.pending_approvals.insert(
+ request_id.clone(),
+ PendingApproval {
+ input,
+ suggestions: HashMap::new(),
+ },
+ );
+ let parent_item_id = request
+ .get("agent_id")
+ .and_then(Value::as_str)
+ .and_then(|agent_id| self.task_tools.get(agent_id))
+ .cloned();
+ events.push(AgentEvent::ApprovalRequested(ApprovalRequest {
+ id: request_id,
+ turn_id: self.current_turn_id.clone(),
+ parent_item_id,
+ kind: ApprovalKind::Plan { item_id, markdown },
+ options: vec![
+ ApprovalOption {
+ id: "allow".into(),
+ label: "Approve".into(),
+ kind: ApprovalOptionKind::AllowOnce,
+ },
+ ApprovalOption {
+ id: "deny".into(),
+ label: "Keep planning".into(),
+ kind: ApprovalOptionKind::RejectOnce,
+ },
+ ],
+ }));
+ events
+ }
+
fn on_result(&mut self, msg: &Value) -> Vec {
if let Some(id) = msg.get("uuid").and_then(Value::as_str)
&& !self.result_ids.insert(id.to_owned())
@@ -3234,6 +3368,19 @@ fn subagent_description(input: &Value) -> String {
/// Classify approvals with an ordered substring matcher. Read-only matches
/// take precedence (`WebSearch` → `FileRead`); agent tools use `ToolUse` even
/// when their names also match command or file-change substrings.
+/// The tool Claude Code calls to present its plan and leave plan mode.
+const EXIT_PLAN_MODE: &str = "ExitPlanMode";
+
+/// The plan `ExitPlanMode` presents, which Claude Code fills in from the plan
+/// file it wrote.
+fn plan_markdown(input: &Value) -> String {
+ input
+ .get("plan")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_owned()
+}
+
fn classify_claude_tool(name: &str) -> ClaudeRequestType {
let n = name.to_lowercase();
if n == "read"
@@ -3436,26 +3583,6 @@ fn render_structured_patch(value: &Value) -> Option {
(!rendered.is_empty()).then(|| rendered.join("\n"))
}
-/// Flatten a `tool_result` content field (string or block array) into text.
-fn tool_result_text(content: Option<&Value>) -> String {
- match content {
- Some(Value::String(s)) => s.clone(),
- Some(Value::Array(blocks)) => {
- let mut parts = Vec::new();
- for block in blocks {
- if let Some(text) = block.get("text").and_then(Value::as_str) {
- parts.push(text.to_string());
- } else {
- parts.push(block.to_string());
- }
- }
- parts.join("\n")
- }
- Some(other) => other.to_string(),
- None => String::new(),
- }
-}
-
fn result_status(msg: &Value, stop_reason: Option<&str>) -> TurnStatus {
let subtype = msg.get("subtype").and_then(Value::as_str);
let is_error = msg.get("is_error").and_then(Value::as_bool);
@@ -3769,12 +3896,7 @@ mod tests {
let session =
json!({"type":"addRules", "rules":[{"toolName":"Write"}], "destination":"session"});
let user = json!({"type":"setMode", "mode":"acceptEdits", "destination":"userSettings"});
- for name in [
- "Write",
- "EnterPlanMode",
- "ExitPlanMode",
- "mcp__tcode_report__report_result",
- ] {
+ for name in ["Write", "EnterPlanMode", "mcp__tcode_report__report_result"] {
for choice in [
"allow",
"deny",
@@ -5045,42 +5167,76 @@ mod tests {
}
}
+ /// Recorded from Claude Code 2.1.316 (`claude -p --output-format
+ /// stream-json --verbose --model haiku` in a scratch directory holding an
+ /// 8x8 blue PNG and `a.txt`): a Read of the image, then a subagent that
+ /// runs Bash, reads and edits `a.txt`. The image is the result's only
+ /// content and reaches `image_reads` alone; the subagent's Bash and Edit
+ /// map as they do at the top level.
#[test]
- fn image_read_results_exclude_failed_reads_and_image_generation() {
- for (name, failed, expected) in [
- ("Read", false, 1),
- ("Read", true, 0),
- ("generate_image", false, 0),
- ("mcp__browser__preview_screenshot", false, 1),
- ] {
- let mut mapper = Mapper::new();
- feed(&mut mapper, &json!({"type":"assistant","message":{"id":"m","content":[{"type":"tool_use","id":"read","name":name,"input":{"file_path":"/tmp/image.png"}}]}}).to_string());
- let events = feed(&mut mapper, &json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"read","is_error":failed,"content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AQID"}}]}]}}).to_string());
- let AgentEvent::ItemCompleted(ThreadItem {
- content:
- ItemContent::ToolCall {
- image_reads,
- status,
- ..
- },
- ..
- }) = &events[0]
- else {
- panic!("tool completion: {events:?}")
- };
- assert_eq!(image_reads.len(), expected, "{name}, failed={failed}");
- assert_eq!(
- *status,
- if failed {
- ItemStatus::Failed
- } else {
- ItemStatus::Completed
+ fn recorded_image_read_and_subagent_tools_map_like_top_level_calls() {
+ let mut mapper = Mapper::new();
+ let mut completed = Vec::new();
+ for line in include_str!("../tests/fixtures/claude/tool_results_recorded.jsonl").lines() {
+ for event in feed(&mut mapper, line) {
+ if let AgentEvent::ItemCompleted(item) = event {
+ completed.push(item);
}
- );
- if expected != 0 {
- assert_eq!(image_reads[0].data_base64, "AQID");
}
}
+ let item = |id: &str| {
+ completed
+ .iter()
+ .find(|item| item.id == id)
+ .unwrap_or_else(|| panic!("{id} never completed: {completed:#?}"))
+ };
+ assert_eq!(
+ item("toolu_01HG34Q57eKf3Vmy7mipKtqD").content,
+ ItemContent::ToolCall {
+ image_reads: vec![Attachment {
+ media_type: "image/png".into(),
+ data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAEElEQVR4nGNgYPiPAw0pCQCpcD/BFMrqcwAAAABJRU5ErkJggg==".into(),
+ source_path: None,
+ }],
+ name: "Read".into(),
+ input: json!({"file_path": "blue.png"}),
+ output: Some(String::new()),
+ status: ItemStatus::Completed,
+ }
+ );
+ let child = |id: &str| {
+ let item = item(&format!("toolu_01DEp56X845RWkeSRLr9oGhE:{id}"));
+ assert_eq!(
+ item.parent_item_id.as_deref(),
+ Some("toolu_01DEp56X845RWkeSRLr9oGhE")
+ );
+ &item.content
+ };
+ assert_eq!(
+ child("toolu_014AnXC7pBrAgEgqpWymetDS"),
+ &ItemContent::CommandExecution {
+ command: "echo hi".into(),
+ output: "hi".into(),
+ exit_code: Some(0),
+ status: ItemStatus::Completed,
+ }
+ );
+ assert_eq!(
+ child("toolu_01QkfKor1fX5xFDBvWR1ZUEX"),
+ &ItemContent::FileChange {
+ changes: vec![FileChange {
+ path: "/private/tmp/b2rec/claude/a.txt".into(),
+ kind: FileChangeKind::Modify,
+ diff: Some("-hello\n+bye".into()),
+ }],
+ status: ItemStatus::Completed,
+ }
+ );
+ assert!(matches!(
+ child("toolu_01KLtmHwybFdAVQGeGUZEfvP"),
+ ItemContent::ToolCall { name, output: Some(output), .. }
+ if name == "Read" && output == "1\thello\n2\tworld\n3\t"
+ ));
}
#[test]
@@ -5501,6 +5657,80 @@ mod tests {
}
}
+ #[test]
+ fn exit_plan_mode_shows_its_plan_and_takes_the_decision_natively() {
+ let plan = "# Plan\n\n1. Write hello.txt";
+ let input = json!({"plan": plan, "planFilePath": "/plans/p.md"});
+ for (choice, expected) in [
+ (
+ ApprovalDecision::Option("allow".into()),
+ json!({"behavior": "allow", "updatedInput": input}),
+ ),
+ (
+ ApprovalDecision::Option("deny".into()),
+ json!({"behavior": "deny", "message": "User declined tool execution."}),
+ ),
+ (
+ ApprovalDecision::Feedback("Use Python instead".into()),
+ json!({"behavior": "deny", "message": "Use Python instead"}),
+ ),
+ ] {
+ let mut mapper = Mapper::new();
+ let started = mapper.on_message(json!({"type":"assistant", "message": {"id":"msg_plan",
+ "content":[{"type":"tool_use", "id":"toolu_plan", "name":"ExitPlanMode", "input":input}]}}));
+ assert!(
+ started.iter().any(|event| matches!(event, AgentEvent::ItemStarted(ThreadItem { id, content: ItemContent::Plan { markdown }, .. })
+ if id == "toolu_plan" && markdown == plan)),
+ "{started:?}"
+ );
+ let events = mapper.on_message(json!({"type":"control_request", "request_id":"plan-request",
+ "request": {"subtype":"can_use_tool", "tool_name":"ExitPlanMode", "input":input, "tool_use_id":"toolu_plan"}}));
+ let [AgentEvent::ApprovalRequested(request)] = events.as_slice() else {
+ panic!("the plan item is already shown: {events:?}")
+ };
+ assert_eq!(
+ request.kind,
+ ApprovalKind::Plan {
+ item_id: "toolu_plan".into(),
+ markdown: plan.into()
+ }
+ );
+ assert_eq!(
+ request
+ .options
+ .iter()
+ .map(|option| (option.id.as_str(), option.label.as_str(), option.kind))
+ .collect::>(),
+ [
+ ("allow", "Approve", ApprovalOptionKind::AllowOnce),
+ ("deny", "Keep planning", ApprovalOptionKind::RejectOnce)
+ ]
+ );
+ let response = mapper
+ .build_approval_response("plan-request", choice)
+ .unwrap();
+ assert_eq!(response["response"]["response"], expected);
+ let completed = mapper.on_message(json!({"type":"user", "message": {"content":[
+ {"type":"tool_result", "tool_use_id":"toolu_plan", "content":"User has approved your plan."}]}}));
+ assert!(
+ completed.iter().any(|event| matches!(event, AgentEvent::ItemCompleted(ThreadItem { id, content: ItemContent::Plan { markdown }, .. })
+ if id == "toolu_plan" && markdown == plan)),
+ "{completed:?}"
+ );
+ }
+
+ let mut mapper = Mapper::new();
+ let events = mapper.on_message(json!({"type":"control_request", "request_id":"early",
+ "request": {"subtype":"can_use_tool", "tool_name":"ExitPlanMode", "input":{"plan": plan}, "tool_use_id":"toolu_early"}}));
+ assert!(
+ matches!(events.as_slice(), [
+ AgentEvent::ItemStarted(ThreadItem { id, content: ItemContent::Plan { markdown }, .. }),
+ AgentEvent::ApprovalRequested(ApprovalRequest { kind: ApprovalKind::Plan { item_id, .. }, .. }),
+ ] if id == "toolu_early" && item_id == id && markdown == plan),
+ "a prompt that arrives before its tool block still shows the plan it decides: {events:?}"
+ );
+ }
+
#[test]
fn ask_user_question_parse_and_answer_wire_shape() {
let mut m = Mapper::new();
diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs
index 9f1bbe2bf..21f321f50 100644
--- a/crates/agent/src/codex.rs
+++ b/crates/agent/src/codex.rs
@@ -19,7 +19,7 @@ use crate::{
GoalStatus, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, OptionSelection,
PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor,
SelectOption, SessionCommand, SessionHandle, SessionOptions, ThreadItem, TokenUsage,
- TurnOptions, TurnStatus, UserInputDelivery, UserInputOption, UserInputQuestion,
+ ToolOutput, TurnOptions, TurnStatus, UserInputDelivery, UserInputOption, UserInputQuestion,
file_changes_from_unified_diff, permission_control, selection_str,
};
@@ -949,6 +949,11 @@ impl SessionActor for Actor {
};
let result = match &decision {
ApprovalDecision::Cancel => pending.cancel_reply.clone(),
+ ApprovalDecision::Feedback(_) => pending
+ .replies
+ .get("decline")
+ .cloned()
+ .unwrap_or_else(|| pending.cancel_reply.clone()),
ApprovalDecision::Option(id) => pending
.replies
.get(id)
@@ -1576,17 +1581,15 @@ impl Actor {
let path = self.cwd.join(path);
match smol::fs::read(&path).await {
Ok(bytes) => {
- *image = Some(crate::Attachment {
- media_type: match path.extension().and_then(|ext| ext.to_str()) {
+ *image = crate::tool_output::image(
+ Some(match path.extension().and_then(|ext| ext.to_str()) {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "image/png",
- }
- .into(),
- data_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
- source_path: None,
- });
+ }),
+ &base64::engine::general_purpose::STANDARD.encode(bytes),
+ );
self.items.insert(item.id.clone(), item.clone());
}
Err(error) => log::warn!("could not preserve image read {}: {error}", path.display()),
@@ -3571,7 +3574,10 @@ fn map_item(item: &Value) -> Option {
output: None,
status: ItemStatus::Completed,
},
- "agentMessage" | "plan" | "proposed_plan" => ItemContent::AssistantMessage {
+ "plan" | "proposed_plan" => ItemContent::Plan {
+ markdown: string_field(item, "text"),
+ },
+ "agentMessage" => ItemContent::AssistantMessage {
text: string_field(item, "text"),
summarized: false,
},
@@ -3599,40 +3605,36 @@ fn map_item(item: &Value) -> Option {
.unwrap_or_default(),
status: map_status(item.get("status").and_then(Value::as_str)),
},
- "mcpToolCall" | "dynamicToolCall" => ItemContent::ToolCall {
- image_reads: if item.get("status").and_then(Value::as_str) == Some("completed")
- && item.get("error").is_none_or(Value::is_null)
- && item.get("success").and_then(Value::as_bool) != Some(false)
- && item.pointer("/result/isError").and_then(Value::as_bool) != Some(true)
- && crate::image_reads::is_read_tool(&string_field(item, "tool"))
- {
- crate::image_reads::content_images(
- item.pointer("/result/content")
- .or_else(|| item.get("contentItems"))
- .unwrap_or(&Value::Null),
- )
- } else {
- Vec::new()
- },
- name: if provider_kind == "mcpToolCall" {
- format!(
- "{}/{}",
- string_field(item, "server"),
+ "mcpToolCall" | "dynamicToolCall" => {
+ let result = item
+ .get(if provider_kind == "mcpToolCall" {
+ "result"
+ } else {
+ "contentItems"
+ })
+ .filter(|value| !value.is_null());
+ let ToolOutput { text, images } =
+ result.map(ToolOutput::from_content).unwrap_or_default();
+ ItemContent::ToolCall {
+ image_reads: images,
+ name: if provider_kind == "mcpToolCall" {
+ format!(
+ "{}/{}",
+ string_field(item, "server"),
+ string_field(item, "tool")
+ )
+ } else {
string_field(item, "tool")
- )
- } else {
- string_field(item, "tool")
- },
- input: item.get("arguments").cloned().unwrap_or(Value::Null),
- output: if provider_kind == "mcpToolCall" {
- tool_output(item)
- } else {
- item.get("contentItems")
- .filter(|v| !v.is_null())
- .map(Value::to_string)
- },
- status: map_status(item.get("status").and_then(Value::as_str)),
- },
+ },
+ input: item.get("arguments").cloned().unwrap_or(Value::Null),
+ output: item
+ .pointer("/error/message")
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ .or(result.map(|_| text)),
+ status: map_status(item.get("status").and_then(Value::as_str)),
+ }
+ }
"collabAgentToolCall" => ItemContent::ToolCall {
image_reads: Vec::new(),
name: string_field(item, "tool"),
@@ -3769,15 +3771,6 @@ fn notification_turn_id(params: &Value, active_turn: &Option) -> Option<
.or_else(|| active_turn.clone())
}
-fn tool_output(item: &Value) -> Option {
- if let Some(message) = item.pointer("/error/message").and_then(Value::as_str) {
- return Some(message.into());
- }
- item.get("result")
- .filter(|v| !v.is_null())
- .map(Value::to_string)
-}
-
/// Map one `turn/plan/updated` step, falling back to `pending` status and
/// `"step"` text when those fields are absent or unusable.
/// A `ThreadGoal`; `null` (no goal) maps to `None`.
@@ -5668,7 +5661,7 @@ mod tests {
}
#[test]
- fn native_plan_items_stream_as_assistant_text() {
+ fn native_plan_items_stream_as_plans() {
smol::block_on(async {
let (mut actor, events) = test_actor();
actor
@@ -5680,7 +5673,7 @@ mod tests {
assert!(matches!(
events.recv().await.unwrap(),
AgentEvent::ItemStarted(ThreadItem {
- content: ItemContent::AssistantMessage { .. },
+ content: ItemContent::Plan { .. },
..
})
));
@@ -5695,7 +5688,7 @@ mod tests {
);
actor.handle_notification("item/completed", &json!({"item": {"type": "plan", "id": "native-plan", "text": "# Final native plan"}})).await;
assert!(
- matches!(events.recv().await.unwrap(), AgentEvent::ItemCompleted(ThreadItem { content: ItemContent::AssistantMessage { text, .. }, .. }) if text == "# Final native plan")
+ matches!(events.recv().await.unwrap(), AgentEvent::ItemCompleted(ThreadItem { content: ItemContent::Plan { markdown }, .. }) if markdown == "# Final native plan")
);
let _ = actor.child.kill();
let _ = actor.child.wait();
@@ -6180,30 +6173,64 @@ mod tests {
assert_eq!(codex_service_tier(&explicit).as_deref(), Some("flex"));
}
+ /// MCP results recorded from the app-server (each `result` as Tcode
+ /// stored it, base64 swapped for a 1x1 JPEG): the timeline keeps the text
+ /// the model read and moves image parts into `image_reads`, typed by their
+ /// bytes, whatever the tool.
#[test]
- fn image_read_results_exclude_failed_reads_and_image_generation() {
- for (name, status, expected) in [
- ("read_image", "completed", 1),
- ("read_image", "failed", 0),
- ("generate_image", "completed", 0),
- ] {
- for kind in ["mcpToolCall", "dynamicToolCall"] {
- let mut wire = json!({"type":kind,"id":"read","server":"files","tool":name,"status":status,
- "result":{"content":[{"type":"image","mimeType":"image/png","data":"AQID"}]},
- "contentItems":[{"type":"inputImage","imageUrl":"data:image/png;base64,AQID"}]});
- if kind == "dynamicToolCall" {
- wire.as_object_mut().unwrap().remove("result");
- }
- let item = map_item(&wire).unwrap();
- let ItemContent::ToolCall { image_reads, .. } = item.content else {
- panic!("tool")
- };
- assert_eq!(image_reads.len(), expected, "{kind}, {name}, {status}");
- if expected != 0 {
- assert_eq!(image_reads[0].data_base64, "AQID");
+ fn mcp_results_keep_their_text_and_move_images_into_image_reads() {
+ smol::block_on(async {
+ let (mut actor, events) = test_actor();
+ for line in include_str!("../tests/fixtures/codex/mcp_tool_results.jsonl").lines() {
+ actor.handle_line(line).await;
+ }
+ let mut completed = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AgentEvent::ItemCompleted(ThreadItem {
+ content:
+ ItemContent::ToolCall {
+ name,
+ output,
+ image_reads,
+ status,
+ ..
+ },
+ ..
+ }) = event
+ {
+ let images: Vec<_> = image_reads
+ .iter()
+ .map(|image| (image.media_type.clone(), image.data_base64.len()))
+ .collect();
+ completed.push((name, output.unwrap(), images, status));
}
}
- }
+ assert_eq!(
+ completed,
+ [
+ (
+ "cua_repl/js".to_owned(),
+ "The following is a diff from the previous accessibility tree for Window: \"\" with ~ and + representing changed and added elements, respectively. Removed elements are summarized by ID range.\nRemoved element IDs: 1-30\n+41 window Secondary Actions: Raise\nThe focused UI element is 41 window Secondary Actions: Raise".to_owned(),
+ vec![("image/jpeg".to_owned(), 216)],
+ ItemStatus::Completed,
+ ),
+ (
+ "cua_repl/js".to_owned(),
+ "Computer Use server error -10005: noWindowsAvailable".to_owned(),
+ vec![],
+ ItemStatus::Failed,
+ ),
+ (
+ "codex_apps/github.search_installed_repositories_v2".to_owned(),
+ "Action completed.\n{\"repositories\":[]}".to_owned(),
+ vec![],
+ ItemStatus::Completed,
+ ),
+ ]
+ );
+ let _ = actor.child.kill();
+ let _ = actor.child.wait();
+ });
}
#[test]
diff --git a/crates/agent/src/cursor.rs b/crates/agent/src/cursor.rs
index 18f4eae93..6b91ababa 100644
--- a/crates/agent/src/cursor.rs
+++ b/crates/agent/src/cursor.rs
@@ -22,9 +22,9 @@ use crate::acp_session::{
prompt_status,
};
use crate::{
- AgentError, AgentEvent, Attachment, LaunchEnv, ModelSpec, PlanStep, PlanStepStatus,
- ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TurnStatus, UserInputOption,
- UserInputQuestion,
+ AgentError, AgentEvent, ApprovalDecision, ApprovalOptionKind, Attachment, LaunchEnv, ModelSpec,
+ PlanStep, PlanStepStatus, ProviderKind, ResumeCursor, SessionHandle, SessionOptions,
+ TurnStatus, UserInputOption, UserInputQuestion,
};
use updates::Updates;
@@ -375,38 +375,29 @@ impl Dialect for Cursor {
if replaying {
return Ok(json!({"outcome": {"outcome": "cancelled"}}));
}
- let content = params.get("plan").and_then(Value::as_str).unwrap_or("");
- let answers = session
- .ask_user(vec![UserInputQuestion {
- id: "decision".into(),
- header: method,
- question: content.to_owned(),
- options: [
- ("Accept", "Let Cursor save the plan"),
- (
- "Reject",
- "Decline the plan; type a reason to provide feedback",
- ),
- ]
- .into_iter()
- .map(|(label, description)| UserInputOption {
- label: label.into(),
- description: description.into(),
- })
- .collect(),
- multi_select: false,
- prefill: None,
- }])
+ let decision = session
+ .ask_plan(
+ ¶ms,
+ "plan",
+ [
+ ("accept", "Accept", ApprovalOptionKind::AllowOnce),
+ ("reject", "Reject", ApprovalOptionKind::RejectOnce),
+ ],
+ )
.await;
- let answer = answers
- .as_ref()
- .and_then(|answers| answers.get("decision"))
- .and_then(Value::as_str);
- Ok(match answer {
- Some("Accept") => json!({"outcome": {"outcome": "accepted"}}),
- Some("Reject") => json!({"outcome": {"outcome": "rejected"}}),
- Some(reason) => json!({"outcome": {"outcome": "rejected", "reason": reason}}),
- None => json!({"outcome": {"outcome": "cancelled"}}),
+ Ok(match decision {
+ ApprovalDecision::Option(id) if id == "accept" => {
+ json!({"outcome": {"outcome": "accepted"}})
+ }
+ ApprovalDecision::Option(id) if id == "reject" => {
+ json!({"outcome": {"outcome": "rejected"}})
+ }
+ ApprovalDecision::Feedback(reason) => {
+ json!({"outcome": {"outcome": "rejected", "reason": reason}})
+ }
+ ApprovalDecision::Option(_) | ApprovalDecision::Cancel => {
+ json!({"outcome": {"outcome": "cancelled"}})
+ }
})
}
UPDATE_TODOS => {
diff --git a/crates/agent/src/cursor/tests.rs b/crates/agent/src/cursor/tests.rs
index 5dd54cea3..cdfc9a6da 100644
--- a/crates/agent/src/cursor/tests.rs
+++ b/crates/agent/src/cursor/tests.rs
@@ -12,8 +12,9 @@ use smol::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufR
use smol::net::{TcpListener, TcpStream};
use crate::{
- AgentEvent, ItemContent, ItemStatus, LaunchEnv, OptionSelection, PlanStepStatus, ResumeCursor,
- SessionCommand, SessionHandle, SessionOptions, ThreadItem, TurnStatus,
+ AgentEvent, ApprovalDecision, ApprovalKind, ItemContent, ItemStatus, LaunchEnv,
+ OptionSelection, PlanStepStatus, ResumeCursor, SessionCommand, SessionHandle, SessionOptions,
+ ThreadItem, TurnStatus,
};
const RECORDED: &str = include_str!("../../tests/fixtures/cursor/recorded_signed_out.jsonl");
@@ -714,7 +715,7 @@ fn questions_and_todos_are_answered_in_cursors_nested_shapes() {
}
#[test]
-fn native_plan_requests_return_the_users_choice_and_cancel_on_interrupt() {
+fn native_plan_requests_show_the_plan_and_return_the_users_decision() {
smol::block_on(bounded(async {
for grok in [false, true] {
let mut agent = Agent::new().await;
@@ -740,13 +741,22 @@ fn native_plan_requests_return_the_users_choice_and_cancel_on_interrupt() {
"_x.ai/exit_plan_mode",
json!({"sessionId": SESSION, "toolCallId": "native-plan", "planContent": "# Native content"}),
vec![
- ("Approve", json!({"outcome": "approved"})),
- ("Request changes", json!({"outcome": "cancelled"})),
(
- "Use another approach",
+ ApprovalDecision::Option("approve".into()),
+ json!({"outcome": "approved"}),
+ ),
+ (
+ ApprovalDecision::Option("keep_planning".into()),
+ json!({"outcome": "cancelled"}),
+ ),
+ (
+ ApprovalDecision::Feedback("Use another approach".into()),
json!({"outcome": "cancelled", "feedback": "Use another approach"}),
),
- ("Abandon", json!({"outcome": "abandoned"})),
+ (
+ ApprovalDecision::Option("abandon".into()),
+ json!({"outcome": "abandoned"}),
+ ),
],
)
} else {
@@ -754,35 +764,43 @@ fn native_plan_requests_return_the_users_choice_and_cancel_on_interrupt() {
"cursor/create_plan",
json!({"sessionId": SESSION, "toolCallId": "native-plan", "plan": "# Native content"}),
vec![
- ("Accept", json!({"outcome": {"outcome": "accepted"}})),
- ("Reject", json!({"outcome": {"outcome": "rejected"}})),
(
- "Use another approach",
+ ApprovalDecision::Option("accept".into()),
+ json!({"outcome": {"outcome": "accepted"}}),
+ ),
+ (
+ ApprovalDecision::Option("reject".into()),
+ json!({"outcome": {"outcome": "rejected"}}),
+ ),
+ (
+ ApprovalDecision::Feedback("Use another approach".into()),
json!({"outcome": {"outcome": "rejected", "reason": "Use another approach"}}),
),
],
)
};
- for (choice, expected) in cases {
+ for (decision, expected) in cases {
let requested = agent.request(method, params.clone()).await;
let events = events_until(&handle, |event| {
- matches!(event, AgentEvent::UserInputRequested { .. })
+ matches!(event, AgentEvent::ApprovalRequested(_))
})
.await;
- let Some(AgentEvent::UserInputRequested {
- request_id,
- questions,
- ..
- }) = events.last()
- else {
+ assert!(
+ events.iter().any(|event| matches!(event, AgentEvent::ItemCompleted(ThreadItem { id, content: ItemContent::Plan { markdown }, .. })
+ if id == "plan:native-plan" && markdown == "# Native content")),
+ "{events:?}"
+ );
+ let Some(AgentEvent::ApprovalRequested(request)) = events.last() else {
unreachable!()
};
- assert_eq!(questions[0].question, "# Native content");
+ assert!(
+ matches!(&request.kind, ApprovalKind::Plan { item_id, .. } if item_id == "plan:native-plan")
+ );
handle
.commands
- .send(SessionCommand::RespondUserInput {
- request_id: request_id.clone(),
- answers: Map::from_iter([("decision".into(), json!(choice))]),
+ .send(SessionCommand::RespondApproval {
+ request_id: request.id.clone(),
+ decision,
})
.await
.unwrap();
@@ -790,7 +808,7 @@ fn native_plan_requests_return_the_users_choice_and_cancel_on_interrupt() {
}
let requested = agent.request(method, params).await;
events_until(&handle, |event| {
- matches!(event, AgentEvent::UserInputRequested { .. })
+ matches!(event, AgentEvent::ApprovalRequested(_))
})
.await;
handle
diff --git a/crates/agent/src/grok.rs b/crates/agent/src/grok.rs
index d75695a39..194673b77 100644
--- a/crates/agent/src/grok.rs
+++ b/crates/agent/src/grok.rs
@@ -16,9 +16,9 @@ use crate::acp_session::{
prompt_status, stop_reason_status,
};
use crate::{
- AgentError, AgentEvent, Attachment, Compaction, ItemContent, LaunchEnv, ModelSpec,
- ProviderKind, ResumeCursor, SessionHandle, SessionOptions, TokenUsage, TurnStatus,
- UserInputOption, UserInputQuestion,
+ AgentError, AgentEvent, ApprovalDecision, ApprovalOptionKind, Attachment, Compaction,
+ ItemContent, LaunchEnv, ModelSpec, ProviderKind, ResumeCursor, SessionHandle, SessionOptions,
+ TokenUsage, TurnStatus, UserInputOption, UserInputQuestion,
};
/// The non-interactive auth method: it validates `XAI_API_KEY` (or the key in
@@ -654,42 +654,32 @@ impl Dialect for Grok {
params: Value,
) -> Result {
if method == EXIT_PLAN_MODE {
- let content = params
- .get("planContent")
- .and_then(Value::as_str)
- .unwrap_or("");
- let answers = session
- .ask_user(vec![UserInputQuestion {
- id: "decision".into(),
- header: method,
- question: content.to_owned(),
- options: [
- ("Approve", "Exit planning and begin implementation"),
+ let decision = session
+ .ask_plan(
+ ¶ms,
+ "planContent",
+ [
+ ("approve", "Approve", ApprovalOptionKind::AllowOnce),
(
- "Request changes",
- "Keep planning; type feedback to request specific changes",
+ "keep_planning",
+ "Keep planning",
+ ApprovalOptionKind::RejectOnce,
),
- ("Abandon", "Exit planning without implementation"),
- ]
- .into_iter()
- .map(|(label, description)| UserInputOption {
- label: label.into(),
- description: description.into(),
- })
- .collect(),
- multi_select: false,
- prefill: None,
- }])
+ ("abandon", "Abandon", ApprovalOptionKind::RejectAlways),
+ ],
+ )
.await;
- let answer = answers
- .as_ref()
- .and_then(|answers| answers.get("decision"))
- .and_then(Value::as_str);
- return Ok(match answer {
- Some("Approve") => json!({"outcome": "approved"}),
- Some("Abandon") | None => json!({"outcome": "abandoned"}),
- Some("Request changes") => json!({"outcome": "cancelled"}),
- Some(feedback) => json!({"outcome": "cancelled", "feedback": feedback}),
+ return Ok(match decision {
+ ApprovalDecision::Option(id) if id == "approve" => json!({"outcome": "approved"}),
+ ApprovalDecision::Option(id) if id == "keep_planning" => {
+ json!({"outcome": "cancelled"})
+ }
+ ApprovalDecision::Feedback(feedback) => {
+ json!({"outcome": "cancelled", "feedback": feedback})
+ }
+ ApprovalDecision::Option(_) | ApprovalDecision::Cancel => {
+ json!({"outcome": "abandoned"})
+ }
});
}
let questions = questions(¶ms)?;
@@ -1263,10 +1253,13 @@ mod tests {
Some(&("", Some(0)))
);
+ // The diff is the hunk Grok sent, placed at its `_meta` line.
assert!(matches!(completed(&events, "call_35_0"),
ItemContent::FileChange { changes, status: ItemStatus::Completed }
if changes.len() == 1 && changes[0].kind == FileChangeKind::Modify
- && changes[0].path.ends_with("/in.txt")));
+ && changes[0].path.ends_with("/in.txt")
+ && changes[0].diff.as_deref() == Some(
+ "--- a//private/tmp/grok-fixture/cwork/in.txt\n+++ b//private/tmp/grok-fixture/cwork/in.txt\n@@ -1,1 +1,1 @@\n-hello\n+bye\n")));
assert!(events.iter().any(|event| matches!(event,
AgentEvent::UserInputRequested { questions, .. }
if questions[0].id == "Which greeting should I use?")));
diff --git a/crates/agent/src/image_reads.rs b/crates/agent/src/image_reads.rs
deleted file mode 100644
index 31f76eed4..000000000
--- a/crates/agent/src/image_reads.rs
+++ /dev/null
@@ -1,78 +0,0 @@
-use serde_json::Value;
-
-use crate::Attachment;
-
-pub fn tool_result_images(name: &str, content: &Value) -> Vec {
- if is_read_tool(name) {
- content_images(content)
- } else {
- Vec::new()
- }
-}
-// Tool results are model inputs, but generated images are not read operations.
-// Only recognize explicit read/capture tools; an image block alone is insufficient.
-pub(crate) fn is_read_tool(name: &str) -> bool {
- let name = name.rsplit('/').next().unwrap_or(name);
- let name = name.rsplit("__").next().unwrap_or(name);
- let name = name.rsplit('.').next().unwrap_or(name);
- matches!(
- name.to_ascii_lowercase().as_str(),
- "read"
- | "read_file"
- | "read_image"
- | "view_image"
- | "image_view"
- | "viewimage"
- | "screenshot"
- | "take_screenshot"
- | "capture_screenshot"
- | "browser_take_screenshot"
- | "preview_screenshot"
- | "read_media_file"
- | "observe_ui"
- | "inspect_ui"
- | "expand_ui"
- | "search_ui"
- | "act_ui"
- | "wait_for"
- )
-}
-
-pub(crate) fn content_images(content: &Value) -> Vec {
- content
- .as_array()
- .into_iter()
- .flatten()
- .filter_map(|block| {
- match block.get("type").and_then(Value::as_str) {
- Some("inputImage") => return data_url(block.get("imageUrl")?.as_str()?),
- Some("input_image") => return data_url(block.get("image_url")?.as_str()?),
- Some("image") => {}
- _ => return None,
- }
- let source = block.get("source").unwrap_or(block);
- let media_type = source
- .get("mimeType")
- .or_else(|| source.get("media_type"))?
- .as_str()?;
- let data = source.get("data")?.as_str()?;
- if !media_type.starts_with("image/") || data.is_empty() {
- return None;
- }
- Some(Attachment {
- media_type: media_type.into(),
- data_base64: data.into(),
- source_path: None,
- })
- })
- .collect()
-}
-
-pub(crate) fn data_url(url: &str) -> Option {
- let (mime, data) = url.strip_prefix("data:")?.split_once(";base64,")?;
- (mime.starts_with("image/") && !data.is_empty()).then(|| Attachment {
- media_type: mime.into(),
- data_base64: data.into(),
- source_path: None,
- })
-}
diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index fa5550710..dc07493ca 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -21,8 +21,6 @@ mod claude_manifest;
#[cfg(feature = "process")]
mod claude_plugins;
pub mod context_window;
-mod image_reads;
-pub use image_reads::tool_result_images;
#[cfg(not(feature = "process"))]
pub mod claude {
pub use crate::claude_context::*;
@@ -43,6 +41,10 @@ mod process;
pub use process::keep_own_stdio_from_children;
#[cfg(feature = "process")]
mod subagent_tail;
+mod tool_name;
+pub use tool_name::ToolName;
+mod tool_output;
+pub use tool_output::ToolOutput;
use std::path::PathBuf;
@@ -70,7 +72,9 @@ pub enum ProviderKind {
pub struct Caps {
pub supports_steering: bool,
pub supports_fork: bool,
- pub native_rewind: bool,
+ /// What the harness restores of a checkpointed turn
+ /// ([`SessionCommand::Rewind`]), when it can.
+ pub native_rewind: Option,
/// The harness compacts its context on request ([`SessionCommand::Compact`]).
pub manual_compaction: bool,
/// How the harness's standing goal is driven ([`SessionCommand::SetGoal`]),
@@ -96,6 +100,23 @@ pub struct Caps {
pub account_login: bool,
}
+/// What a harness restores of a turn it checkpointed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RewindControl {
+ /// The scopes it restores, in the order a menu offers them.
+ pub modes: &'static [RewindMode],
+ /// The conversation can be restored to before the thread's first turn.
+ pub first_turn_conversation: bool,
+}
+
+impl RewindControl {
+ /// Whether `mode` can restore turn `turn`, counted from the thread's first.
+ pub fn offers(self, mode: RewindMode, turn: usize) -> bool {
+ self.modes.contains(&mode)
+ && (turn > 0 || self.first_turn_conversation || !mode.includes_conversation())
+ }
+}
+
/// What a harness lets a client do with its standing goal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GoalControl {
@@ -110,12 +131,11 @@ pub struct GoalControl {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstructionChannel {
- /// Appended to the system prompt once per process, on creation and resume alike.
+ /// Part of the session's system instructions, set at every start, on creation and resume
+ /// alike (Claude Code's appended system prompt, an OpenCode session instruction entry).
SessionAppend,
/// Developer context Codex re-sends when it changes; restored after a compaction.
DeveloperContext,
- /// The system field of every prompt.
- PerPromptSystem,
/// A content block of its own after the user's text on every prompt that is not a native
/// `/command`, while the session attached Tcode's MCP servers.
PerPromptUserBlock,
@@ -166,7 +186,16 @@ impl ProviderKind {
ProviderKind::ClaudeCode => Caps {
supports_steering: true,
supports_fork: true,
- native_rewind: true,
+ native_rewind: Some(RewindControl {
+ modes: &[
+ RewindMode::FilesAndConversation,
+ RewindMode::Conversation,
+ RewindMode::Files,
+ ],
+ // Claude Code 2.1.214 cannot persist a conversation rewind
+ // before the first assistant anchor.
+ first_turn_conversation: false,
+ }),
manual_compaction: true,
goals: Some(GoalControl {
pause: false,
@@ -197,7 +226,7 @@ impl ProviderKind {
ProviderKind::Codex => Caps {
supports_steering: true,
supports_fork: true,
- native_rewind: false,
+ native_rewind: None,
manual_compaction: true,
goals: Some(GoalControl {
pause: true,
@@ -228,7 +257,7 @@ impl ProviderKind {
ProviderKind::Acp => Caps {
supports_steering: false,
supports_fork: false,
- native_rewind: false,
+ native_rewind: None,
manual_compaction: false,
goals: None,
per_turn_effort: false,
@@ -249,7 +278,7 @@ impl ProviderKind {
ProviderKind::Pi => Caps {
supports_steering: true,
supports_fork: false,
- native_rewind: false,
+ native_rewind: None,
manual_compaction: true,
goals: None,
per_turn_effort: false,
@@ -266,14 +295,19 @@ impl ProviderKind {
account_login: false,
},
ProviderKind::OpenCode => Caps {
- supports_steering: false,
- supports_fork: false,
- native_rewind: false,
- manual_compaction: false,
+ supports_steering: true,
+ supports_fork: true,
+ // A revert restores the conversation, with or without the
+ // files its steps changed; there is no files-only revert.
+ native_rewind: Some(RewindControl {
+ modes: &[RewindMode::FilesAndConversation, RewindMode::Conversation],
+ first_turn_conversation: true,
+ }),
+ manual_compaction: true,
goals: None,
per_turn_effort: true,
options_apply_live: false,
- live_option_push: LiveOptionPush::None,
+ live_option_push: LiveOptionPush::Only(&["agent"]),
mcp_servers: true,
native_computer_use: false,
launch_args: true,
@@ -281,13 +315,13 @@ impl ProviderKind {
home_path: false,
trust_project_extensions: false,
plugin_management: PluginManagement::NONE,
- instruction_channel: InstructionChannel::PerPromptSystem,
+ instruction_channel: InstructionChannel::SessionAppend,
account_login: false,
},
ProviderKind::Cursor => Caps {
supports_steering: false,
supports_fork: false,
- native_rewind: false,
+ native_rewind: None,
manual_compaction: false,
goals: None,
per_turn_effort: false,
@@ -306,7 +340,7 @@ impl ProviderKind {
ProviderKind::Grok => Caps {
supports_steering: true,
supports_fork: true,
- native_rewind: false,
+ native_rewind: None,
manual_compaction: false,
goals: None,
per_turn_effort: false,
@@ -2150,6 +2184,13 @@ pub enum ItemContent {
Reasoning {
text: String,
},
+ /// A plan the harness proposed on its way out of its own plan mode
+ /// (Claude Code's `ExitPlanMode`, Grok's `exit_plan_mode`, Cursor's
+ /// `create_plan`, Codex's plan item). When the harness waits on a decision
+ /// it also asks an [`ApprovalKind::Plan`] naming this item.
+ Plan {
+ markdown: String,
+ },
CommandExecution {
command: String,
output: String,
@@ -2355,6 +2396,10 @@ pub enum ApprovalKind {
input: serde_json::Value,
detail: String,
},
+ /// Leave the harness's plan mode on the plan in [`ItemContent::Plan`]
+ /// item `item_id`. A reply in the user's own words is
+ /// [`ApprovalDecision::Feedback`]: the harness keeps planning from it.
+ Plan { item_id: String, markdown: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -2365,6 +2410,10 @@ pub enum ApprovalDecision {
Cancel,
/// Pick one of the provider's own [`ApprovalOption`]s.
Option(String),
+ /// Decline in the user's own words, which the agent continues from:
+ /// Claude denies with them as the message, Grok and Cursor return them as
+ /// plan feedback. A provider with no channel for words declines once.
+ Feedback(String),
}
impl<'de> Deserialize<'de> for ApprovalDecision {
@@ -2374,6 +2423,7 @@ impl<'de> Deserialize<'de> for ApprovalDecision {
enum StoredDecision {
Cancel,
Option(String),
+ Feedback(String),
Approve,
ApproveForSession,
Deny,
@@ -2381,6 +2431,7 @@ impl<'de> Deserialize<'de> for ApprovalDecision {
Ok(match StoredDecision::deserialize(deserializer)? {
StoredDecision::Cancel => Self::Cancel,
StoredDecision::Option(id) => Self::Option(id),
+ StoredDecision::Feedback(text) => Self::Feedback(text),
// These ids are history-only and never occur in live request options.
StoredDecision::Approve => Self::Option("legacy:approve".into()),
StoredDecision::ApproveForSession => Self::Option("legacy:approve_for_session".into()),
diff --git a/crates/agent/src/opencode.rs b/crates/agent/src/opencode.rs
index db2e0d96b..8498e2d24 100644
--- a/crates/agent/src/opencode.rs
+++ b/crates/agent/src/opencode.rs
@@ -1,36 +1,46 @@
-//! Native OpenCode provider: one authenticated `opencode serve` child per
-//! tcode session, REST commands, and an SSE event stream.
+//! Native OpenCode provider for OpenCode 2.x: one private `opencode serve
+//! --stdio` per Tcode session, the server's `/api` routes, and its
+//! `/api/event` SSE stream.
use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader, Read};
-use std::net::{Ipv4Addr, TcpListener};
use std::path::{Path, PathBuf};
-use std::process::{Child, Stdio};
+use std::process::{Child, ChildStdin, Stdio};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use base64::Engine as _;
-use serde_json::{Value, json};
+use serde_json::{Map, Value, json};
use smol::channel::{Receiver, Sender};
use smol::future;
-#[cfg(test)]
-use crate::TurnStatus;
use crate::actor::{self, EventSenderExt as _, SessionActor, TransportOutcome};
use crate::{
AgentError, AgentEvent, ApprovalDecision, ApprovalKind, ApprovalOption, ApprovalOptionKind,
- ApprovalRequest, Attachment, ChangeCompleteness, DeltaKind, FileChange, FileChangeKind,
- ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor, OptionSelection,
- ProviderCommand, ProviderCommandKind, ProviderKind, ResumeCursor, SelectOption, SessionCommand,
- SessionHandle, SessionOptions, ThreadItem, TokenUsage, UserInputDelivery, UserInputOption,
- UserInputQuestion,
+ ApprovalRequest, Attachment, ChangeCompleteness, Compaction, DeltaKind, FileChange,
+ FileChangeKind, ItemContent, ItemStatus, LaunchEnv, ModelSpec, OptionDescriptor,
+ OptionSelection, PlanStep, PlanStepStatus, ProviderCommand, ProviderCommandKind, ProviderKind,
+ ResumeCursor, RewindMode, SelectOption, SessionCommand, SessionHandle, SessionOptions,
+ ThreadItem, TokenUsage, ToolOutput, UserInputDelivery, UserInputOption, UserInputQuestion,
};
+/// The first release with the server API this adapter speaks; 1.x servers
+/// expose an incompatible one.
+const MIN_VERSION: (u32, u32, u32) = (2, 0, 0);
+const PERMISSION_MODE: &str = "permission_mode";
+const EFFORT: &str = "reasoningEffort";
+const AGENT: &str = "agent";
+/// Tcode's entry among the session's API-managed instructions.
+const INSTRUCTIONS_KEY: &str = "tcode";
+/// A first start migrates OpenCode's database before the server listens.
+const STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
+/// The server streams no shell output; its own clients poll the shell's
+/// output route while the command runs.
+const SHELL_POLL_INTERVAL: Duration = Duration::from_millis(250);
+
pub async fn start(opts: SessionOptions) -> Result {
- if opts.fork {
- return Err(AgentError::Protocol(
- "session fork is not supported for this provider".into(),
- ));
- }
+ require_supported_version(opts.binary_path.as_deref(), &opts.launch_env).await?;
crate::spawn_session(
ProviderKind::OpenCode,
opts,
@@ -44,16 +54,17 @@ pub async fn list_models(
binary_path: Option,
launch_env: LaunchEnv,
) -> Result, AgentError> {
+ require_supported_version(binary_path.as_deref(), &launch_env).await?;
crate::process::unblock(move || {
let cwd = std::env::current_dir()?;
- let mut server =
- OpenCodeServer::spawn(binary_path.as_deref(), &cwd, &launch_env, &[], &[])?;
+ let mut server = Server::spawn(binary_path.as_deref(), &cwd, &launch_env, &[], &[])?;
let result = (|| {
- server.wait_healthy(Duration::from_secs(15))?;
- let provider_state = server.http.get_json("/provider")?;
- let mut catalog = server.http.get_json("/config/providers")?;
- reconcile_provider_catalog(&mut catalog, &provider_state);
- Ok(map_models(&catalog))
+ let models = server.http.get("/api/model")?;
+ let default = server.http.get("/api/model/default").ok();
+ Ok(map_models(
+ &models["data"],
+ default.as_ref().map(|default| &default["data"]),
+ ))
})();
server.stop();
result
@@ -61,157 +72,463 @@ pub async fn list_models(
.await
}
+async fn require_supported_version(
+ binary_path: Option<&Path>,
+ launch_env: &LaunchEnv,
+) -> Result<(), AgentError> {
+ let binary = crate::resolve_binary(binary_path, "opencode")?;
+ match crate::process::probe_version(&binary, launch_env, ProviderKind::OpenCode).await {
+ Some(version @ (major, minor, patch)) if version < MIN_VERSION => {
+ Err(AgentError::Provider(format!(
+ "OpenCode 2.0 or later is required, but `{}` is version {major}.{minor}.{patch}. \
+ Update OpenCode to use it with Tcode.",
+ binary.display()
+ )))
+ }
+ // An unreadable version leaves the failure to the server start itself.
+ _ => Ok(()),
+ }
+}
+
async fn run_actor(
opts: SessionOptions,
commands: Receiver,
events: Sender,
ready: Sender>,
) {
- let mut server = match OpenCodeServer::spawn(
- opts.binary_path.as_deref(),
- &opts.cwd,
- &opts.launch_env,
- &opts.extra_args,
- &opts.mcp_servers.iter().collect::>(),
- ) {
- Ok(server) => server,
- Err(err) => {
- let _ = ready.send(Err(err)).await;
- return;
- }
+ let launch = {
+ let opts = opts.clone();
+ crate::process::unblock(move || {
+ let mut server = Server::spawn(
+ opts.binary_path.as_deref(),
+ &opts.cwd,
+ &opts.launch_env,
+ &opts.extra_args,
+ &opts.mcp_servers.iter().collect::>(),
+ )?;
+ match server.subscribe() {
+ Ok(stream) => Ok((server, stream)),
+ Err(err) => {
+ server.stop();
+ Err(err)
+ }
+ }
+ })
+ .await
};
- if let Err(err) = server.wait_healthy(Duration::from_secs(15)) {
- server.stop();
- let _ = ready.send(Err(err)).await;
- return;
- }
- let sse = match server.subscribe() {
- Ok(sse) => sse,
+ let (mut server, (transport, transport_tx)) = match launch {
+ Ok(launched) => launched,
Err(err) => {
- server.stop();
let _ = ready.send(Err(err)).await;
return;
}
};
- if let Err(err) = wait_for_server_connected(&sse).await {
+ if let Err(err) = wait_for_server_connected(&transport).await {
server.stop();
let _ = ready.send(Err(err)).await;
return;
}
- let requested_model = opts.model.as_deref().and_then(split_model_id);
- let session = if let Some(session_id) = resume_session_id(&opts.resume) {
- server.http.get_json(&format!("/session/{session_id}"))
- } else {
- let mut body = json!({});
- if let Some((provider_id, model_id)) = requested_model {
- body["model"] = json!({"providerID":provider_id,"id":model_id});
- }
- server
- .http
- .post_json("/session", &body)
- .map(|(_, value)| value)
+ let setup = {
+ let http = server.http.clone();
+ let opts = opts.clone();
+ crate::process::unblock(move || open_session(&http, &opts)).await
};
- let session = match session {
- Ok(session) => session,
+ let setup = match setup {
+ Ok(setup) => setup,
Err(err) => {
server.stop();
let _ = ready.send(Err(err)).await;
return;
}
};
- let Some(session_id) = session.get("id").and_then(Value::as_str).map(str::to_owned) else {
- server.stop();
- let _ = ready
- .send(Err(AgentError::Protocol(
- "OpenCode session response omitted id".into(),
- )))
- .await;
- return;
- };
- let resolved_model = session
- .get("model")
- .and_then(|model| {
- Some(format!(
- "{}/{}",
- model.get("providerID")?.as_str()?,
- model.get("id")?.as_str()?
- ))
- })
- .or_else(|| opts.model.clone());
- let provider_commands = discover_commands(&server.http);
- let model = resolved_model
- .as_deref()
- .and_then(split_model_id)
- .map(|(provider_id, model_id)| (provider_id.to_owned(), model_id.to_owned()));
+
+ let model_id = setup.model.as_ref().map(ModelRef::id);
+ let mut mapper = Mapper::new(
+ setup.session_id.clone(),
+ setup.mcp_servers,
+ setup.context_windows,
+ );
+ mapper.served_model = model_id.clone();
let mut actor = OpenCodeActor {
server,
- sse,
+ http: setup.http,
+ transport,
+ transport_tx,
events,
- mapper: OpenCodeMapper::new(session_id.clone()),
- model,
- variant: selected_variant(&opts.option_selections).map(str::to_owned),
- auto_approve: opts
- .option_selections
- .iter()
- .find(|selection| selection.id == "permission_mode")
- .and_then(|selection| selection.value.as_str())
+ mapper,
+ model: setup.model,
+ variant: crate::selection_str(&opts.option_selections, EFFORT),
+ agent: setup.agent,
+ agents: setup.agents,
+ commands: setup.commands.clone(),
+ auto_approve: crate::selection_str(&opts.option_selections, PERMISSION_MODE).as_deref()
== Some("auto"),
- pending_permissions: HashSet::new(),
- pending_questions: HashMap::new(),
- instructions: opts.instructions.clone(),
+ permissions: HashMap::new(),
+ forms: HashMap::new(),
+ steers: HashMap::new(),
+ prompts: HashSet::new(),
+ shell_polls: HashMap::new(),
};
actor
.events
.emit(AgentEvent::SessionStarted {
- provider_session_id: session_id.clone(),
- resume: ResumeCursor(json!({"session_id":session_id})),
- model: resolved_model,
+ provider_session_id: setup.session_id.clone(),
+ resume: ResumeCursor(json!({ "session_id": setup.session_id })),
+ model: model_id,
})
.await;
actor.emit_options().await;
actor
.events
.emit(AgentEvent::ProviderCommands {
- commands: provider_commands,
+ commands: setup.commands,
})
.await;
if opts.launch_env.home.is_some() {
- actor.events
+ actor
+ .events
.emit(AgentEvent::Warning {
- message:
- "OpenCode has no supported single-directory home override; custom environment variables still apply"
+ message: "OpenCode has no supported single-directory home override; custom \
+ environment variables still apply"
.into(),
- })
+ })
.await;
}
if ready.send(Ok(())).await.is_err() {
actor.shutdown().await;
return;
}
-
actor::run(actor, &commands).await;
}
+/// What the actor needs from a created, resumed or forked session.
+struct SessionSetup {
+ session_id: String,
+ /// Requests addressed to the session's own location.
+ http: Http,
+ model: Option,
+ agent: Option,
+ agents: Vec,
+ commands: Vec,
+ context_windows: HashMap,
+ mcp_servers: Vec,
+}
+
+fn open_session(http: &Http, opts: &SessionOptions) -> Result {
+ let requested_model = opts
+ .model
+ .as_deref()
+ .and_then(ModelRef::parse)
+ .map(|model| ModelRef {
+ variant: crate::selection_str(&opts.option_selections, EFFORT),
+ ..model
+ });
+ let requested_agent = crate::selection_str(&opts.option_selections, AGENT);
+ let resumed = opts
+ .resume
+ .as_ref()
+ .and_then(|cursor| cursor.str_field(&["session_id"]));
+ let session = match resumed {
+ Some(session_id) if opts.fork => {
+ http.post(&format!("/api/session/{session_id}/fork"), &json!({}))?
+ }
+ Some(session_id) => http.get(&format!("/api/session/{session_id}"))?,
+ None => {
+ let mut body = json!({});
+ if let Some(model) = &requested_model {
+ body["model"] = model.wire();
+ }
+ if let Some(agent) = &requested_agent {
+ body["agent"] = json!(agent);
+ }
+ http.post("/api/session", &body)?
+ }
+ };
+ let session = &session["data"];
+ let session_id = session["id"]
+ .as_str()
+ .ok_or_else(|| AgentError::Protocol("OpenCode session response omitted id".into()))?
+ .to_owned();
+ // Routes such as a shell's output resolve against the location the session
+ // was created in, which OpenCode canonicalizes.
+ let http = match session
+ .pointer("/location/directory")
+ .and_then(Value::as_str)
+ {
+ Some(directory) => http.at(Path::new(directory)),
+ None => http.clone(),
+ };
+ let mut model = ModelRef::from_wire(&session["model"]);
+ if let Some(requested) = requested_model
+ && model.as_ref() != Some(&requested)
+ {
+ http.post_empty(
+ &format!("/api/session/{session_id}/model"),
+ &json!({ "model": requested.wire() }),
+ )?;
+ model = Some(requested);
+ }
+ if model.is_none() {
+ // A session without its own model runs on the server default; knowing
+ // it lets a per-turn variant be applied to it.
+ model = http
+ .get("/api/model/default")
+ .ok()
+ .and_then(|default| ModelRef::from_wire(&default["data"]));
+ }
+ let mut agent = session["agent"].as_str().map(str::to_owned);
+ if let Some(requested) = requested_agent
+ && agent.as_deref() != Some(requested.as_str())
+ {
+ http.post_empty(
+ &format!("/api/session/{session_id}/agent"),
+ &json!({ "agent": requested }),
+ )?;
+ agent = Some(requested);
+ }
+ let entry =
+ format!("/api/experimental/session/{session_id}/instructions/entries/{INSTRUCTIONS_KEY}");
+ match &opts.instructions {
+ Some(instructions) => http.put(&entry, &json!({ "value": instructions }))?,
+ // Removing an absent entry is a no-op; a stale one must not outlive
+ // the setting that put it there.
+ None => http.delete(&entry)?,
+ };
+ Ok(SessionSetup {
+ commands: discover_commands(&http),
+ agents: http
+ .get("/api/agent")
+ .map(|agents| map_agents(&agents["data"]))
+ .unwrap_or_default(),
+ context_windows: http
+ .get("/api/model")
+ .map(|models| context_windows(&models["data"]))
+ .unwrap_or_default(),
+ mcp_servers: mcp_servers(&http),
+ session_id,
+ http,
+ model,
+ agent,
+ })
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct ModelRef {
+ provider: String,
+ id: String,
+ variant: Option,
+}
+
+impl ModelRef {
+ /// Tcode's model id, `provider/model`.
+ fn parse(model: &str) -> Option {
+ let (provider, id) = model
+ .split_once('/')
+ .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())?;
+ Some(Self {
+ provider: provider.into(),
+ id: id.into(),
+ variant: None,
+ })
+ }
+
+ fn from_wire(model: &Value) -> Option {
+ Some(Self {
+ provider: model["providerID"].as_str()?.into(),
+ id: model["id"].as_str()?.into(),
+ variant: model["variant"].as_str().map(str::to_owned),
+ })
+ }
+
+ fn wire(&self) -> Value {
+ let mut model = json!({ "providerID": self.provider, "id": self.id });
+ if let Some(variant) = &self.variant {
+ model["variant"] = json!(variant);
+ }
+ model
+ }
+
+ fn id(&self) -> String {
+ format!("{}/{}", self.provider, self.id)
+ }
+}
+
+#[derive(Debug, Clone)]
+struct AgentInfo {
+ id: String,
+ name: String,
+ description: Option,
+}
+
+/// The primary agents a user can select for a session; subagents and the
+/// hidden system agents (title, compaction, summary) are not choices.
+fn map_agents(agents: &Value) -> Vec {
+ agents
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter(|agent| agent["mode"] == "primary" && agent["hidden"] != true)
+ .filter_map(|agent| {
+ let id = agent["id"].as_str()?.to_owned();
+ Some(AgentInfo {
+ name: agent["name"].as_str().unwrap_or(&id).to_owned(),
+ description: agent["description"].as_str().map(str::to_owned),
+ id,
+ })
+ })
+ .collect()
+}
+
+/// The MCP servers configured for the session's location, Tcode's included.
+fn mcp_servers(http: &Http) -> Vec {
+ http.get("/api/mcp")
+ .ok()
+ .and_then(|servers| servers["data"].as_array().cloned())
+ .unwrap_or_default()
+ .iter()
+ .filter_map(|server| server["name"].as_str().map(str::to_owned))
+ .collect()
+}
+
+fn discover_commands(http: &Http) -> Vec {
+ let listed = |path: &str| {
+ http.get(path)
+ .ok()
+ .and_then(|value| value["data"].as_array().cloned())
+ .unwrap_or_default()
+ };
+ let mut commands: Vec = listed("/api/command")
+ .iter()
+ .filter_map(|command| {
+ let name = command["name"].as_str()?.trim();
+ (!name.is_empty()).then(|| ProviderCommand {
+ name: name.to_owned(),
+ description: command["description"].as_str().map(str::to_owned),
+ kind: ProviderCommandKind::Command,
+ })
+ })
+ .collect();
+ // A skill is invoked by its id; its display name may contain spaces.
+ commands.extend(listed("/api/skill").iter().filter_map(|skill| {
+ let id = skill["id"].as_str()?.trim();
+ (!id.is_empty()).then(|| ProviderCommand {
+ name: id.to_owned(),
+ description: skill["description"].as_str().map(str::to_owned),
+ kind: ProviderCommandKind::Skill,
+ })
+ }));
+ commands
+}
+
+fn map_models(models: &Value, default: Option<&Value>) -> Vec {
+ let default = default.and_then(ModelRef::from_wire);
+ models
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter(|model| {
+ model["enabled"] != false
+ && model["status"] != "deprecated"
+ // Image and decision models cannot run a session.
+ && model["type"].as_str().is_none_or(|kind| kind == "chat")
+ })
+ .filter_map(|model| {
+ let reference = ModelRef::from_wire(model)?;
+ let variants: Vec = model["variants"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|variant| variant["id"].as_str().map(str::to_owned))
+ .collect();
+ let options = (!variants.is_empty())
+ .then(|| OptionDescriptor::Select {
+ id: EFFORT.into(),
+ label: "Thinking".into(),
+ options: variants
+ .into_iter()
+ .map(|variant| SelectOption {
+ label: title_case(&variant),
+ value: variant,
+ description: None,
+ unavailable: None,
+ })
+ .collect(),
+ default_value: None,
+ role: crate::OptionRole::Model,
+ apply: crate::ApplyTiming::Live,
+ recommended: None,
+ permissive: None,
+ })
+ .into_iter()
+ .collect();
+ Some(ModelSpec {
+ id: reference.id(),
+ display_name: model["name"].as_str().unwrap_or(&reference.id).to_owned(),
+ is_default: default.as_ref().is_some_and(|default| {
+ default.provider == reference.provider && default.id == reference.id
+ }),
+ options,
+ })
+ })
+ .collect()
+}
+
+fn context_windows(models: &Value) -> HashMap {
+ models
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|model| {
+ Some((
+ ModelRef::from_wire(model)?.id(),
+ model.pointer("/limit/context")?.as_u64()?,
+ ))
+ })
+ .collect()
+}
+
+fn title_case(value: &str) -> String {
+ let mut characters = value.chars();
+ characters
+ .next()
+ .map(|first| first.to_uppercase().collect::() + characters.as_str())
+ .unwrap_or_default()
+}
+
struct OpenCodeActor {
- server: OpenCodeServer,
- sse: Receiver,
+ server: Server,
+ http: Http,
+ transport: Receiver,
+ /// Shell output pollers feed the same queue as the event stream.
+ transport_tx: Sender,
events: Sender,
- mapper: OpenCodeMapper,
- model: Option<(String, String)>,
+ mapper: Mapper,
+ model: Option,
variant: Option,
-
+ agent: Option,
+ agents: Vec,
+ commands: Vec,
auto_approve: bool,
- pending_permissions: HashSet,
- pending_questions: HashMap>,
- /// [`SessionOptions::instructions`], the `system` field of every prompt.
- instructions: Option,
+ /// Pending permission request id → the session that asked.
+ permissions: HashMap,
+ forms: HashMap,
+ /// Inbox id of a steer → the runtime's steer request id.
+ steers: HashMap,
+ /// Inbox ids of the turns Tcode sent; each is a revert boundary.
+ prompts: HashSet,
+ shell_polls: HashMap>,
+}
+
+struct PendingForm {
+ session_id: String,
+ fields: Vec<(String, String)>,
}
impl SessionActor for OpenCodeActor {
- type TransportItem = SseOutput;
+ type TransportItem = Transport;
- fn transport(&self) -> &Receiver {
- &self.sse
+ fn transport(&self) -> &Receiver {
+ &self.transport
}
fn events(&self) -> &Sender {
@@ -230,274 +547,477 @@ impl SessionActor for OpenCodeActor {
options,
attachments,
} => {
- let mut parts = vec![json!({"type":"text","text":text})];
- parts.extend(attachments.into_iter().map(attachment_part));
- let mut body = json!({"parts":parts});
- if let Some((provider_id, model_id)) = &self.model {
- body["model"] = json!({"providerID":provider_id,"modelID":model_id});
- }
- body["agent"] = json!("build");
- let variant = options
- .and_then(|options| options.effort)
- .or_else(|| self.variant.clone());
- if let Some(variant) = variant {
- body["variant"] = json!(variant);
- }
- if let Some(instructions) = &self.instructions {
- body["system"] = json!(instructions);
- }
- let (status, _) = self
- .server
- .http
- .post_json(
- &format!("/session/{}/prompt_async", self.mapper.session_id),
- &body,
- )
+ let effort = options.and_then(|options| options.effort);
+ self.send_turn(text, effort, attachments)
.map_err(|err| err.to_string())?;
- if status != 204 {
- return Err(format!(
- "OpenCode prompt_async returned unexpected HTTP {status}"
- ));
- }
self.events
.emit(AgentEvent::TurnAccepted { delivery_id })
.await;
- Ok(())
- }
- SessionCommand::Interrupt => {
- self.mapper.interrupted = true;
- self.cancel_pending_questions().await;
- self.server
- .http
- .post_json(
- &format!("/session/{}/abort", self.mapper.session_id),
- &json!({}),
- )
- .map_err(|err| err.to_string())?;
- Ok(())
}
- SessionCommand::RespondApproval {
+ SessionCommand::Steer {
request_id,
- decision,
+ text,
+ attachments,
} => {
- let reply = match &decision {
- ApprovalDecision::Option(id)
- if matches!(id.as_str(), "once" | "always" | "reject") =>
- {
- id.as_str()
- }
- ApprovalDecision::Cancel => "reject",
- ApprovalDecision::Option(_) => return Ok(()),
- };
- if !self.pending_permissions.remove(&request_id) {
+ if self.mapper.active_turn.is_none() {
+ // No running turn to fold it into; the runtime sends it as one.
+ self.events
+ .emit(AgentEvent::SteerRejected { request_id })
+ .await;
return Ok(());
}
- self.server
+ let mut body = prompt_body(text, &attachments, "steer");
+ self.attach_skill(&mut body);
+ let inbox = self
.http
- .post_json(
- &format!("/permission/{request_id}/reply"),
- &json!({"reply":reply}),
- )
+ .post(&self.session_path("prompt"), &body)
.map_err(|err| err.to_string())?;
- if decision == ApprovalDecision::Cancel {
- self.mapper.interrupted = true;
- let _ = self.server.http.post_json(
- &format!("/session/{}/abort", self.mapper.session_id),
- &json!({}),
- );
- }
- self.events
- .emit(AgentEvent::ApprovalResolved {
- request_id,
- decision,
- })
- .await;
- Ok(())
- }
- SessionCommand::SetOption { id, value } if id == "permission_mode" => {
- if let Some(value @ ("normal" | "auto")) = value.as_str() {
- self.auto_approve = value == "auto";
- self.emit_options().await;
- if self.auto_approve {
- let requests: Vec<_> = self.pending_permissions.iter().cloned().collect();
- for request_id in requests {
- match self.server.http.post_json(
- &format!("/permission/{request_id}/reply"),
- &json!({"reply":"once"}),
- ) {
- Ok(_) => {
- self.pending_permissions.remove(&request_id);
- self.events
- .emit(AgentEvent::ApprovalResolved {
- request_id,
- decision: ApprovalDecision::Option("once".into()),
- })
- .await;
- }
- Err(error) => {
- self.events
- .emit(AgentEvent::Warning {
- message: error.to_string(),
- })
- .await
- }
- }
- }
- }
+ if let Some(inbox_id) = inbox.pointer("/data/id").and_then(Value::as_str) {
+ self.steers.insert(inbox_id.to_owned(), request_id);
}
- Ok(())
- }
- SessionCommand::SetOption { id, value } if id == "reasoningEffort" => {
- self.variant = value.as_str().map(str::to_owned);
- Ok(())
}
- SessionCommand::Steer { .. } => {
- self.events
- .emit(AgentEvent::Warning {
- message: "OpenCode's server API does not support steering an active turn"
- .into(),
- })
- .await;
- Ok(())
+ SessionCommand::Interrupt => {
+ self.http
+ .post(&self.session_path("interrupt"), &json!({}))
+ .map_err(|err| err.to_string())?;
}
+ SessionCommand::RespondApproval {
+ request_id,
+ decision,
+ } => self.respond_approval(request_id, decision).await?,
SessionCommand::RespondUserInput {
request_id,
answers,
- } => {
- let Some(question_ids) = self.pending_questions.get(&request_id) else {
- return Ok(());
- };
- match (!answers.is_empty()).then(|| native_question_answers(question_ids, &answers))
- {
- None => {
- self.server
- .http
- .post_json(&format!("/question/{request_id}/reject"), &json!({}))
- .map_err(|err| err.to_string())?;
- }
- Some(answers) => {
- self.server
- .http
- .post_json(
- &format!("/question/{request_id}/reply"),
- &json!({"answers":answers}),
- )
- .map_err(|err| err.to_string())?;
- }
- }
- self.pending_questions.remove(&request_id);
- self.events
- .emit(AgentEvent::UserInputResolved {
- request_id,
- answers,
- })
- .await;
- Ok(())
- }
+ } => self.respond_user_input(request_id, answers).await?,
+ SessionCommand::SetOption { id, value } => self.set_option(&id, value).await?,
SessionCommand::Compact => {
- self.events
- .emit(AgentEvent::Warning {
- message: "OpenCode compaction is not exposed by Tcode's native adapter"
- .into(),
- })
- .await;
- Ok(())
+ self.http
+ .post(&self.session_path("compact"), &json!({}))
+ .map_err(|err| err.to_string())?;
}
- SessionCommand::Rewind { .. } => {
- self.events
- .emit(AgentEvent::Warning {
- message: "OpenCode rewind is not exposed by Tcode's native adapter".into(),
- })
- .await;
- Ok(())
+ SessionCommand::Rewind {
+ checkpoint_id,
+ mode,
+ } => {
+ let event = match self.rewind(&checkpoint_id, mode) {
+ Ok(prefill) => AgentEvent::RewindCompleted {
+ checkpoint_id,
+ mode,
+ prefill,
+ },
+ Err(error) => AgentEvent::RewindFailed {
+ checkpoint_id,
+ mode,
+ error,
+ },
+ };
+ self.events.emit(event).await;
}
SessionCommand::RetryTurn { .. }
- | SessionCommand::SetOption { .. }
| SessionCommand::SetGoal { .. }
| SessionCommand::ClearGoal
- | SessionCommand::Shutdown => Ok(()),
+ | SessionCommand::Shutdown => {}
}
+ Ok(())
}
async fn handle_transport(
&mut self,
- item: Result,
+ item: Result,
) -> TransportOutcome {
match item {
- Ok(SseOutput::Event(event)) => {
+ Ok(Transport::Event(event)) => {
self.handle_event(&event).await;
TransportOutcome::Continue
}
- Ok(SseOutput::Error(err)) => TransportOutcome::Fatal(err),
- Ok(SseOutput::Eof) | Err(_) => {
- TransportOutcome::Closed("OpenCode SSE stream closed".into())
+ Ok(Transport::ShellOutput { item_id, text }) => {
+ let events = self.mapper.shell_output(&item_id, text);
+ for event in events {
+ self.events.emit(event).await;
+ }
+ TransportOutcome::Continue
+ }
+ Ok(Transport::Error(err)) => TransportOutcome::Fatal(err),
+ Ok(Transport::Eof) | Err(_) => {
+ TransportOutcome::Closed("OpenCode event stream closed".into())
}
}
}
async fn teardown(mut self, reason: Option) -> Option {
self.shutdown().await;
- reason.map(|reason| {
- self.server
- .stderr_tail
- .append_to(reason, "\nserver output:\n")
- })
+ reason.map(|reason| self.server.tail.append_to(reason, "\nserver output:\n"))
}
}
impl OpenCodeActor {
- async fn emit_options(&self) {
- self.events
- .emit(AgentEvent::ProviderOptions {
- descriptors: crate::permission_control(ProviderKind::OpenCode)
- .into_iter()
- .collect(),
- selections: vec![OptionSelection {
- id: "permission_mode".into(),
- value: json!(if self.auto_approve { "auto" } else { "normal" }),
- }],
- })
- .await;
+ fn session_path(&self, route: &str) -> String {
+ format!("/api/session/{}/{route}", self.mapper.root)
}
- async fn handle_event(&mut self, event: &Value) {
- let mut mapped = self.mapper.on_event(event);
- for request_id in mapped.permission_ids {
- self.pending_permissions.insert(request_id.clone());
- if self.auto_approve {
- match self.server.http.post_json(
- &format!("/permission/{request_id}/reply"),
- &json!({"reply":"once"}),
- ) {
- Ok(_) => {
- self.pending_permissions.remove(&request_id);
- mapped.events.retain(|event| !matches!(event, AgentEvent::ApprovalRequested(request) if request.id == request_id));
- mapped.events.push(AgentEvent::ApprovalResolved {
- request_id,
- decision: ApprovalDecision::Option("once".into()),
- });
- }
- Err(err) => mapped.events.push(AgentEvent::Warning {
- message: format!("failed to auto-approve OpenCode permission: {err}"),
- }),
- }
- }
+
+ fn send_turn(
+ &mut self,
+ text: String,
+ effort: Option,
+ attachments: Vec,
+ ) -> Result<(), AgentError> {
+ if let Some(variant) = effort.or_else(|| self.variant.clone())
+ && let Some(model) = &self.model
+ && model.variant.as_deref() != Some(variant.as_str())
+ {
+ let model = ModelRef {
+ variant: Some(variant),
+ ..model.clone()
+ };
+ self.http.post_empty(
+ &self.session_path("model"),
+ &json!({ "model": model.wire() }),
+ )?;
+ self.model = Some(model);
}
- for (request_id, question_ids) in mapped.question_requests {
- self.pending_questions.insert(request_id, question_ids);
+ if let Some((name, arguments)) = self.command_invocation(&text) {
+ let mut body = prompt_body(arguments, &attachments, "queue");
+ body["name"] = json!(name);
+ self.http.post_empty(&self.session_path("command"), &body)?;
+ return Ok(());
}
- for (request_id, answers) in mapped.question_resolutions {
- let Some(question_ids) = self.pending_questions.remove(&request_id) else {
- continue;
- };
- self.events
- .emit(AgentEvent::UserInputResolved {
- request_id,
- answers: answers
- .map(|answers| canonical_question_answers(&question_ids, &answers))
- .unwrap_or_default(),
- })
- .await;
+ let mut body = prompt_body(text, &attachments, "queue");
+ self.attach_skill(&mut body);
+ let inbox = self.http.post(&self.session_path("prompt"), &body)?;
+ if let Some(inbox_id) = inbox.pointer("/data/id").and_then(Value::as_str) {
+ self.prompts.insert(inbox_id.to_owned());
+ }
+ Ok(())
+ }
+
+ /// `/name arguments` for a listed OpenCode command, which 2.x runs through
+ /// its command route rather than as prompt text.
+ fn command_invocation(&self, text: &str) -> Option<(String, String)> {
+ let rest = text.strip_prefix('/')?;
+ let (name, arguments) = rest
+ .split_once(char::is_whitespace)
+ .map_or((rest, ""), |(name, arguments)| {
+ (name, arguments.trim_start())
+ });
+ self.commands
+ .iter()
+ .any(|command| command.kind == ProviderCommandKind::Command && command.name == name)
+ .then(|| (name.to_owned(), arguments.to_owned()))
+ }
+
+ /// A leading `$id` mention of a listed skill attaches that skill, as
+ /// OpenCode's own composer does for a skill mention.
+ fn attach_skill(&self, body: &mut Value) {
+ let Some(rest) = body["text"]
+ .as_str()
+ .and_then(|text| text.strip_prefix('$'))
+ else {
+ return;
+ };
+ let id = &rest[..rest.find(char::is_whitespace).unwrap_or(rest.len())];
+ if self
+ .commands
+ .iter()
+ .any(|command| command.kind == ProviderCommandKind::Skill && command.name == id)
+ {
+ body["skills"] = json!([{
+ "id": id,
+ "mention": { "start": 0, "end": id.len() + 1, "text": format!("${id}") },
+ }]);
+ }
+ }
+
+ async fn set_option(&mut self, id: &str, value: Value) -> Result<(), String> {
+ match id {
+ PERMISSION_MODE => {
+ let Some(mode @ ("normal" | "auto")) = value.as_str() else {
+ return Ok(());
+ };
+ self.auto_approve = mode == "auto";
+ self.emit_options().await;
+ if self.auto_approve {
+ let pending: Vec = self.permissions.keys().cloned().collect();
+ for request_id in pending {
+ self.reply_permission(&request_id, "once", None).await;
+ }
+ }
+ }
+ EFFORT => self.variant = value.as_str().map(str::to_owned),
+ AGENT => {
+ let Some(agent) = value.as_str() else {
+ return Ok(());
+ };
+ if self.agent.as_deref() != Some(agent) {
+ self.http
+ .post_empty(&self.session_path("agent"), &json!({ "agent": agent }))
+ .map_err(|err| err.to_string())?;
+ self.agent = Some(agent.to_owned());
+ }
+ self.emit_options().await;
+ }
+ _ => {}
+ }
+ Ok(())
+ }
+
+ async fn emit_options(&self) {
+ let mut descriptors: Vec<_> = crate::permission_control(ProviderKind::OpenCode)
+ .into_iter()
+ .collect();
+ let mut selections = vec![OptionSelection {
+ id: PERMISSION_MODE.into(),
+ value: json!(if self.auto_approve { "auto" } else { "normal" }),
+ }];
+ if self.agents.len() > 1 {
+ descriptors.push(OptionDescriptor::Select {
+ id: AGENT.into(),
+ label: "Agent".into(),
+ options: self
+ .agents
+ .iter()
+ .map(|agent| SelectOption {
+ value: agent.id.clone(),
+ label: agent.name.clone(),
+ description: agent.description.clone(),
+ unavailable: None,
+ })
+ .collect(),
+ default_value: None,
+ role: crate::OptionRole::Model,
+ apply: crate::ApplyTiming::Live,
+ recommended: None,
+ permissive: None,
+ });
+ if let Some(agent) = &self.agent {
+ selections.push(OptionSelection {
+ id: AGENT.into(),
+ value: json!(agent),
+ });
+ }
+ }
+ self.events
+ .emit(AgentEvent::ProviderOptions {
+ descriptors,
+ selections,
+ })
+ .await;
+ }
+
+ async fn respond_approval(
+ &mut self,
+ request_id: String,
+ decision: ApprovalDecision,
+ ) -> Result<(), String> {
+ let (reply, message) = match &decision {
+ ApprovalDecision::Option(id) if matches!(id.as_str(), "once" | "always" | "reject") => {
+ (id.as_str(), None)
+ }
+ // A rejection with a message is OpenCode's correction: the model
+ // continues from the user's words instead of stopping.
+ ApprovalDecision::Feedback(text) => ("reject", Some(text.as_str())),
+ ApprovalDecision::Cancel => ("reject", None),
+ ApprovalDecision::Option(_) => return Ok(()),
+ };
+ let Some(session_id) = self.permissions.remove(&request_id) else {
+ return Ok(());
+ };
+ let mut body = json!({ "decision": reply });
+ if let Some(message) = message {
+ body["message"] = json!(message);
+ }
+ self.http
+ .post_empty(
+ &format!("/api/session/{session_id}/permission/{request_id}/reply"),
+ &body,
+ )
+ .map_err(|err| err.to_string())?;
+ self.events
+ .emit(AgentEvent::ApprovalResolved {
+ request_id,
+ decision,
+ })
+ .await;
+ Ok(())
+ }
+
+ async fn reply_permission(&mut self, request_id: &str, reply: &str, message: Option<&str>) {
+ let Some(session_id) = self.permissions.get(request_id).cloned() else {
+ return;
+ };
+ let mut body = json!({ "decision": reply });
+ if let Some(message) = message {
+ body["message"] = json!(message);
+ }
+ match self.http.post_empty(
+ &format!("/api/session/{session_id}/permission/{request_id}/reply"),
+ &body,
+ ) {
+ Ok(()) => {
+ self.permissions.remove(request_id);
+ self.events
+ .emit(AgentEvent::ApprovalResolved {
+ request_id: request_id.to_owned(),
+ decision: ApprovalDecision::Option(reply.to_owned()),
+ })
+ .await;
+ }
+ Err(err) => {
+ self.events
+ .emit(AgentEvent::Warning {
+ message: format!("failed to answer OpenCode permission request: {err}"),
+ })
+ .await;
+ }
+ }
+ }
+
+ async fn respond_user_input(
+ &mut self,
+ request_id: String,
+ answers: Map,
+ ) -> Result<(), String> {
+ let Some(form) = self.forms.remove(&request_id) else {
+ return Ok(());
+ };
+ let path = format!("/api/session/{}/form/{request_id}", form.session_id);
+ if answers.is_empty() {
+ self.http.delete(&path).map_err(|err| err.to_string())?;
+ } else {
+ self.http
+ .post_empty(
+ &format!("{path}/reply"),
+ &json!({ "answer": form_answer(&form.fields, &answers) }),
+ )
+ .map_err(|err| err.to_string())?;
+ }
+ self.events
+ .emit(AgentEvent::UserInputResolved {
+ request_id,
+ answers,
+ })
+ .await;
+ Ok(())
+ }
+
+ /// OpenCode reverts the conversation to a user message, restoring the
+ /// files its steps changed when asked; it has no files-only restore.
+ fn rewind(&mut self, checkpoint_id: &str, mode: RewindMode) -> Result