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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/user/subagents-and-acp-harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ Without `output_schema`, `output` is text. A turn that emits selected non-text a
}
```

Text-only turns omit `updates`. Capture is limited to 64 update objects and 64 KiB of serialized update data per turn. Excess or oversized updates are omitted and set `updates.truncated` to `true`. When an ACP tool update's rendered content is only a JSON copy of its structured `rawOutput`, Kit retains only `rawOutput` in the parent-visible update. Kit also captures usage (including reported cumulative session cost), session info, available commands, notices, compaction lifecycle updates, and compaction summary chunks. These remain separate from the final answer text. Usage values describe context occupancy, not billable token deltas; cumulative costs must not be summed across updates. Kit does not add child costs to parent billing totals. Child thoughts, user-message echoes, modes, and configuration are not exposed through this value. Session titles and context usage also feed the live agent roster independently of these capture limits; thoughts are not forwarded as activity text.
Text-only turns omit `updates`. Capture is limited to 64 update objects and 64 KiB of serialized update data per turn. Excess or oversized updates are omitted and set `updates.truncated` to `true`. When an ACP tool update's rendered content is only a JSON copy of its structured `rawOutput`, Kit retains `rawOutput` and replaces the duplicate content with an empty array. This preserves the update's explicit replacement of earlier rich content. Kit also captures usage (including reported cumulative session cost), session info, available commands, notices, compaction lifecycle updates, and compaction summary chunks. These remain separate from the final answer text. Usage values describe context occupancy, not billable token deltas; cumulative costs must not be summed across updates. Kit does not add child costs to parent billing totals. Child thoughts, user-message echoes, modes, and configuration are not exposed through this value. Session titles and context usage also feed the live agent roster independently of these capture limits; thoughts are not forwarded as activity text.

After a successful child turn, ACP clients receive the child's final diff and terminal content on the existing `subagent`, `prompt`, or `fork` tool card. Content updates replace earlier snapshots; content chunks append. V1 file snapshots also map to v2 file changes. Native v2 diffs without complete before/after text remain v2-only, as do agent-owned terminals. Child terminal output is replayed at completion, not streamed live; terminal IDs are scoped to the parent invocation. Kit does not execute captured terminal commands. Missing exit information remains unknown, and incomplete terminal captures carry `kit/outputIncomplete` metadata. Display is limited to 64 rich content items, with the same metadata marking additional display truncation. Raw child output and captured terminal IDs are not rewritten by this display projection. Failed or cancelled child turns do not publish a completion snapshot.

## Choose the built-in `acp.kit` harness

Expand Down
22 changes: 22 additions & 0 deletions fixtures/mock-acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,28 @@ def prompt(request):
if "MOCK_REFUSAL" in text:
respond(request["id"], {"stopReason": "refusal"})
return
if text == "MOCK_TOOL_CONTENT":
diff = ({"type": "diff", "changes": [{"operation": "modify", "path": "/tmp/child.txt"}],
"patch": {"format": "git_patch", "text": "-old\n+new\n"}}
if "--v2" in sys.argv else
{"type": "diff", "path": "/tmp/child.txt", "oldText": "old\n", "newText": "new\n"})
raw = {"stdout": "child output"}
updates = [
{"sessionUpdate": "tool_call_update", "toolCallId": "call-1",
"content": [{"type": "content", "content": {"type": "text", "text": json.dumps(raw)}}, diff],
"rawOutput": raw},
]
if "--v2" in sys.argv:
updates += [
{"sessionUpdate": "terminal_update", "terminalId": "terminal-1", "command": "echo child output"},
{"sessionUpdate": "tool_call_update", "toolCallId": "call-2",
"content": [{"type": "terminal", "terminalId": "terminal-1"}]},
{"sessionUpdate": "terminal_output_chunk", "terminalId": "terminal-1", "data": "Y2hpbGQgb3V0cHV0Cg=="},
{"sessionUpdate": "terminal_update", "terminalId": "terminal-1", "exitStatus": {"exitCode": 0}},
]
for update in updates:
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": update}})
text = "tool content done"
if "MOCK_RICH_OUTPUT" in text:
updates = [
{
Expand Down
62 changes: 58 additions & 4 deletions src/acp_child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,16 @@ impl ChildOutput {
self.capture_value(value);
}

/// Preserve the original rich wire content: v1 decoding skips v2 diffs and
/// cannot represent agent-owned terminal notifications. The existing shared
/// count/byte budget applies before these values can reach a parent result.
fn record_tool_value(&mut self, mut value: Value) {
if value["sessionUpdate"] == "tool_call_update" {
deduplicate_tool_output(&mut value);
}
self.capture_value(value);
}

fn capture_value(&mut self, value: Value) {
if self.updates.len() >= MAX_CAPTURED_UPDATES {
self.updates_truncated = true;
Expand Down Expand Up @@ -893,7 +903,9 @@ fn deduplicate_tool_output(update: &mut Value) {
if rendered_text_only(content).is_some_and(|text| {
serde_json::from_str::<Value>(text).is_ok_and(|value| value == *raw_output)
}) {
object.remove("content");
// Keep the explicit replacement: omission would resurrect earlier rich
// content when a parent folds these updates into a final snapshot.
object.insert("content".into(), Value::Array(Vec::new()));
}
}

Expand Down Expand Up @@ -1401,6 +1413,19 @@ async fn run(
if let Ok(mut output) = route.output.lock() { output.record_message(update); }
return Ok(());
}
// Capture rich tool values without round-tripping through the v1
// schema, which silently drops v2 diff entries and terminal events.
// Build the owned value before locking; recording has no callbacks,
// wakeups, or awaits and shares ChildOutput's existing byte budget.
let tool_update = matches!(params["update"]["sessionUpdate"].as_str(),
Some("tool_call" | "tool_call_update" | "tool_call_content_chunk"
| "terminal_update" | "terminal_output_chunk"));
if tool_update {
let value = params["update"].clone();
if let Ok(mut output) = route.output.lock() {
output.record_tool_value(value);
}
}
// Reuse normalized configuration identifiers for captured output.
let Some(notification) = notification else { return Ok(()); };
if !route.owner.is_empty() && let Some(activity) = roster_activity(&notification.update) {
Expand All @@ -1418,7 +1443,7 @@ async fn run(
}),
});
}
if let Ok(mut output) = route.output.lock() {
if !tool_update && let Ok(mut output) = route.output.lock() {
output.record(notification.update);
}
Ok(())
Expand Down Expand Up @@ -2825,7 +2850,7 @@ mod tests {
}

#[test]
fn captured_tool_updates_drop_content_that_duplicates_raw_output() {
fn captured_tool_updates_clear_content_that_duplicates_raw_output() {
let raw = json!({"exit_code": 0, "stdout": "done", "stderr": "", "success": true});
let mut output = ChildOutput::default();
output.record(update(json!({
Expand All @@ -2840,10 +2865,39 @@ mod tests {
})));

assert_eq!(output.updates.len(), 1);
assert!(output.updates[0].get("content").is_none());
assert_eq!(output.updates[0]["content"], json!([]));
assert_eq!(output.updates[0]["rawOutput"]["stdout"], "done");
}

#[test]
fn captured_v2_tool_values_keep_explicit_rich_content_replacement() {
let mut output = ChildOutput::default();
output.record_tool_value(json!({
"sessionUpdate": "tool_call_update", "toolCallId": "a",
"content": [{"type": "diff", "changes": [{"operation": "modify", "path": "/file"}]}]
}));
let raw = json!({"stdout": "done"});
output.record_tool_value(json!({
"sessionUpdate": "tool_call_update", "toolCallId": "a",
"content": [{"type": "content", "content": {"type": "text", "text": raw.to_string()}}],
"rawOutput": raw
}));
assert_eq!(
output.updates[0]["content"][0]["changes"][0]["path"],
"/file"
);
assert_eq!(output.updates[1]["content"], json!([]));
assert_eq!(output.updates[1]["rawOutput"], raw);
assert_eq!(
output.update_bytes,
output
.updates
.iter()
.map(|value| serde_json::to_vec(value).unwrap().len())
.sum::<usize>()
);
}

#[test]
fn captured_tool_updates_keep_distinct_content_and_raw_output() {
let mut output = ChildOutput::default();
Expand Down
149 changes: 94 additions & 55 deletions src/protocols/acp/tool_projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use tokio::sync::broadcast;
)]
mod tests;

pub(crate) mod child;
pub(crate) mod terminal;

#[cfg(test)]
Expand Down Expand Up @@ -221,48 +222,56 @@ pub(crate) fn diff(request: &ToolRequest, path: &Path, old: Option<&str>, new: O
|| request.session_id.0.len() > MAX_ID
|| request.call_id.0.len() > MAX_ID
|| !request.call_id.0.contains(":compose:")
|| old
.map_or(0, str::len)
.saturating_add(new.map_or(0, str::len))
> MAX_DIFF_TEXT
|| location_value(path, None).is_none()
{
return;
}
let operation = match (old, new) {
(None, Some(_)) => "add",
(Some(_), None) => "delete",
(Some(_), Some(_)) => "modify",
(None, None) => return,
let Some(content) = diff_content(path, old, new) else {
return;
};
publish(Update {
session: request.session_id.0.clone(),
call: request.call_id.0.clone(),
start: None,
patch: Some(Value::Object(Map::from_iter([
("toolCallId".into(), Value::from(request.call_id.0.clone())),
(
"content".into(),
Value::Array(vec![Value::Object(Map::from_iter([
("type".into(), Value::from("diff")),
("path".into(), Value::from(path.to_str())),
("oldText".into(), Value::from(old)),
("newText".into(), Value::from(new.unwrap_or_default())),
(
"changes".into(),
Value::Array(vec![Value::Object(Map::from_iter([
("operation".into(), Value::from(operation)),
("path".into(), Value::from(path.to_str())),
("fileType".into(), Value::from("text")),
]))]),
),
]))]),
),
("content".into(), Value::Array(vec![content])),
]))),
ok: false,
});
}

/// Shared conversion for local committed edits and captured v1 child snapshots.
fn diff_content(path: &Path, old: Option<&str>, new: Option<&str>) -> Option<Value> {
if old
.map_or(0, str::len)
.saturating_add(new.map_or(0, str::len))
> MAX_DIFF_TEXT
|| location_value(path, None).is_none()
{
return None;
}
let operation = match (old, new) {
(None, Some(_)) => "add",
(Some(_), None) => "delete",
(Some(_), Some(_)) => "modify",
(None, None) => return None,
};
Some(Value::Object(Map::from_iter([
("type".into(), Value::from("diff")),
("path".into(), Value::from(path.to_str())),
("oldText".into(), Value::from(old)),
("newText".into(), Value::from(new.unwrap_or_default())),
(
"changes".into(),
Value::Array(vec![Value::Object(Map::from_iter([
("operation".into(), Value::from(operation)),
("path".into(), Value::from(path.to_str())),
("fileType".into(), Value::from("text")),
]))]),
),
])))
}

fn location_value(path: &Path, line: Option<u32>) -> Option<Value> {
if !path.is_absolute() || path.as_os_str().len() > MAX_PATH {
return None;
Expand Down Expand Up @@ -453,7 +462,7 @@ fn forward_event(
event: Result<Update, broadcast::error::RecvError>,
receiver: &mut broadcast::Receiver<Update>,
session: &str,
active: &mut HashMap<String, terminal::State>,
active: &mut HashMap<String, HashMap<String, terminal::State>>,
budget: &mut terminal::Budget,
send: &impl Fn(Update) -> bool,
) -> Result<(), agentkit_acp::AcpRuntimeError> {
Expand All @@ -463,16 +472,43 @@ fn forward_event(
if active.len() >= CAPACITY || active.contains_key(&update.call) {
return Ok(());
}
active.insert(update.call.clone(), terminal::State::default());
active.insert(update.call.clone(), HashMap::new());
} else if let Some(patch) = &update.patch {
let Some(state) = active.get_mut(&update.call) else {
let Some(terminals) = active.get_mut(&update.call) else {
return Ok(());
};
if patch.get("sessionUpdate").and_then(Value::as_str) == Some("terminal_update") {
state.running = patch.get("exitStatus").is_none();
}
if !budget.admit(&mut update, state) {
return Ok(());
let kind = patch.get("sessionUpdate").and_then(Value::as_str);
if matches!(kind, Some("terminal_update" | "terminal_output_chunk")) {
let Some(id) = patch
.get("terminalId")
.and_then(Value::as_str)
.filter(|id| id.len() <= MAX_ID)
else {
return Ok(());
};
if !terminals.contains_key(id)
&& (terminals.len() >= CAPACITY / 4
|| kind == Some("terminal_output_chunk"))
{
return Ok(());
}
let state = terminals
.entry(id.to_owned())
.or_insert_with(terminal::State::running);
if kind == Some("terminal_update")
&& let Some(exit) = patch.get("exitStatus")
{
// Omission preserves the last state; null explicitly clears
// an exit, and only an object declares the terminal exited.
if exit.is_null() {
state.running = true;
} else if exit.is_object() {
state.running = false;
}
}
if !budget.admit(&mut update, state) {
return Ok(());
}
}
} else if active.remove(&update.call).is_none() {
return Ok(());
Expand All @@ -483,30 +519,33 @@ fn forward_event(
}
Ok(_) => {}
Err(error) => {
for (call, state) in active.drain() {
// Loss invalidates the stream as well as its card. Do not leave
// an editor waiting for a terminal exit frame that was dropped.
if state.running
&& !send(Update {
for (call, terminals) in active.drain() {
// Child replay terminals have independent IDs. Invalidate each
// affected stream, without asserting a child process has exited.
for (id, _) in terminals.into_iter().filter(|(_, state)| state.running) {
let mut patch = Map::from_iter([
("sessionUpdate".into(), Value::from("terminal_update")),
("terminalId".into(), Value::from(id.clone())),
(
"_meta".into(),
Value::Object(Map::from_iter([(
"kit/outputIncomplete".into(),
Value::from(true),
)])),
),
]);
if id == call {
patch.insert("exitStatus".into(), Value::Object(Map::new()));
}
if !send(Update {
session: session.into(),
call: call.clone(),
start: None,
patch: Some(Value::Object(Map::from_iter([
("sessionUpdate".into(), Value::from("terminal_update")),
("terminalId".into(), Value::from(call.clone())),
("exitStatus".into(), Value::Object(Map::new())),
(
"_meta".into(),
Value::Object(Map::from_iter([(
"kit/outputIncomplete".into(),
Value::from(true),
)])),
),
]))),
patch: Some(Value::Object(patch)),
ok: false,
})
{
return Err(delivery_error());
}) {
return Err(delivery_error());
}
}
if !send(Update {
session: session.into(),
Expand Down
Loading
Loading