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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- UUID validation on `session_id` path parameter in `session_messages_handler` — returns 400 on invalid input (#1004)
- Startup `tracing::warn!` when `auth_bearer_token` is None and HTTP transport is active (#1004)
- `--init` wizard prompts for `max_history` and `title_max_chars` (#1004)
- `zeph-acp`: `parent_tool_use_id` propagation through `LoopbackEvent::ToolStart/ToolOutput` → `AcpContext` → `loopback_event_to_updates`; subagent events carry `_meta.claudeCode.parentToolUseId` so IDEs can nest subagent output under the parent tool call card (#1008)
- `zeph-core`: `Agent::with_parent_tool_use_id()` builder method; `AgentBuilder` injects the parent tool call UUID when spawning subagents via `SubAgentManager` (#1008)
- `zeph-acp`: `AcpShellExecutor` terminal streaming — `stream_until_exit` helper polls output every 200 ms via `tokio::select!` and emits `ToolCallUpdate` with `_meta.terminal_output` per chunk and `_meta.terminal_exit` on completion; IDEs receive real-time bash output inside tool cards (#1009)
- `zeph-tools`: `locations: Option<Vec<String>>` field on `ToolOutput`; `AcpFileExecutor` populates it with the absolute file path for `read_file`/`write_file` operations; `loopback_event_to_updates` forwards it as `ToolCall.location` for IDE file-following (#1010)
- Unit tests: `loopback_tool_start_parent_tool_use_id_injected_into_meta`, `loopback_tool_output_parent_tool_use_id_injected_into_meta`, `streaming_mode_emits_terminal_exit_notification`, `read_file_returns_location`, `write_file_returns_location` (#1008, #1009, #1010)

### Fixed
- ACP terminal release deferred until after `tool_call_update` notification: IDE now receives `ToolCallContent::Terminal` while the terminal is still alive, enabling tool output display in Zed ACP panel (#1013)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ zeph acp --ws :8080 # WebSocket
- **Slash commands** — `AvailableCommandsUpdate` advertises built-in slash commands (`/help`, `/model`, `/mode`, `/clear`, `/compact`); user input starting with `/` is dispatched to the matching handler
- **LSP diagnostics injection** — `@diagnostics` mention in a Zed prompt triggers LSP diagnostic context injection, providing the agent with current editor diagnostics
- **Session history** — `GET /sessions` lists persisted sessions with title, timestamp, and message count; `GET /sessions/{id}/messages` returns the full event log; sending an existing `session_id` resumes the conversation from stored context; title auto-inferred from the first user message
- **Subagent nesting** — sub-agent output is nested under the parent tool call in the IDE via `_meta.claudeCode.parentToolUseId` carried on every `session_update`, so multi-agent runs appear as collapsible trees in Zed and VS Code ACP tool cards
- **Terminal streaming** — `AcpShellExecutor` streams bash output in real time via `_meta.terminal_output` chunks with a final `_meta.terminal_exit` event; IDEs display live output inside the tool card as commands execute
- **File following** — `ToolCall.location` carries `filePath` for file read/write operations; the IDE editor cursor tracks the agent across files automatically

> [!NOTE]
> `list_sessions`, `fork_session`, and `resume_session` are gated behind the `unstable` feature flag. `UsageUpdate`, `SetSessionModel`, and `SessionInfoUpdate` are gated behind their respective `unstable-session-usage`, `unstable-session-model`, and `unstable-session-info-update` flags.
Expand Down
23 changes: 23 additions & 0 deletions crates/zeph-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ Each tool call is identified by a UUID generated per invocation. The UUID is thr
> [!NOTE]
> Prior to #1003 the fenced-block path did not generate a UUID or emit `ToolStart`. Prior to #1013 the terminal was released inside `execute_in_terminal` before `tool_call_update` was sent, preventing IDEs from displaying terminal output. Both issues are now resolved.

## Subagent IDE visibility

Three `_meta` extensions give IDEs (Zed, VS Code ACP) structured visibility into sub-agent execution:

### Parent tool use ID propagation (#1008)

Every `session_update` emitted by a sub-agent carries `_meta.claudeCode.parentToolUseId` set to the tool call ID that spawned it. IDEs use this to nest sub-agent output under the originating tool card, so multi-agent runs render as collapsible trees rather than interleaved flat messages.

### Terminal streaming (#1009)

`AcpShellExecutor` streams shell output in real time instead of buffering until completion:

- Each chunk of stdout/stderr is emitted as a `session_update` with `_meta.terminal_output`.
- When the command exits, a final `session_update` with `_meta.terminal_exit` carries the exit code.
- IDEs display the output inside the tool card as it arrives.

### Tool call location (#1010)

`ToolCall.location` carries a `filePath` for file read/write tool calls. IDEs move the editor cursor to the referenced file as the agent works, so the user always sees which file is being modified without manually switching tabs.

> [!TIP]
> All three extensions are additive `_meta` fields — they are ignored by clients that do not recognize them and require no feature flag.

### Terminal command timeout

`AcpShellExecutor` enforces a configurable wall-clock timeout on every IDE-proxied shell command (default: 120 seconds, controlled via `acp.terminal_timeout_secs`). When the timeout expires:
Expand Down
95 changes: 92 additions & 3 deletions crates/zeph-acp/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub struct AcpContext {
/// Shared slot for runtime model switching via `set_session_config_option`.
/// When `Some`, the agent should swap its provider before the next turn.
pub provider_override: Arc<std::sync::RwLock<Option<AnyProvider>>>,
/// Tool call ID of the parent agent's tool call that spawned this subagent session.
/// `None` for top-level (non-subagent) sessions.
pub parent_tool_use_id: Option<String>,
}

/// Factory: receives a [`LoopbackChannel`] and optional [`AcpContext`], runs the agent loop.
Expand Down Expand Up @@ -278,6 +281,7 @@ impl ZephAcpAgent {
permission_gate: Some(perm_gate),
cancel_signal,
provider_override,
parent_tool_use_id: None,
})
}

Expand Down Expand Up @@ -1664,6 +1668,7 @@ fn loopback_event_to_updates(event: LoopbackEvent) -> Vec<acp::SessionUpdate> {
tool_name,
tool_call_id,
params,
parent_tool_use_id,
} => {
// Derive a human-readable title from params when available.
// For bash: use the command string (truncated). For others: fall back to tool_name.
Expand Down Expand Up @@ -1693,6 +1698,14 @@ fn loopback_event_to_updates(event: LoopbackEvent) -> Vec<acp::SessionUpdate> {
if let Some(p) = params {
tool_call = tool_call.raw_input(p);
}
if let Some(parent_id) = parent_tool_use_id {
let mut meta = serde_json::Map::new();
meta.insert(
"claudeCode".to_owned(),
serde_json::json!({ "parentToolUseId": parent_id }),
);
tool_call = tool_call.meta(meta);
}
vec![acp::SessionUpdate::ToolCall(tool_call)]
}
LoopbackEvent::ToolOutput {
Expand All @@ -1702,6 +1715,7 @@ fn loopback_event_to_updates(event: LoopbackEvent) -> Vec<acp::SessionUpdate> {
tool_call_id,
is_error,
terminal_id,
parent_tool_use_id,
..
} => {
let acp_locations: Vec<acp::ToolCallLocation> = locations
Expand All @@ -1727,9 +1741,16 @@ fn loopback_event_to_updates(event: LoopbackEvent) -> Vec<acp::SessionUpdate> {
if !acp_locations.is_empty() {
fields = fields.locations(acp_locations);
}
vec![acp::SessionUpdate::ToolCallUpdate(
acp::ToolCallUpdate::new(tool_call_id, fields),
)]
let mut update = acp::ToolCallUpdate::new(tool_call_id, fields);
if let Some(parent_id) = parent_tool_use_id {
let mut meta = serde_json::Map::new();
meta.insert(
"claudeCode".to_owned(),
serde_json::json!({ "parentToolUseId": parent_id }),
);
update = update.meta(meta);
}
vec![acp::SessionUpdate::ToolCallUpdate(update)]
}
LoopbackEvent::Flush => vec![],
#[cfg(feature = "unstable-session-usage")]
Expand Down Expand Up @@ -2128,12 +2149,73 @@ mod tests {
assert!(loopback_event_to_updates(LoopbackEvent::Status(String::new())).is_empty());
}

#[test]
fn loopback_tool_start_parent_tool_use_id_injected_into_meta() {
let event = LoopbackEvent::ToolStart {
tool_name: "bash".to_owned(),
tool_call_id: "child-id".to_owned(),
params: None,
parent_tool_use_id: Some("parent-uuid".to_owned()),
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
match &updates[0] {
acp::SessionUpdate::ToolCall(tc) => {
let meta = tc.meta.as_ref().expect("meta must be present");
let claude_code = meta
.get("claudeCode")
.expect("claudeCode key missing")
.as_object()
.expect("claudeCode must be an object");
assert_eq!(
claude_code.get("parentToolUseId").and_then(|v| v.as_str()),
Some("parent-uuid")
);
}
other => panic!("expected ToolCall, got {other:?}"),
}
}

#[test]
fn loopback_tool_output_parent_tool_use_id_injected_into_meta() {
let event = LoopbackEvent::ToolOutput {
tool_name: "bash".to_owned(),
display: "done".to_owned(),
diff: None,
filter_stats: None,
kept_lines: None,
locations: None,
tool_call_id: "child-id".to_owned(),
is_error: false,
terminal_id: None,
parent_tool_use_id: Some("parent-uuid".to_owned()),
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
match &updates[0] {
acp::SessionUpdate::ToolCallUpdate(tcu) => {
let meta = tcu.meta.as_ref().expect("meta must be present");
let claude_code = meta
.get("claudeCode")
.expect("claudeCode key missing")
.as_object()
.expect("claudeCode must be an object");
assert_eq!(
claude_code.get("parentToolUseId").and_then(|v| v.as_str()),
Some("parent-uuid")
);
}
other => panic!("expected ToolCallUpdate, got {other:?}"),
}
}

#[test]
fn loopback_tool_start_maps_to_tool_call_in_progress() {
let event = LoopbackEvent::ToolStart {
tool_name: "bash".to_owned(),
tool_call_id: "test-id".to_owned(),
params: None,
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand All @@ -2154,6 +2236,7 @@ mod tests {
tool_name: "bash".to_owned(),
tool_call_id: "test-id-2".to_owned(),
params: Some(params),
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand All @@ -2174,6 +2257,7 @@ mod tests {
tool_name: "bash".to_owned(),
tool_call_id: "test-id-3".to_owned(),
params: Some(params),
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
match &updates[0] {
Expand All @@ -2198,6 +2282,7 @@ mod tests {
tool_call_id: "test-id".to_owned(),
is_error: false,
terminal_id: None,
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand All @@ -2221,6 +2306,7 @@ mod tests {
tool_call_id: "test-id".to_owned(),
is_error: true,
terminal_id: None,
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand Down Expand Up @@ -2525,6 +2611,7 @@ mod tests {
tool_call_id: "test-id".to_owned(),
is_error: false,
terminal_id: None,
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand All @@ -2551,6 +2638,7 @@ mod tests {
tool_call_id: "test-id".to_owned(),
is_error: false,
terminal_id: None,
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand Down Expand Up @@ -2588,6 +2676,7 @@ mod tests {
tool_call_id: "tid-1".to_owned(),
is_error: false,
terminal_id: Some("term-42".to_owned()),
parent_tool_use_id: None,
};
let updates = loopback_event_to_updates(event);
assert_eq!(updates.len(), 1);
Expand Down
10 changes: 10 additions & 0 deletions crates/zeph-acp/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ impl zeph_tools::ToolExecutor for AcpFileExecutor {
diff: None,
streamed: false,
terminal_id: None,
locations: Some(vec![params.path]),
}))
}
"write_file" if self.can_write => {
Expand All @@ -202,6 +203,7 @@ impl zeph_tools::ToolExecutor for AcpFileExecutor {
diff: None,
streamed: false,
terminal_id: None,
locations: Some(vec![params.path]),
}))
}
_ => Ok(None),
Expand Down Expand Up @@ -318,6 +320,10 @@ mod tests {

let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
assert_eq!(result.summary, "hello world");
assert_eq!(
result.locations.as_deref(),
Some(&[test_path("test.txt")][..])
);
})
.await;
}
Expand All @@ -344,6 +350,10 @@ mod tests {

let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
assert!(result.summary.contains(&test_path("out.txt")));
assert_eq!(
result.locations.as_deref(),
Some(&[test_path("out.txt")][..])
);
})
.await;
}
Expand Down
Loading
Loading