diff --git a/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs b/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs index 27af41429..6598cf8f1 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs @@ -40,8 +40,8 @@ use agent_client_protocol::schema::v1::{ SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, }; use agent_client_protocol::{ - Agent, Builder, Client, ConnectionTo, HandleDispatchFrom, Responder, RunWithConnectionTo, on_receive_notification, - on_receive_request, + Agent, Builder, Client, ConnectionTo, Dispatch, HandleDispatchFrom, Handled, Responder, RunWithConnectionTo, + on_receive_notification, on_receive_request, }; use futures::StreamExt; use serde_json::json; @@ -635,6 +635,43 @@ where }, on_receive_notification!(), ) + .with_handler(LodySubagentManagementHandler { agent }) +} + +struct LodySubagentManagementHandler { + agent: Arc, +} + +impl HandleDispatchFrom for LodySubagentManagementHandler { + async fn handle_dispatch_from( + &mut self, + message: Dispatch, + connection: ConnectionTo, + ) -> Result, SdkError> { + let Dispatch::Request(request, responder) = message else { + return Ok(Handled::No { message, retry: false }); + }; + if !super::lody::is_lody_subagent_management_method(request.method()) { + return Ok(Handled::No { + message: Dispatch::Request(request, responder), + retry: false, + }); + } + + let (method, params) = request.into_parts(); + let agent = Arc::clone(&self.agent); + connection + .spawn(async move { + let result = super::lody::handle_lody_subagent_management(&agent, &method, params).await; + responder.respond_with_result(result) + }) + .map_err(|error| SdkError::internal_error().data(error.to_string()))?; + Ok(Handled::Yes) + } + + fn describe_chain(&self) -> impl std::fmt::Debug { + "LodySubagentManagementHandler" + } } async fn handle_initialize( @@ -653,7 +690,13 @@ async fn handle_initialize( INITIALIZE_VERSION_MISMATCH_LOG ); } - let mut capabilities = advertised_agent_capabilities(); + let controller = agent.local_tool_registry.subagent_controller(); + let mut capabilities = advertised_agent_capabilities( + controller.is_some(), + controller + .as_deref() + .is_some_and(vtcode_core::subagents::SubagentController::background_subagents_enabled), + ); capabilities.prompt_capabilities.embedded_context = true; capabilities.prompt_capabilities.image = true; capabilities.prompt_capabilities.audio = true; @@ -668,12 +711,16 @@ async fn handle_initialize( request_cx.respond(response) } -fn advertised_agent_capabilities() -> acp::AgentCapabilities { +fn advertised_agent_capabilities(has_subagent_controller: bool, background_enabled: bool) -> acp::AgentCapabilities { let mut capabilities = acp::AgentCapabilities::default(); capabilities.load_session = true; capabilities.session_capabilities = acp::SessionCapabilities::new() .list(acp::SessionListCapabilities::new()) .resume(acp::SessionResumeCapabilities::new()); + super::lody_usage::add_lody_usage_capability(&mut capabilities); + if has_subagent_controller { + super::lody::add_lody_subagent_management_capability(&mut capabilities, background_enabled); + } capabilities } @@ -1259,6 +1306,7 @@ async fn run_prompt(agent: Arc, args: PromptRequest) -> Result, args: PromptRequest) -> Result = AsyncMutex::const_new(()); @@ -1630,11 +1681,232 @@ mod tests { #[test] fn advertised_capabilities_include_session_discovery_and_resume() { - let capabilities = advertised_agent_capabilities(); + let capabilities = advertised_agent_capabilities(false, false); assert!(capabilities.load_session); assert!(capabilities.session_capabilities.list.is_some()); assert!(capabilities.session_capabilities.resume.is_some()); + let lody = &capabilities.meta.expect("Lody capability metadata")["lody"]; + assert_eq!(lody["usage"]["version"], 1); + assert!(lody.get("subagents").is_none()); + } + + #[test] + fn advertised_capabilities_include_lody_subagent_management_with_subagents() { + let capabilities = advertised_agent_capabilities(true, false); + let lody = &capabilities.meta.expect("Lody capability metadata")["lody"]; + + assert_eq!(lody["subagents"]["version"], 1); + assert_eq!(lody["subagents"]["lifecycle"], true); + assert_eq!(lody["subagents"]["list"], true); + assert_eq!(lody["subagents"]["cancel"], true); + assert_eq!(lody["subagents"]["output"], true); + assert_eq!(lody["usage"]["version"], 1); + assert!(lody.get("tasks").is_none()); + } + + #[test] + fn advertised_capabilities_include_background_tasks_only_when_enabled() { + let capabilities = advertised_agent_capabilities(true, true); + let lody = &capabilities.meta.expect("Lody capability metadata")["lody"]; + + assert_eq!(lody["tasks"]["version"], 1); + assert_eq!(lody["tasks"]["background"], true); + } + + #[tokio::test] + async fn lody_subagent_management_extensions_round_trip_over_official_acp_duplex() { + let workspace = TempDir::new().expect("Lody management workspace"); + write_lody_background_fixture(workspace.path()); + let agent = Arc::new(build_wire_test_agent(workspace.path()).await); + let mut vt_config = vtcode_core::config::VTCodeConfig::default(); + vt_config.subagents.enabled = true; + vt_config.subagents.background.enabled = true; + Box::pin(super::super::attach_acp_subagent_controller( + &agent.local_tool_registry, + &agent.config, + &[], + Some(&vt_config), + None, + )) + .await; + assert!(agent.local_tool_registry.has_subagent_controller()); + let controller = agent + .local_tool_registry + .subagent_controller() + .expect("Lody management controller"); + + let (agent_channel, client_channel) = Channel::duplex(); + let agent_connection = install_handlers( + Agent.builder().name("vtcode-lody-management-test"), + Arc::clone(&agent), + ) + .connect_with(agent_channel, { + let agent = Arc::clone(&agent); + async move |cx: ConnectionTo| { + agent.attach_client(crate::zed::connection::ConnectionHandle::new(cx)); + std::future::pending::>().await + } + }); + let agent_task = tokio::spawn(agent_connection); + + let workspace_path = workspace.path().to_path_buf(); + let client_connection = Client + .builder() + .connect_with(client_channel, async move |cx: ConnectionTo| { + let initialized = cx + .send_request(InitializeRequest::new(acp::ProtocolVersion::V1)) + .block_task() + .await?; + let lody = &initialized.agent_capabilities.meta.expect("Lody capabilities")["lody"]; + assert_eq!(lody["subagents"]["list"], true); + assert_eq!(lody["tasks"]["background"], true); + + let session = cx + .send_request(NewSessionRequest::new(workspace_path.clone())) + .block_task() + .await?; + controller.set_parent_session_id(session.session_id.to_string()).await; + let background = controller + .spawn_background_subprocess(SpawnBackgroundSubprocessRequest { + agent_type: Some("background-demo".to_string()), + ..SpawnBackgroundSubprocessRequest::default() + }) + .await + .expect("spawn owned background task"); + let params = serde_json::value::to_raw_value(&serde_json::json!({ + "sessionId": session.session_id, + "activeOnly": false, + }))?; + let response = cx + .send_request(acp::ClientRequest::ExtMethodRequest(acp::ExtRequest::new( + super::super::lody::LODY_SUBAGENTS_LIST_METHOD, + params.into(), + ))) + .block_task() + .await?; + assert_eq!(response["tasks"][0]["taskId"], background.id); + + let mut output = serde_json::Value::Null; + for _ in 0..20 { + let params = serde_json::value::to_raw_value(&serde_json::json!({ + "sessionId": session.session_id, + "taskId": background.id, + "tail": 1, + }))?; + output = cx + .send_request(acp::ClientRequest::ExtMethodRequest(acp::ExtRequest::new( + super::super::lody::LODY_SUBAGENTS_OUTPUT_METHOD, + params.into(), + ))) + .block_task() + .await?; + if output["output"] == "lody-output-two" { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert_eq!(output["output"], "lody-output-two"); + + let foreign_session = cx + .send_request(NewSessionRequest::new(workspace_path.clone())) + .block_task() + .await?; + let params = serde_json::value::to_raw_value(&serde_json::json!({ + "sessionId": foreign_session.session_id, + "activeOnly": false, + }))?; + let foreign_list = cx + .send_request(acp::ClientRequest::ExtMethodRequest(acp::ExtRequest::new( + super::super::lody::LODY_SUBAGENTS_LIST_METHOD, + params.into(), + ))) + .block_task() + .await?; + assert_eq!(foreign_list, serde_json::json!({ "tasks": [] })); + + let foreign_error = lody_output_error(&cx, &foreign_session.session_id, &background.id).await; + let unknown_error = lody_output_error(&cx, &session.session_id, "unknown-task").await; + assert_eq!(foreign_error, unknown_error); + assert_eq!(foreign_error.data, Some(serde_json::json!({ "reason": "unknown_task" }))); + + let params = serde_json::value::to_raw_value(&serde_json::json!({ + "sessionId": session.session_id, + "taskId": background.id, + "reason": "test complete", + }))?; + let cancelled = cx + .send_request(acp::ClientRequest::ExtMethodRequest(acp::ExtRequest::new( + super::super::lody::LODY_SUBAGENTS_CANCEL_METHOD, + params.into(), + ))) + .block_task() + .await?; + assert_eq!(cancelled, serde_json::json!({})); + Ok(()) + }); + + tokio::time::timeout(Duration::from_secs(5), client_connection) + .await + .expect("Lody management client should finish") + .expect("Lody management protocol flow should succeed"); + agent_task.abort(); + drop(agent_task.await); + } + + async fn lody_output_error( + connection: &ConnectionTo, + session_id: &acp::SessionId, + task_id: &str, + ) -> acp::Error { + let params = serde_json::value::to_raw_value(&serde_json::json!({ + "sessionId": session_id, + "taskId": task_id, + })) + .expect("serialize Lody output request"); + connection + .send_request(acp::ClientRequest::ExtMethodRequest(acp::ExtRequest::new( + super::super::lody::LODY_SUBAGENTS_OUTPUT_METHOD, + params.into(), + ))) + .block_task() + .await + .expect_err("Lody output request should fail") + } + + fn write_lody_background_fixture(workspace: &std::path::Path) { + let agent_dir = workspace.join(".vtcode/agents"); + std::fs::create_dir_all(&agent_dir).expect("create Lody test agent directory"); + std::fs::write( + agent_dir.join("background-demo.md"), + r#"--- +name: background-demo +description: Lody management protocol fixture. +tools: + - command_session +background: true +maxTurns: 2 +initialPrompt: Report readiness once. +--- + +Run the managed background fixture. +"#, + ) + .expect("write Lody test agent"); + let scripts_dir = workspace.join("scripts"); + std::fs::create_dir_all(&scripts_dir).expect("create Lody test scripts directory"); + let script = scripts_dir.join("demo-background-subagent.sh"); + std::fs::write(&script, "#!/bin/sh\nprintf 'lody-output-one\\nlody-output-two\\n'\nsleep 30\n") + .expect("write Lody background script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&script) + .expect("Lody background script metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(script, permissions).expect("make Lody background script executable"); + } } use vtcode_core::llm::provider::{LLMError, LLMErrorMetadata}; @@ -1985,6 +2257,7 @@ mod tests { requests: Arc>>, tool_calls: Vec<(String, String)>, mutation_before_call: Option<(usize, PathBuf, String)>, + emit_usage: bool, } struct PartialThenFailProvider; @@ -2066,6 +2339,11 @@ mod tests { )]), finish_reason: vtcode_core::llm::provider::FinishReason::ToolCalls, model: "wire-model".to_string(), + usage: self.emit_usage.then(|| Usage { + prompt_tokens: u32::try_from(100 + response_index).expect("fixture usage fits u32"), + completion_tokens: 11, + ..Usage::default() + }), ..LLMResponse::default() }; vec![ @@ -2078,6 +2356,11 @@ mod tests { content: Some("Tool complete.".to_string()), finish_reason: vtcode_core::llm::provider::FinishReason::Stop, model: "wire-model".to_string(), + usage: self.emit_usage.then(|| Usage { + prompt_tokens: u32::try_from(100 + response_index).expect("fixture usage fits u32"), + completion_tokens: 12, + ..Usage::default() + }), ..LLMResponse::default() }; vec![ @@ -2347,6 +2630,7 @@ mod tests { requests: Arc::clone(&factory_requests), tool_calls: vec![("list_files".to_string(), r#"{"path":""}"#.to_string())], mutation_before_call: None, + emit_usage: true, }) }), ); @@ -2357,7 +2641,7 @@ mod tests { .expect("write launch workspace fixture"); let agent = Arc::new(build_wire_test_agent(launch_workspace.path()).await); let (agent_channel, client_channel) = Channel::duplex(); - let (updates_tx, mut updates_rx) = mpsc::unbounded_channel(); + let (notifications_tx, mut notifications_rx) = mpsc::unbounded_channel(); let agent_connection = install_handlers(Agent.builder().name("vtcode-stream-tool-test"), Arc::clone(&agent)) .connect_with(agent_channel, { @@ -2372,8 +2656,8 @@ mod tests { let client_connection = Client .builder() .on_receive_notification( - async move |notification: acp::SessionNotification, _cx| { - drop(updates_tx.send(notification)); + async move |notification: acp::AgentNotification, _cx| { + drop(notifications_tx.send(notification)); Ok(()) }, on_receive_notification!(), @@ -2406,7 +2690,31 @@ mod tests { agent_task.abort(); drop(agent_task.await); - let updates = std::iter::from_fn(|| updates_rx.try_recv().ok()).collect::>(); + let notifications = std::iter::from_fn(|| notifications_rx.try_recv().ok()).collect::>(); + let updates = notifications + .iter() + .filter_map(|notification| match notification { + acp::AgentNotification::SessionNotification(notification) => Some(notification), + _ => None, + }) + .collect::>(); + let usage_updates = notifications + .iter() + .filter_map(|notification| match notification { + acp::AgentNotification::ExtNotification(notification) + if notification.method.as_ref().trim_start_matches('_') + == super::super::lody_usage::LODY_SESSION_USAGE_UPDATE_METHOD.trim_start_matches('_') => + { + serde_json::from_str::(notification.params.get()).ok() + } + _ => None, + }) + .collect::>(); + assert_eq!(usage_updates.len(), 2, "each tool-loop response must publish one usage delta"); + assert_eq!(usage_updates[0]["usage"]["inputTokens"], 100); + assert_eq!(usage_updates[0]["usage"]["outputTokens"], 11); + assert_eq!(usage_updates[1]["usage"]["inputTokens"], 101); + assert_eq!(usage_updates[1]["usage"]["outputTokens"], 12); assert!( updates .iter() @@ -2491,6 +2799,7 @@ mod tests { requests: Arc::clone(&factory_requests), tool_calls: vec![("task_tracker".to_string(), task_arguments.clone())], mutation_before_call: None, + emit_usage: false, }) }), ); @@ -2605,6 +2914,7 @@ mod tests { requests: Arc::clone(&factory_requests), tool_calls: vec![("apply_patch".to_string(), patch_arguments.clone())], mutation_before_call: None, + emit_usage: false, }) }), ); @@ -2747,6 +3057,7 @@ mod tests { requests: Arc::clone(&factory_requests), tool_calls: tool_calls.clone(), mutation_before_call: Some((1, mutation_path.clone(), "current\n".to_string())), + emit_usage: false, }) }), ); diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody.rs new file mode 100644 index 000000000..d3dc2a61d --- /dev/null +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody.rs @@ -0,0 +1,422 @@ +use crate::acp; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashSet; +use vtcode_core::subagents::{ + BackgroundSubprocessEntry, BackgroundSubprocessStatus, SubagentProgressEvent, SubagentStatus, SubagentStatusEntry, + extract_tail_lines, normalize_output_tail_lines, +}; + +use super::ZedAgent; +use agent_client_protocol::Error as SdkError; + +const LODY_EXTENSION_VERSION: u8 = 1; +pub(super) const LODY_SUBAGENTS_LIST_METHOD: &str = "_lody/subagents/list"; +pub(super) const LODY_SUBAGENTS_CANCEL_METHOD: &str = "_lody/subagents/cancel"; +pub(super) const LODY_SUBAGENTS_OUTPUT_METHOD: &str = "_lody/subagents/output"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LodyTaskMeta<'a> { + version: u8, + task_id: &'a str, + kind: &'static str, + status: &'static str, + description: &'a str, + actor: &'a str, + started_at_epoch_seconds: f64, + #[serde(skip_serializing_if = "Option::is_none")] + ended_at_epoch_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a str>, +} + +struct TaskSnapshot<'a> { + id: &'a str, + title: &'a str, + status: acp::ToolCallStatus, + meta: LodyTaskMeta<'a>, +} + +pub(super) fn lody_task_id(event: &SubagentProgressEvent) -> &str { + match event { + SubagentProgressEvent::Subagent { task, .. } => &task.id, + SubagentProgressEvent::BackgroundProcess { task, .. } => &task.id, + } +} + +pub(super) fn lody_task_session_update( + event: SubagentProgressEvent, + previously_emitted: bool, +) -> anyhow::Result { + let snapshot = match &event { + SubagentProgressEvent::Subagent { task, .. } => subagent_snapshot(task), + SubagentProgressEvent::BackgroundProcess { task, .. } => background_snapshot(task), + }; + let tool_call_id = format!("task:{}", snapshot.id); + let task_value = serde_json::to_value(snapshot.meta)?; + let mut lody = Map::new(); + let _ = lody.insert("task".to_string(), task_value); + let mut meta = Map::new(); + let _ = meta.insert("lody".to_string(), Value::Object(lody)); + + if previously_emitted { + let fields = acp::ToolCallUpdateFields::new() + .title(snapshot.title.to_string()) + .kind(acp::ToolKind::Think) + .status(snapshot.status) + .content(task_content(&event)); + Ok(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(tool_call_id, fields).meta(meta))) + } else { + Ok(acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_call_id, snapshot.title) + .kind(acp::ToolKind::Think) + .status(snapshot.status) + .content(task_content(&event)) + .meta(meta), + )) + } +} + +pub(super) fn add_lody_subagent_lifecycle_capability(capabilities: &mut acp::AgentCapabilities) { + let mut subagents = Map::new(); + let _ = subagents.insert("version".to_string(), Value::from(LODY_EXTENSION_VERSION)); + let _ = subagents.insert("lifecycle".to_string(), Value::Bool(true)); + + if let Some(lody) = lody_capabilities_mut(capabilities) { + let _ = lody.insert("subagents".to_string(), Value::Object(subagents)); + } +} + +pub(super) fn lody_capabilities_mut(capabilities: &mut acp::AgentCapabilities) -> Option<&mut Map> { + let meta = capabilities.meta.get_or_insert_with(Map::new); + if !matches!(meta.get("lody"), Some(Value::Object(_))) { + let _ = meta.insert("lody".to_string(), Value::Object(Map::new())); + } + meta.get_mut("lody").and_then(Value::as_object_mut) +} + +pub(super) fn add_lody_subagent_management_capability( + capabilities: &mut acp::AgentCapabilities, + background_enabled: bool, +) { + add_lody_subagent_lifecycle_capability(capabilities); + let Some(Value::Object(lody)) = capabilities.meta.as_mut().and_then(|meta| meta.get_mut("lody")) else { + return; + }; + let Some(Value::Object(subagents)) = lody.get_mut("subagents") else { + return; + }; + for operation in ["list", "cancel", "output"] { + let _ = subagents.insert(operation.to_string(), Value::Bool(true)); + } + if background_enabled { + let mut tasks = Map::new(); + let _ = tasks.insert("version".to_string(), Value::from(LODY_EXTENSION_VERSION)); + let _ = tasks.insert("background".to_string(), Value::Bool(true)); + let _ = lody.insert("tasks".to_string(), Value::Object(tasks)); + } +} + +pub(super) fn is_lody_subagent_management_method(method: &str) -> bool { + matches!(method, LODY_SUBAGENTS_LIST_METHOD | LODY_SUBAGENTS_CANCEL_METHOD | LODY_SUBAGENTS_OUTPUT_METHOD) +} + +pub(super) async fn handle_lody_subagent_management( + agent: &ZedAgent, + method: &str, + params: Value, +) -> Result { + match method { + LODY_SUBAGENTS_LIST_METHOD => list_subagents(agent, parse_params(params)?).await, + LODY_SUBAGENTS_CANCEL_METHOD => cancel_subagent(agent, parse_params(params)?).await, + LODY_SUBAGENTS_OUTPUT_METHOD => subagent_output(agent, parse_params(params)?).await, + _ => Err(SdkError::method_not_found()), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSubagentsRequest { + session_id: String, + #[serde(default)] + active_only: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CancelSubagentRequest { + session_id: String, + task_id: String, + #[serde(default)] + reason: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubagentOutputRequest { + session_id: String, + task_id: String, + tail: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ManagedTask { + task_id: String, + description: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + subagent_type: Option, + started_at_epoch_seconds: f64, + ended_at_epoch_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stop_reason: Option, +} + +fn parse_params Deserialize<'de>>(params: Value) -> Result { + serde_json::from_value(params).map_err(|error| { + SdkError::invalid_params().data(serde_json::json!({ + "reason": "invalid_lody_subagent_params", + "detail": error.to_string(), + })) + }) +} + +fn session_controller( + agent: &ZedAgent, + session_id: &str, +) -> Result<(super::super::types::SessionHandle, std::sync::Arc), SdkError> +{ + let session_id = acp::SessionId::new(session_id); + let session = agent + .session_handle(&session_id) + .ok_or_else(|| SdkError::invalid_params().data(serde_json::json!({ "reason": "unknown_session" })))?; + let controller = agent.session_subagent_controller(&session).ok_or_else(|| { + SdkError::invalid_params().data(serde_json::json!({ "reason": "subagent_management_unavailable" })) + })?; + Ok((session, controller)) +} + +async fn list_subagents(agent: &ZedAgent, request: ListSubagentsRequest) -> Result { + let (_session, controller) = session_controller(agent, &request.session_id)?; + let children = controller.status_entries().await; + let owned_children = owned_child_ids(&request.session_id, &children); + let mut tasks = children + .iter() + .filter(|entry| owned_children.contains(entry.id.as_str())) + .filter(|entry| !request.active_only || !entry.status.is_terminal()) + .map(managed_child_task) + .collect::>(); + tasks.extend( + controller + .background_status_entries() + .await + .iter() + .filter(|entry| entry.owner_session_id.as_deref() == Some(request.session_id.as_str())) + .filter(|entry| !request.active_only || entry.status.is_active()) + .map(managed_background_task), + ); + tasks.sort_unstable_by(|left, right| left.task_id.cmp(&right.task_id)); + Ok(serde_json::json!({ "tasks": tasks })) +} + +async fn cancel_subagent(agent: &ZedAgent, request: CancelSubagentRequest) -> Result { + let (_session, controller) = session_controller(agent, &request.session_id)?; + let children = controller.status_entries().await; + if owned_child_ids(&request.session_id, &children).contains(request.task_id.as_str()) { + drop(controller.close(&request.task_id).await.map_err(internal_management_error)?); + return Ok(serde_json::json!({})); + } + let owned_background = controller.background_status_entries().await.into_iter().any(|entry| { + entry.id == request.task_id && entry.owner_session_id.as_deref() == Some(request.session_id.as_str()) + }); + if owned_background { + drop( + controller + .force_cancel_background(&request.task_id) + .await + .map_err(internal_management_error)?, + ); + return Ok(serde_json::json!({})); + } + let _ = request.reason; + Err(unknown_task_error()) +} + +async fn subagent_output(agent: &ZedAgent, request: SubagentOutputRequest) -> Result { + let (_session, controller) = session_controller(agent, &request.session_id)?; + let tail = normalize_output_tail_lines(request.tail).map_err(|error| { + SdkError::invalid_params().data(serde_json::json!({ + "reason": "invalid_tail", + "detail": error.to_string(), + })) + })?; + let children = controller.status_entries().await; + let output = if owned_child_ids(&request.session_id, &children).contains(request.task_id.as_str()) { + let snapshot = controller + .snapshot_for_thread(&request.task_id) + .await + .map_err(internal_management_error)?; + let transcript = snapshot + .snapshot + .messages + .iter() + .map(|message| message.content.as_text()) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + extract_tail_lines(&transcript, tail) + } else { + let owned_background = controller.background_status_entries().await.into_iter().any(|entry| { + entry.id == request.task_id && entry.owner_session_id.as_deref() == Some(request.session_id.as_str()) + }); + if !owned_background { + return Err(unknown_task_error()); + } + controller + .background_output_tail(&request.task_id, Some(tail)) + .await + .map_err(internal_management_error)? + }; + Ok(serde_json::json!({ "output": output })) +} + +pub(super) fn owned_child_ids<'a>(session_id: &str, entries: &'a [SubagentStatusEntry]) -> HashSet<&'a str> { + let mut owned_sessions = HashSet::from([session_id]); + let mut owned_ids = HashSet::new(); + loop { + let before = owned_ids.len(); + for entry in entries { + if owned_sessions.contains(entry.parent_thread_id.as_str()) { + let _ = owned_ids.insert(entry.id.as_str()); + let _ = owned_sessions.insert(entry.session_id.as_str()); + } + } + if owned_ids.len() == before { + break; + } + } + owned_ids +} + +fn managed_child_task(entry: &SubagentStatusEntry) -> ManagedTask { + let status = match entry.status { + SubagentStatus::Queued | SubagentStatus::Running | SubagentStatus::Waiting => "running", + SubagentStatus::Completed => "completed", + SubagentStatus::Failed => "failed", + SubagentStatus::Closed => "killed", + }; + ManagedTask { + task_id: entry.id.clone(), + description: entry.description.clone(), + status, + agent_id: Some(entry.session_id.clone()), + subagent_type: Some(entry.agent_name.clone()), + started_at_epoch_seconds: epoch_seconds(entry.created_at), + ended_at_epoch_seconds: entry.completed_at.map(epoch_seconds), + stop_reason: entry.error.clone(), + } +} + +fn managed_background_task(entry: &BackgroundSubprocessEntry) -> ManagedTask { + let status = match entry.status { + BackgroundSubprocessStatus::Starting | BackgroundSubprocessStatus::Running => "running", + BackgroundSubprocessStatus::Stopped => "completed", + BackgroundSubprocessStatus::Error => "failed", + }; + ManagedTask { + task_id: entry.id.clone(), + description: entry.description.clone(), + status, + agent_id: Some(entry.session_id.clone()), + subagent_type: Some(entry.agent_name.clone()), + started_at_epoch_seconds: epoch_seconds(entry.started_at.unwrap_or(entry.created_at)), + ended_at_epoch_seconds: entry.ended_at.map(epoch_seconds), + stop_reason: entry.error.clone(), + } +} + +fn unknown_task_error() -> SdkError { + SdkError::invalid_params().data(serde_json::json!({ "reason": "unknown_task" })) +} + +fn internal_management_error(error: anyhow::Error) -> SdkError { + SdkError::internal_error().data(serde_json::json!({ + "reason": "subagent_management_failed", + "detail": error.to_string(), + })) +} + +fn subagent_snapshot(entry: &SubagentStatusEntry) -> TaskSnapshot<'_> { + let (lody_status, acp_status) = match entry.status { + SubagentStatus::Queued => ("pending", acp::ToolCallStatus::Pending), + SubagentStatus::Running | SubagentStatus::Waiting => ("in_progress", acp::ToolCallStatus::InProgress), + SubagentStatus::Completed => ("completed", acp::ToolCallStatus::Completed), + SubagentStatus::Failed | SubagentStatus::Closed => ("failed", acp::ToolCallStatus::Failed), + }; + TaskSnapshot { + id: &entry.id, + title: &entry.display_label, + status: acp_status, + meta: LodyTaskMeta { + version: LODY_EXTENSION_VERSION, + task_id: &entry.id, + kind: "subagent", + status: lody_status, + description: &entry.description, + actor: &entry.agent_name, + started_at_epoch_seconds: epoch_seconds(entry.created_at), + ended_at_epoch_seconds: entry.completed_at.map(epoch_seconds), + summary: entry.summary.as_deref(), + error: entry.error.as_deref(), + }, + } +} + +fn background_snapshot(entry: &BackgroundSubprocessEntry) -> TaskSnapshot<'_> { + let (lody_status, acp_status) = match entry.status { + BackgroundSubprocessStatus::Starting => ("pending", acp::ToolCallStatus::Pending), + BackgroundSubprocessStatus::Running => ("in_progress", acp::ToolCallStatus::InProgress), + BackgroundSubprocessStatus::Stopped => ("completed", acp::ToolCallStatus::Completed), + BackgroundSubprocessStatus::Error => ("failed", acp::ToolCallStatus::Failed), + }; + TaskSnapshot { + id: &entry.id, + title: &entry.display_label, + status: acp_status, + meta: LodyTaskMeta { + version: LODY_EXTENSION_VERSION, + task_id: &entry.id, + kind: "background", + status: lody_status, + description: &entry.description, + actor: &entry.agent_name, + started_at_epoch_seconds: epoch_seconds(entry.started_at.unwrap_or(entry.created_at)), + ended_at_epoch_seconds: entry.ended_at.map(epoch_seconds), + summary: entry.summary.as_deref(), + error: entry.error.as_deref(), + }, + } +} + +fn epoch_seconds(timestamp: DateTime) -> f64 { + timestamp.timestamp_millis().max(0) as f64 / 1_000.0 +} + +fn task_content(event: &SubagentProgressEvent) -> Vec { + let text = match event { + SubagentProgressEvent::Subagent { task, .. } => { + task.error.as_deref().or(task.summary.as_deref()).unwrap_or(&task.description) + } + SubagentProgressEvent::BackgroundProcess { task, .. } => { + task.error.as_deref().or(task.summary.as_deref()).unwrap_or(&task.description) + } + }; + vec![acp::ContentBlock::Text(acp::TextContent::new(text)).into()] +} diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody_tests.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody_tests.rs new file mode 100644 index 000000000..db74406e9 --- /dev/null +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody_tests.rs @@ -0,0 +1,78 @@ +use chrono::Utc; +use proptest::prelude::*; +use vtcode_core::subagents::{SubagentStatus, SubagentStatusEntry}; + +use super::lody::owned_child_ids; + +fn child(id: &str, session_id: &str, parent_thread_id: &str) -> SubagentStatusEntry { + let now = Utc::now(); + SubagentStatusEntry { + id: id.to_string(), + session_id: session_id.to_string(), + parent_thread_id: parent_thread_id.to_string(), + agent_name: "worker".to_string(), + display_label: id.to_string(), + description: id.to_string(), + source: "test".to_string(), + color: None, + status: SubagentStatus::Running, + background: false, + depth: 1, + created_at: now, + updated_at: now, + completed_at: None, + summary: None, + error: None, + transcript_path: None, + nickname: None, + } +} + +#[test] +fn ownership_selection_includes_nested_children_and_excludes_foreign_siblings() { + let entries = vec![ + child("direct", "direct-session", "session-a"), + child("nested", "nested-session", "direct-session"), + child("foreign", "foreign-session", "session-b"), + ]; + + let owned = owned_child_ids("session-a", &entries); + + assert_eq!(owned, std::collections::HashSet::from(["direct", "nested"])); +} + +proptest! { + #[test] + fn ownership_selection_is_the_transitive_session_closure( + owned_len in 1usize..=16, + foreign_len in 1usize..=16, + ) { + let mut entries = Vec::with_capacity(owned_len + foreign_len); + let mut parent = "session-a".to_string(); + for index in 0..owned_len { + let id = format!("owned-{index}"); + let session_id = format!("owned-session-{index}"); + entries.push(child(&id, &session_id, &parent)); + parent = session_id; + } + parent = "session-b".to_string(); + for index in 0..foreign_len { + let id = format!("foreign-{index}"); + let session_id = format!("foreign-session-{index}"); + entries.push(child(&id, &session_id, &parent)); + parent = session_id; + } + + let owned = owned_child_ids("session-a", &entries); + + prop_assert_eq!(owned.len(), owned_len); + for index in 0..owned_len { + let id = format!("owned-{index}"); + prop_assert!(owned.contains(id.as_str())); + } + for index in 0..foreign_len { + let id = format!("foreign-{index}"); + prop_assert!(!owned.contains(id.as_str())); + } + } +} diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs new file mode 100644 index 000000000..897ac4897 --- /dev/null +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs @@ -0,0 +1,252 @@ +use crate::acp; +#[cfg(test)] +use crate::zed::connection::ConnectionHandle; +use serde::Serialize; +use std::collections::BTreeMap; +use std::sync::Arc; +use vtcode_core::llm::Usage; + +use super::{ZedAgent, lody::lody_capabilities_mut}; + +const LODY_EXTENSION_VERSION: u8 = 1; +pub(super) const LODY_SESSION_USAGE_UPDATE_METHOD: &str = "_lody/session/usage_update"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LodyUsage { + input_tokens: u32, + output_tokens: u32, + cache_read_input_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cache_creation_input_tokens: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LodySessionUsageUpdate<'a> { + session_id: &'a str, + usage: LodyUsage, + model_usage: BTreeMap<&'a str, LodyUsage>, +} + +pub(super) fn add_lody_usage_capability(capabilities: &mut acp::AgentCapabilities) { + let usage = serde_json::json!({ "version": LODY_EXTENSION_VERSION }); + if let Some(lody) = lody_capabilities_mut(capabilities) { + let _ = lody.insert("usage".to_string(), usage); + } +} + +fn usage_notification(session_id: &acp::SessionId, model: &str, usage: &Usage) -> anyhow::Result { + let delta = LodyUsage { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + cache_read_input_tokens: usage.cache_read_tokens_or_fallback(), + cache_creation_input_tokens: usage.cache_creation_tokens, + }; + let payload = LodySessionUsageUpdate { + session_id: session_id.0.as_ref(), + usage: delta.clone(), + model_usage: BTreeMap::from([(model, delta)]), + }; + let params = serde_json::value::to_raw_value(&payload)?; + Ok(acp::ExtNotification::new(LODY_SESSION_USAGE_UPDATE_METHOD, Arc::from(params))) +} + +fn response_usage_notification( + session_id: &acp::SessionId, + fallback_model: &str, + response: &vtcode_core::llm::provider::LLMResponse, +) -> anyhow::Result> { + let Some(usage) = response.usage.as_ref() else { + return Ok(None); + }; + let model = if response.model.is_empty() { + fallback_model + } else { + response.model.as_str() + }; + usage_notification(session_id, model, usage).map(Some) +} + +#[cfg(test)] +fn send_usage_update( + client: &ConnectionHandle, + session_id: &acp::SessionId, + model: &str, + usage: &Usage, +) -> anyhow::Result<()> { + client + .send_ext_notification(usage_notification(session_id, model, usage)?) + .map_err(|error| anyhow::anyhow!(error.to_string())) +} + +impl ZedAgent { + pub(super) fn publish_lody_usage( + &self, + session_id: &acp::SessionId, + fallback_model: &str, + response: &vtcode_core::llm::provider::LLMResponse, + ) { + let Some(client) = self.client() else { + return; + }; + let notification = match response_usage_notification(session_id, fallback_model, response) { + Ok(Some(notification)) => notification, + Ok(None) => return, + Err(error) => { + tracing::warn!(%error, %session_id, "Failed to serialize Lody ACP usage update"); + return; + } + }; + if let Err(error) = client.send_ext_notification(notification) { + tracing::warn!(%error, %session_id, "Failed to publish Lody ACP usage update"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::{Agent, Builder, Client, ConnectionTo, RunWithConnectionTo, on_receive_notification}; + use proptest::prelude::*; + use tokio::sync::{Notify, mpsc}; + + #[test] + fn maps_normalized_usage_to_the_lody_delta_contract() { + let usage = Usage { + prompt_tokens: 21, + completion_tokens: 8, + cached_prompt_tokens: Some(5), + cache_creation_tokens: Some(3), + ..Usage::default() + }; + let notification = usage_notification(&acp::SessionId::new(Arc::from("session-1")), "model-a", &usage) + .expect("usage notification"); + let value: serde_json::Value = serde_json::from_str(notification.params.get()).expect("usage JSON"); + + assert_eq!(notification.method.as_ref(), LODY_SESSION_USAGE_UPDATE_METHOD); + assert_eq!(value["sessionId"], "session-1"); + assert_eq!(value["usage"]["inputTokens"], 21); + assert_eq!(value["usage"]["outputTokens"], 8); + assert_eq!(value["usage"]["cacheReadInputTokens"], 5); + assert_eq!(value["usage"]["cacheCreationInputTokens"], 3); + assert_eq!(value["modelUsage"]["model-a"], value["usage"]); + } + + #[test] + fn response_without_usage_produces_no_notification() { + let response = vtcode_core::llm::provider::LLMResponse::new("model-a", "answer"); + + let notification = + response_usage_notification(&acp::SessionId::new(Arc::from("session-1")), "fallback-model", &response) + .expect("optional usage notification"); + + assert!(notification.is_none()); + } + + proptest! { + #[test] + fn preserves_all_normalized_u32_usage_counts( + input in any::(), + output in any::(), + cache_read in any::(), + cache_creation in proptest::option::of(any::()), + model in "[^\\p{C}]{1,48}", + ) { + let usage = Usage { + prompt_tokens: input, + completion_tokens: output, + cache_read_tokens: Some(cache_read), + cache_creation_tokens: cache_creation, + ..Usage::default() + }; + let notification = usage_notification( + &acp::SessionId::new(Arc::from("property-session")), + &model, + &usage, + ).expect("usage notification"); + let value: serde_json::Value = serde_json::from_str(notification.params.get()).expect("usage JSON"); + + prop_assert_eq!(value["usage"]["inputTokens"].as_u64(), Some(u64::from(input))); + prop_assert_eq!(value["usage"]["outputTokens"].as_u64(), Some(u64::from(output))); + prop_assert_eq!(value["usage"]["cacheReadInputTokens"].as_u64(), Some(u64::from(cache_read))); + match cache_creation { + Some(count) => prop_assert_eq!( + value["usage"]["cacheCreationInputTokens"].as_u64(), + Some(u64::from(count)), + ), + None => prop_assert!(value["usage"].get("cacheCreationInputTokens").is_none()), + } + prop_assert_eq!(&value["modelUsage"][model.as_str()], &value["usage"]); + } + } + + #[tokio::test] + async fn sends_usage_through_the_official_acp_extension_channel() { + let (agent_channel, client_channel) = agent_client_protocol::Channel::duplex(); + let (received_tx, mut received_rx) = mpsc::unbounded_channel(); + let client_ready = Arc::new(Notify::new()); + let agent_ready = Arc::clone(&client_ready); + let notification_received = Arc::new(Notify::new()); + let agent_ack = Arc::clone(¬ification_received); + + let agent_connection = Agent.builder().name("vtcode-usage-test").connect_with( + agent_channel, + move |cx: ConnectionTo| async move { + agent_ready.notified().await; + let client = ConnectionHandle::new(cx); + send_usage_update( + &client, + &acp::SessionId::new(Arc::from("session-wire")), + "wire-model", + &Usage { + prompt_tokens: 13, + completion_tokens: 5, + ..Usage::default() + }, + ) + .expect("send usage update"); + agent_ack.notified().await; + Ok(()) + }, + ); + let agent_task = tokio::spawn(agent_connection); + + let client_connection = Client + .builder() + .on_receive_notification( + async move |notification: acp::AgentNotification, _cx| { + drop(received_tx.send(notification)); + Ok(()) + }, + on_receive_notification!(), + ) + .connect_with(client_channel, async move |_cx: ConnectionTo| { + client_ready.notify_one(); + let notification = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv()) + .await + .expect("usage notification deadline") + .expect("usage notification"); + let acp::AgentNotification::ExtNotification(notification) = notification else { + panic!("usage must use an ACP extension notification"); + }; + assert_eq!( + notification.method.as_ref().trim_start_matches('_'), + LODY_SESSION_USAGE_UPDATE_METHOD.trim_start_matches('_') + ); + let value: serde_json::Value = + serde_json::from_str(notification.params.get()).expect("usage notification JSON"); + assert_eq!(value["sessionId"], "session-wire"); + assert_eq!(value["usage"]["inputTokens"], 13); + assert_eq!(value["usage"]["outputTokens"], 5); + notification_received.notify_one(); + Ok(()) + }); + + tokio::time::timeout(std::time::Duration::from_secs(3), client_connection) + .await + .expect("client connection deadline") + .expect("client connection"); + agent_task.await.expect("agent task").expect("agent connection"); + } +} diff --git a/crates/codegen/vtcode-acp/src/zed/agent/mod.rs b/crates/codegen/vtcode-acp/src/zed/agent/mod.rs index d5f1a6cfd..10b6fb269 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/mod.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/mod.rs @@ -31,6 +31,10 @@ use super::types::SessionHandle; mod compaction; pub(crate) mod handlers; +mod lody; +#[cfg(test)] +mod lody_tests; +mod lody_usage; mod prompt; mod session_state; mod task_lifecycle; @@ -102,6 +106,7 @@ async fn attach_acp_subagent_controller( config: &CoreAgentConfig, custom_providers: &[CustomProviderConfig], vt_cfg: Option<&VTCodeConfig>, + owner_session_id: Option<&str>, ) { let Some(mut controller_vt_cfg) = vt_cfg.filter(|config| config.subagents.enabled).cloned() else { return; @@ -137,7 +142,7 @@ async fn attach_acp_subagent_controller( let controller_config = SubagentControllerConfig { workspace_root: config.workspace.clone(), - parent_session_id: "vtcode-acp".to_string(), + parent_session_id: owner_session_id.unwrap_or("vtcode-acp").to_string(), parent_model: config.model.clone(), parent_provider: config.provider.clone(), parent_reasoning_effort: config.reasoning_effort, @@ -149,17 +154,22 @@ async fn attach_acp_subagent_controller( pty_manager: registry.pty_manager().clone(), managed_background_runtime: false, }; - match SubagentController::new(controller_config).await { - Ok(controller) => { - let controller = Arc::new(controller); - if controller_vt_cfg.subagents.background.auto_restore - && let Err(error) = controller.restore_background_subagents().await - { - warn!(%error, "Failed to restore ACP background subagents"); - } - registry.set_subagent_controller(controller); + let controller = match SubagentController::new_with_background_owner(controller_config, owner_session_id).await { + Ok(controller) => controller, + Err(error) => { + warn!(%error, "Failed to initialize ACP subagent controller"); + return; + } + }; + { + let controller = Arc::new(controller); + if owner_session_id.is_some() + && controller_vt_cfg.subagents.background.auto_restore + && let Err(error) = controller.restore_background_subagents().await + { + warn!(%error, "Failed to restore ACP background subagents"); } - Err(error) => warn!(%error, "Failed to initialize ACP subagent controller"), + registry.set_subagent_controller(controller); } } @@ -251,6 +261,7 @@ impl SessionWorkspaceRuntime { workspace_root: std::path::PathBuf, runtime_config: &WorkspaceRuntimeConfig, vt_cfg: Option<&VTCodeConfig>, + session_id: &str, ) -> anyhow::Result { let mut session_config = base_config.clone(); session_config.workspace = workspace_root.clone(); @@ -280,6 +291,7 @@ impl SessionWorkspaceRuntime { &session_config, &runtime_config.custom_providers, vt_cfg, + Some(session_id), )) .await; attach_acp_mcp_client(&local_tool_registry, vt_cfg, &workspace_root).await; @@ -357,7 +369,7 @@ impl ZedAgent { { warn!(%error, "Failed to apply tools configuration to ACP tool registry"); } - Box::pin(attach_acp_subagent_controller(&core_tool_registry, &config, custom_providers, vt_cfg)).await; + Box::pin(attach_acp_subagent_controller(&core_tool_registry, &config, custom_providers, vt_cfg, None)).await; attach_acp_mcp_client(&core_tool_registry, vt_cfg, workspace_root.as_path()).await; let local_definitions = core_tool_registry .model_tools( diff --git a/crates/codegen/vtcode-acp/src/zed/agent/session_state.rs b/crates/codegen/vtcode-acp/src/zed/agent/session_state.rs index 45f0a36a8..c4aaca62d 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/session_state.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/session_state.rs @@ -283,10 +283,10 @@ impl ZedAgent { fn register_durable_session( &self, + session_id: acp::SessionId, workspace_runtime: Arc, acp_meta: Option, ) -> acp::SessionId { - let session_id = acp::SessionId::new(Arc::from(format!("{SESSION_PREFIX}-{}", Uuid::new_v4()))); let mut metadata = build_thread_archive_metadata( workspace_runtime.workspace_root.as_path(), &self.config.model, @@ -619,6 +619,7 @@ impl ZedAgent { workspace.to_path_buf(), &self.workspace_runtime_config, self.vt_config.as_deref(), + session_id.0.as_ref(), ) .await .context("Failed to initialise archived ACP session workspace")?, @@ -754,17 +755,19 @@ impl ZedAgent { .synchronize(&workspace, desired_trust) .await .map_err(|error| acp::Error::internal_error().data(format!("Failed to trust ACP session cwd: {error}")))?; + let session_id = acp::SessionId::new(Arc::from(format!("{SESSION_PREFIX}-{}", Uuid::new_v4()))); let workspace_runtime = Arc::new( super::SessionWorkspaceRuntime::build( &self.config, workspace, &self.workspace_runtime_config, self.vt_config.as_deref(), + session_id.0.as_ref(), ) .await .map_err(|error| acp::Error::internal_error().data(error.to_string()))?, ); - let session_id = self.register_durable_session(workspace_runtime, req.meta); + let session_id = self.register_durable_session(session_id, workspace_runtime, req.meta); let session = self.session_handle(&session_id); if let Some(session) = &session { self.ensure_task_lifecycle_forwarder(session); @@ -988,7 +991,7 @@ mod tests { use vtcode_config::{SubagentDiscoveryInput, discover_subagents}; use vtcode_core::config::core::PromptCachingConfig; use vtcode_core::config::types::{AgentConfig as CoreAgentConfig, ModelSelectionSource, UiSurfacePreference}; - use vtcode_core::config::{AgentClientProtocolZedConfig, CommandsConfig, ToolsConfig}; + use vtcode_core::config::{AgentClientProtocolZedConfig, CommandsConfig, ToolsConfig, VTCodeConfig}; use vtcode_core::core::agent::snapshots::{ DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS, }; @@ -1001,7 +1004,7 @@ mod tests { impl HistorySettingsGuard { fn set(persistence: HistoryPersistence, max_bytes: Option) -> Self { - let mut config = vtcode_core::config::VTCodeConfig::default(); + let mut config = VTCodeConfig::default(); config.history.persistence = persistence; config.history.max_bytes = max_bytes; vtcode_core::utils::session_archive::apply_session_history_config_from_vtcode(&config); @@ -1011,9 +1014,7 @@ mod tests { impl Drop for HistorySettingsGuard { fn drop(&mut self) { - vtcode_core::utils::session_archive::apply_session_history_config_from_vtcode( - &vtcode_core::config::VTCodeConfig::default(), - ); + vtcode_core::utils::session_archive::apply_session_history_config_from_vtcode(&VTCodeConfig::default()); } } @@ -1022,6 +1023,14 @@ mod tests { } async fn build_agent_with_default_primary_agent(workspace: &Path, default_primary_agent: &str) -> ZedAgent { + build_agent_with_vt_config(workspace, default_primary_agent, None).await + } + + async fn build_agent_with_vt_config( + workspace: &Path, + default_primary_agent: &str, + vt_config: Option, + ) -> ZedAgent { let core_config = CoreAgentConfig { model: "gpt-5.4".to_string(), api_key: String::new(), @@ -1062,7 +1071,7 @@ mod tests { Some("Zed".to_string()), primary_agents, false, - None, + vt_config.as_ref(), None, )) .await @@ -1369,6 +1378,62 @@ mod tests { assert_eq!(serialized["acp_meta"], serde_json::json!({})); } + #[tokio::test] + async fn new_session_does_not_restore_a_background_record_owned_by_another_session() { + let temp = TempDir::new().unwrap(); + let state_dir = temp.path().join(".vtcode/state"); + fs::create_dir_all(&state_dir).unwrap(); + fs::write( + state_dir.join("background_subagents.json"), + serde_json::json!({ + "records": [{ + "id": "foreign-worker", + "agent_name": "worker", + "display_label": "worker", + "description": "worker", + "source": "builtin", + "color": null, + "owner_session_id": "prior-session", + "session_id": "child-session", + "exec_session_id": "exec-session", + "desired_enabled": true, + "status": "stopped", + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z", + "started_at": null, + "ended_at": null, + "pid": null, + "prompt": "work", + "summary": null, + "error": null, + "archive_path": null, + "transcript_path": null, + "max_turns": null, + "model_override": null, + "reasoning_override": null, + "restart_attempts": 0 + }] + }) + .to_string(), + ) + .unwrap(); + + let mut vt_config = VTCodeConfig::default(); + vt_config.subagents.enabled = true; + vt_config.subagents.background.enabled = true; + vt_config.subagents.background.auto_restore = true; + let agent = build_agent_with_vt_config(temp.path(), "duck", Some(vt_config)).await; + let response = agent.new_session(acp::NewSessionRequest::new(temp.path())).await.unwrap(); + let session = agent.session_handle(&response.session_id).unwrap(); + let runtime = session.workspace_runtime().expect("session workspace runtime"); + let controller = runtime + .local_tool_registry + .subagent_controller() + .expect("per-session subagent controller"); + + assert!(controller.background_status_entries().await.is_empty()); + } + #[tokio::test] async fn new_session_canonicalizes_and_isolates_requested_workspaces() { let launch_workspace = TempDir::new().unwrap(); diff --git a/crates/codegen/vtcode-acp/src/zed/agent/task_lifecycle.rs b/crates/codegen/vtcode-acp/src/zed/agent/task_lifecycle.rs index 997c73092..a4e328932 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/task_lifecycle.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/task_lifecycle.rs @@ -1,17 +1,14 @@ use super::super::types::SessionHandle; use super::ZedAgent; +use super::lody::{lody_task_id, lody_task_session_update}; use crate::acp; use crate::zed::connection::ConnectionHandle; -use serde_json::{Value, json}; +use hashbrown::HashSet; use std::sync::Arc; use tokio::sync::broadcast; use tokio::task::JoinHandle; use tracing::warn; -use vtcode_core::subagents::{ - BackgroundSubprocessEntry, BackgroundSubprocessStatus, SubagentProgressEvent, SubagentStatus, SubagentStatusEntry, -}; - -pub(super) const TASK_LIFECYCLE_METHOD: &str = "_vtcode/taskLifecycle"; +use vtcode_core::subagents::SubagentProgressEvent; impl ZedAgent { pub(super) fn ensure_task_lifecycle_forwarder(&self, session: &SessionHandle) { @@ -56,14 +53,19 @@ fn spawn_task_lifecycle_forwarder( client: Arc, ) -> JoinHandle<()> { tokio::spawn(async move { + let mut emitted_task_ids = HashSet::new(); loop { match receiver.recv().await { Ok(event) => { if event.parent_session_id() != session_id.0.as_ref() { continue; } - if let Err(error) = send_task_lifecycle(&client, &session_id, event) { + let task_id = lody_task_id(&event).to_string(); + let previously_emitted = emitted_task_ids.contains(&task_id); + if let Err(error) = send_task_lifecycle(&client, &session_id, event, previously_emitted) { warn!(%error, %session_id, "Failed to forward ACP task lifecycle notification"); + } else { + let _ = emitted_task_ids.insert(task_id); } } Err(broadcast::error::RecvError::Lagged(skipped)) => { @@ -79,105 +81,20 @@ fn send_task_lifecycle( client: &ConnectionHandle, session_id: &acp::SessionId, event: SubagentProgressEvent, + previously_emitted: bool, ) -> anyhow::Result<()> { - let message = lifecycle_message(event)?; - let payload = json!({ - "sessionId": session_id.to_string(), - "acpSessionId": session_id.to_string(), - "message": message, - }); - let params: Arc = serde_json::value::to_raw_value(&payload)?.into(); + let update = lody_task_session_update(event, previously_emitted)?; client - .send_ext_notification(acp::ExtNotification::new(TASK_LIFECYCLE_METHOD, params)) + .send_session_notification(acp::SessionNotification::new(session_id.clone(), update)) .map_err(|error| anyhow::anyhow!(error.to_string())) } -fn lifecycle_message(event: SubagentProgressEvent) -> anyhow::Result { - match event { - SubagentProgressEvent::Subagent { task, .. } => subagent_lifecycle_message(task), - SubagentProgressEvent::BackgroundProcess { task, .. } => background_lifecycle_message(task), - } -} - -fn subagent_lifecycle_message(entry: SubagentStatusEntry) -> anyhow::Result { - let message_type = lifecycle_message_type(entry.status.is_terminal(), entry.status == SubagentStatus::Queued); - let status = terminal_subagent_status(entry.status); - lifecycle_message_value(message_type, "subagent", entry.id.clone(), status, entry.agent_name.clone(), entry) -} - -fn background_lifecycle_message(entry: BackgroundSubprocessEntry) -> anyhow::Result { - let terminal = matches!(entry.status, BackgroundSubprocessStatus::Stopped | BackgroundSubprocessStatus::Error); - let message_type = lifecycle_message_type(terminal, entry.status == BackgroundSubprocessStatus::Starting); - let status = match entry.status { - BackgroundSubprocessStatus::Starting => "pending", - BackgroundSubprocessStatus::Running => "in_progress", - BackgroundSubprocessStatus::Stopped => "completed", - BackgroundSubprocessStatus::Error => "failed", - }; - lifecycle_message_value( - message_type, - "background_process", - entry.id.clone(), - status, - entry.agent_name.clone(), - entry, - ) -} - -fn lifecycle_message_type(terminal: bool, starting: bool) -> &'static str { - if terminal { - "task_updated" - } else if starting { - "task_started" - } else { - "task_progress" - } -} - -fn terminal_subagent_status(status: SubagentStatus) -> &'static str { - match status { - SubagentStatus::Queued => "pending", - SubagentStatus::Running | SubagentStatus::Waiting => "in_progress", - SubagentStatus::Completed => "completed", - SubagentStatus::Failed => "failed", - SubagentStatus::Closed => "killed", - } -} - -fn lifecycle_message_value( - message_type: &str, - task_type: &str, - task_id: String, - status: &str, - agent_name: String, - details: impl serde::Serialize, -) -> anyhow::Result { - let details = serde_json::to_value(details)?; - if message_type == "task_updated" { - Ok(json!({ - "type": message_type, - "task_id": task_id, - "task_type": task_type, - "subagent_type": agent_name, - "patch": { "status": status, "details": details }, - })) - } else { - Ok(json!({ - "type": message_type, - "task_id": task_id, - "task_type": task_type, - "subagent_type": agent_name, - "status": status, - "details": details, - })) - } -} - #[cfg(test)] mod tests { use super::*; use agent_client_protocol::{Agent, Builder, Client, ConnectionTo, RunWithConnectionTo, on_receive_notification}; - use serde_json::json; + use proptest::prelude::*; + use serde_json::{Value, json}; use tokio::sync::mpsc; fn subagent_event(status: &str) -> SubagentProgressEvent { @@ -228,18 +145,96 @@ mod tests { } } + fn task_meta(update: &acp::SessionUpdate) -> &Value { + let meta = match update { + acp::SessionUpdate::ToolCall(call) => call.meta.as_ref(), + acp::SessionUpdate::ToolCallUpdate(update) => update.meta.as_ref(), + other => panic!("expected task-carrying tool update, got {other:?}"), + } + .expect("task update metadata"); + &meta["lody"]["task"] + } + + fn with_task_id(mut event: SubagentProgressEvent, task_id: String) -> SubagentProgressEvent { + match &mut event { + SubagentProgressEvent::Subagent { task, .. } => task.id = task_id, + SubagentProgressEvent::BackgroundProcess { task, .. } => task.id = task_id, + } + event + } + #[test] - fn maps_worker_and_background_states_to_claude_style_lifecycle_messages() { - assert_eq!(lifecycle_message(subagent_event("queued")).expect("queued message")["type"], "task_started"); - assert_eq!(lifecycle_message(subagent_event("running")).expect("running message")["type"], "task_progress"); - let failed = lifecycle_message(subagent_event("failed")).expect("failed message"); - assert_eq!(failed["type"], "task_updated"); - assert_eq!(failed["patch"]["status"], "failed"); + fn maps_worker_and_background_states_to_lody_task_metadata() { + let cases = [ + (subagent_event("queued"), "subagent", "pending"), + (subagent_event("running"), "subagent", "in_progress"), + (subagent_event("waiting"), "subagent", "in_progress"), + (subagent_event("completed"), "subagent", "completed"), + (subagent_event("failed"), "subagent", "failed"), + (subagent_event("closed"), "subagent", "failed"), + (background_event("starting"), "background", "pending"), + (background_event("running"), "background", "in_progress"), + (background_event("stopped"), "background", "completed"), + (background_event("error"), "background", "failed"), + ]; + + for (event, kind, status) in cases { + let update = lody_task_session_update(event, false).expect("valid Lody task update"); + let acp::SessionUpdate::ToolCall(call) = &update else { + panic!("first task snapshot must be a tool call"); + }; + let task_id = task_meta(&update)["taskId"].as_str().expect("task ID string"); + assert_eq!(call.tool_call_id.0.as_ref(), format!("task:{task_id}")); + assert_eq!(call.kind, acp::ToolKind::Think); + assert_eq!(task_meta(&update)["version"], 1); + assert_eq!(task_meta(&update)["kind"], kind); + assert_eq!(task_meta(&update)["status"], status); + assert!(task_meta(&update).get("sessionId").is_none()); + assert!(task_meta(&update).get("pid").is_none()); + assert!(task_meta(&update).get("transcriptPath").is_none()); + } + } + + proptest! { + #[test] + fn task_updates_keep_stable_ids_across_statuses( + task_id in "[A-Za-z0-9_-]{1,64}", + status_index in 0_usize..10, + ) { + let statuses = [ + "queued", "running", "waiting", "completed", "failed", "closed", + "starting", "running", "stopped", "error", + ]; + let event = if status_index < 6 { + subagent_event(statuses[status_index]) + } else { + background_event(statuses[status_index]) + }; + let event = with_task_id(event, task_id.clone()); + + let initial = lody_task_session_update(event.clone(), false).expect("valid initial task update"); + let subsequent = lody_task_session_update(event, true).expect("valid subsequent task update"); - let background = lifecycle_message(background_event("running")).expect("background message"); - assert_eq!(background["type"], "task_progress"); - assert_eq!(background["task_type"], "background_process"); - assert_eq!(background["details"]["exec_session_id"], "exec-background-1"); + let acp::SessionUpdate::ToolCall(initial) = initial else { + prop_assert!(false, "initial snapshot must create a tool call"); + return Ok(()); + }; + let acp::SessionUpdate::ToolCallUpdate(subsequent) = subsequent else { + prop_assert!(false, "subsequent snapshot must update the tool call"); + return Ok(()); + }; + let expected_id = format!("task:{task_id}"); + prop_assert_eq!(initial.tool_call_id.0.as_ref(), expected_id.as_str()); + prop_assert_eq!(subsequent.tool_call_id.0.as_ref(), expected_id.as_str()); + let initial_task_id = initial.meta.as_ref().expect("initial meta")["lody"]["task"]["taskId"] + .as_str() + .expect("initial task ID"); + let subsequent_task_id = subsequent.meta.as_ref().expect("subsequent meta")["lody"]["task"]["taskId"] + .as_str() + .expect("subsequent task ID"); + prop_assert_eq!(initial_task_id, task_id.as_str()); + prop_assert_eq!(subsequent_task_id, task_id.as_str()); + } } #[tokio::test] @@ -259,6 +254,7 @@ mod tests { ); drop(progress_tx.send(subagent_event_for("other-session", "running"))); drop(progress_tx.send(subagent_event("running"))); + drop(progress_tx.send(subagent_event("completed"))); drop(progress_tx); forwarder.await.expect("lifecycle forwarder"); Ok(()) @@ -270,27 +266,39 @@ mod tests { .builder() .on_receive_notification( async move |notification: acp::AgentNotification, _cx| { - if let acp::AgentNotification::ExtNotification(notification) = notification { - drop(received_tx.send(notification)); - } + drop(received_tx.send(notification)); Ok(()) }, on_receive_notification!(), ) .connect_with(client_channel, async move |_cx: ConnectionTo| { - let notification = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv()) + let first = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv()) + .await + .expect("initial task update deadline") + .expect("initial task update"); + let acp::AgentNotification::SessionNotification(first) = first else { + panic!("task lifecycle must use standard session notifications"); + }; + assert_eq!(first.session_id.0.as_ref(), "parent-session"); + let acp::SessionUpdate::ToolCall(call) = first.update else { + panic!("first task snapshot must create a tool call"); + }; + assert_eq!(call.tool_call_id.0.as_ref(), "task:child-1"); + assert_eq!(call.meta.expect("initial task metadata")["lody"]["task"]["status"], "in_progress"); + + let second = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv()) .await - .expect("extension notification deadline") - .expect("extension notification"); - // The official SDK strips the required leading underscore - // while decoding extension methods; the wire method remains - // `_vtcode/taskLifecycle`. - assert_eq!(notification.method.as_ref(), TASK_LIFECYCLE_METHOD.trim_start_matches('_')); - let payload: Value = serde_json::from_str(notification.params.get()).expect("extension payload"); - assert_eq!(payload["sessionId"], "parent-session"); - assert_eq!(payload["acpSessionId"], "parent-session"); - assert_eq!(payload["message"]["type"], "task_progress"); - assert_eq!(payload["message"]["task_id"], "child-1"); + .expect("task progress update deadline") + .expect("task progress update"); + let acp::AgentNotification::SessionNotification(second) = second else { + panic!("task progress must remain a standard session notification"); + }; + let acp::SessionUpdate::ToolCallUpdate(update) = second.update else { + panic!("later task snapshots must update the existing tool call"); + }; + assert_eq!(update.tool_call_id.0.as_ref(), "task:child-1"); + assert_eq!(update.meta.expect("progress task metadata")["lody"]["task"]["status"], "completed"); + assert!(received_rx.try_recv().is_err(), "foreign-session events must remain filtered"); Ok(()) }); diff --git a/crates/codegen/vtcode-config/src/core/custom_provider.rs b/crates/codegen/vtcode-config/src/core/custom_provider.rs index 4dda4744b..b5a65cb5e 100644 --- a/crates/codegen/vtcode-config/src/core/custom_provider.rs +++ b/crates/codegen/vtcode-config/src/core/custom_provider.rs @@ -88,6 +88,10 @@ pub struct CustomProviderProfileConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_responses_compaction: Option, + /// Whether streaming requests should ask the provider to return usage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_stream_usage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_context_edits: Option, } @@ -119,6 +123,7 @@ pub struct ResolvedCustomProviderProfile { pub supports_parallel_tool_calls: Option, pub supports_context_caching: Option, pub supports_responses_compaction: Option, + pub supports_stream_usage: Option, pub supports_context_edits: Option, } @@ -148,6 +153,7 @@ impl ResolvedCustomProviderProfile { supports_responses_compaction: profile .supports_responses_compaction .or(defaults.supports_responses_compaction), + supports_stream_usage: profile.supports_stream_usage.or(defaults.supports_stream_usage), supports_context_edits: profile.supports_context_edits.or(defaults.supports_context_edits), } } @@ -397,6 +403,10 @@ pub struct CustomProviderConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_responses_compaction: Option, + /// Optional support for streamed usage chunks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_stream_usage: Option, + /// Optional support for context edits. #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_context_edits: Option, @@ -498,6 +508,7 @@ impl CustomProviderConfig { supports_parallel_tool_calls: self.supports_parallel_tool_calls, supports_context_caching: self.supports_context_caching, supports_responses_compaction: self.supports_responses_compaction, + supports_stream_usage: self.supports_stream_usage, supports_context_edits: self.supports_context_edits, } } @@ -625,6 +636,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -654,6 +666,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -683,6 +696,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "MYCORP_API_KEY".to_string(), auth: Some(CustomProviderCommandAuthConfig { @@ -718,6 +732,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: Some(CustomProviderCommandAuthConfig { @@ -753,6 +768,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "MYCORP_API_KEY".to_string(), auth: None, @@ -782,6 +798,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -811,6 +828,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, }, ); @@ -829,6 +847,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -858,6 +877,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "ATLASCLOUD_API_KEY".to_string(), auth: None, @@ -917,6 +937,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, }, ); @@ -935,6 +956,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -958,6 +980,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, } ); @@ -980,6 +1003,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: Some(false), supports_context_edits: None, }, ); @@ -998,6 +1022,7 @@ mod tests { supports_parallel_tool_calls: Some(true), supports_context_caching: Some(false), supports_responses_compaction: None, + supports_stream_usage: Some(true), supports_context_edits: None, api_key_env: String::new(), auth: None, @@ -1017,6 +1042,7 @@ mod tests { assert_eq!(resolved.supports_parallel_tool_calls, Some(true)); assert_eq!(resolved.supports_context_caching, Some(false)); assert_eq!(resolved.supports_responses_compaction, None); + assert_eq!(resolved.supports_stream_usage, Some(false)); assert_eq!(resolved.supports_context_edits, None); } diff --git a/crates/codegen/vtcode-config/src/loader/tests.rs b/crates/codegen/vtcode-config/src/loader/tests.rs index 9776160b9..37af7e4f1 100644 --- a/crates/codegen/vtcode-config/src/loader/tests.rs +++ b/crates/codegen/vtcode-config/src/loader/tests.rs @@ -299,6 +299,7 @@ fn custom_providers_fields_round_trip_through_toml() { supports_parallel_tool_calls: Some(false), supports_context_caching: Some(true), supports_responses_compaction: Some(false), + supports_stream_usage: Some(true), supports_context_edits: Some(true), api_key_env: "MYCORP_API_KEY".to_string(), auth: None, @@ -317,6 +318,7 @@ fn custom_providers_fields_round_trip_through_toml() { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: Some(false), supports_context_edits: None, }, )]), @@ -343,6 +345,7 @@ fn custom_providers_fields_round_trip_through_toml() { assert_eq!(provider.supports_parallel_tool_calls, Some(false)); assert_eq!(provider.supports_context_caching, Some(true)); assert_eq!(provider.supports_responses_compaction, Some(false)); + assert_eq!(provider.supports_stream_usage, Some(true)); assert_eq!(provider.supports_context_edits, Some(true)); assert_eq!(provider.api_key_env, "MYCORP_API_KEY"); assert_eq!(provider.model, "gpt-5-mini"); @@ -360,6 +363,7 @@ fn custom_providers_fields_round_trip_through_toml() { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: Some(false), supports_context_edits: None, } ); @@ -376,10 +380,12 @@ base_url = "https://llm.corp.example/v1" api_format = "openai-chat" context_window = 256000 supports_tools = true +supports_stream_usage = true [custom_providers.profiles."gpt-5-mini"] supports_tools = false supports_parallel_tool_calls = true +supports_stream_usage = false "#, ) .expect("nested custom provider config should parse"); @@ -401,6 +407,7 @@ supports_parallel_tool_calls = true supports_parallel_tool_calls: Some(true), supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: Some(false), supports_context_edits: None, } ); diff --git a/crates/codegen/vtcode-core/src/llm/factory.rs b/crates/codegen/vtcode-core/src/llm/factory.rs index b7084e955..220337f28 100644 --- a/crates/codegen/vtcode-core/src/llm/factory.rs +++ b/crates/codegen/vtcode-core/src/llm/factory.rs @@ -510,6 +510,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "MYCORP_API_KEY".to_string(), auth: None, @@ -560,6 +561,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "MYCORP_API_KEY".to_string(), auth: None, @@ -608,6 +610,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, } } @@ -637,6 +640,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "MIXED_API_KEY".to_string(), auth: None, @@ -761,6 +765,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: None, api_key_env: "ATLASCLOUD_API_KEY".to_string(), auth: None, diff --git a/crates/codegen/vtcode-core/src/subagents/background.rs b/crates/codegen/vtcode-core/src/subagents/background.rs index fe9760a92..4f429fddd 100644 --- a/crates/codegen/vtcode-core/src/subagents/background.rs +++ b/crates/codegen/vtcode-core/src/subagents/background.rs @@ -1,8 +1,8 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use std::path::{Path, PathBuf}; use tokio::fs; -use super::constants::SUBAGENT_PREVIEW_LINES; +use super::constants::{DEFAULT_SUBAGENT_OUTPUT_TAIL_LINES, MAX_SUBAGENT_OUTPUT_TAIL_LINES, SUBAGENT_PREVIEW_LINES}; use super::types::{PersistedBackgroundRecord, PersistedBackgroundState}; use crate::utils::file_utils::write_file_atomic_with_context; use crate::utils::session_archive::{SessionListing, SessionSnapshot}; @@ -176,9 +176,23 @@ pub fn extract_tail_lines(content: &str, max_lines: usize) -> String { lines[start..].join("\n") } +/// Resolves a requested output-tail size, rejecting values beyond the ACP-safe +/// hard maximum. `None` selects the documented 200-line default. +pub fn normalize_output_tail_lines(max_lines: Option) -> Result { + let max_lines = max_lines.unwrap_or(DEFAULT_SUBAGENT_OUTPUT_TAIL_LINES); + if max_lines > MAX_SUBAGENT_OUTPUT_TAIL_LINES { + bail!("background output tail exceeds the hard maximum of {MAX_SUBAGENT_OUTPUT_TAIL_LINES} lines"); + } + Ok(max_lines) +} + pub async fn load_archive_preview(path: &Path) -> Result { + load_archive_output_tail(path, SUBAGENT_PREVIEW_LINES).await +} + +pub(crate) async fn load_archive_output_tail(path: &Path, max_lines: usize) -> Result { let listing = load_session_listing(path).await?; - Ok(extract_tail_lines(&listing.snapshot.transcript.join("\n"), SUBAGENT_PREVIEW_LINES)) + Ok(extract_tail_lines(&listing.snapshot.transcript.join("\n"), max_lines)) } async fn load_session_listing(path: &Path) -> Result { diff --git a/crates/codegen/vtcode-core/src/subagents/constants.rs b/crates/codegen/vtcode-core/src/subagents/constants.rs index 9c91b5f52..ddd217256 100644 --- a/crates/codegen/vtcode-core/src/subagents/constants.rs +++ b/crates/codegen/vtcode-core/src/subagents/constants.rs @@ -7,6 +7,10 @@ pub(crate) const SUBAGENT_MEMORY_HIGHLIGHT_LIMIT: usize = 4; pub(crate) const SUBAGENT_MIN_MAX_TURNS: usize = 2; pub(crate) const SUBAGENT_MIN_BACKGROUND_MAX_TURNS: usize = 4; pub(crate) const SUBAGENT_PREVIEW_LINES: usize = 24; +/// Default number of lines returned by caller-selected background output tails. +pub const DEFAULT_SUBAGENT_OUTPUT_TAIL_LINES: usize = 200; +/// Maximum number of lines returned by caller-selected background output tails. +pub const MAX_SUBAGENT_OUTPUT_TAIL_LINES: usize = 10_000; pub(crate) const VAGUE_SUBAGENT_PROMPTS: &[&str] = &[ "analyze", diff --git a/crates/codegen/vtcode-core/src/subagents/controller_background_ops.rs b/crates/codegen/vtcode-core/src/subagents/controller_background_ops.rs index 646d2bbae..50c62f668 100644 --- a/crates/codegen/vtcode-core/src/subagents/controller_background_ops.rs +++ b/crates/codegen/vtcode-core/src/subagents/controller_background_ops.rs @@ -84,6 +84,46 @@ impl SubagentController { Ok(BackgroundSubprocessSnapshot { entry, preview }) } + /// Returns a caller-selected tail of background output. + /// + /// `None` requests the 200-line default. Values above + /// [`MAX_SUBAGENT_OUTPUT_TAIL_LINES`] are rejected to keep ACP responses + /// bounded; the existing [`Self::background_snapshot`] 24-line preview is + /// unchanged. + pub async fn background_output_tail(&self, target: &str, max_lines: Option) -> Result { + let max_lines = normalize_output_tail_lines(max_lines)?; + let _ = self.refresh_background_processes().await?; + + let entry = { + let state = self.state.read().await; + state + .background_children + .get(target) + .ok_or_else(|| anyhow!("Unknown background subprocess {target}"))? + .build_status_entry() + }; + + if entry.exec_session_id.is_empty() { + return Ok(String::new()); + } + + match self + .config + .exec_sessions + .read_session_output(&entry.exec_session_id, false) + .await + { + Ok(Some(output)) => Ok(extract_tail_lines(&output, max_lines)), + Ok(None) | Err(_) => { + if let Some(path) = entry.transcript_path.as_ref().or(entry.archive_path.as_ref()) { + load_archive_output_tail(path, max_lines).await + } else { + Ok(String::new()) + } + } + } + } + /// Returns whether background subagents are enabled in the configuration. #[must_use] pub fn background_subagents_enabled(&self) -> bool { diff --git a/crates/codegen/vtcode-core/src/subagents/controller_spawn_run.rs b/crates/codegen/vtcode-core/src/subagents/controller_spawn_run.rs index 3490679b3..0b577b9b0 100644 --- a/crates/codegen/vtcode-core/src/subagents/controller_spawn_run.rs +++ b/crates/codegen/vtcode-core/src/subagents/controller_spawn_run.rs @@ -7,9 +7,9 @@ use chrono::Utc; use futures::future::select_all; use std::collections::VecDeque; use std::path::PathBuf; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use tokio::sync::{Notify, RwLock}; +use std::sync::{Arc, OnceLock}; +use tokio::sync::{Mutex, Notify, RwLock}; use crate::config::VTCodeConfig; use crate::config::types::ReasoningEffortLevel; @@ -31,6 +31,14 @@ use self::discovery::discover_controller_subagents; use self::model::*; use vtcode_config::subagents::SUBAGENT_HARD_CONCURRENCY_LIMIT; +use super::background::{load_background_state, persist_background_state}; + +static BACKGROUND_STATE_PERSISTENCE_LOCK: OnceLock> = OnceLock::new(); + +fn background_state_persistence_lock() -> &'static Mutex<()> { + BACKGROUND_STATE_PERSISTENCE_LOCK.get_or_init(|| Mutex::new(())) +} + #[allow( unused_imports, reason = "Intentional compatibility, platform, or test-only suppression." @@ -438,6 +446,7 @@ impl SubagentController { state.background_children.get(&record_id).map(|record| { ( record.created_at, + record.owner_session_id.clone(), record.prompt.clone(), record.max_turns, record.model_override.clone(), @@ -445,6 +454,7 @@ impl SubagentController { ) }) }; + let is_new_record = previous_record.is_none(); let parent_session_id = self.parent_session_id.read().await.clone(); let session_id = format!( "{}-{}-{}", @@ -453,8 +463,15 @@ impl SubagentController { Utc::now().format("%Y%m%dT%H%M%S%3fZ") ); let exec_session_id = format!("exec-{session_id}"); - let (created_at, previous_prompt, previous_max_turns, previous_model_override, previous_reasoning_override) = - previous_record.unwrap_or((Utc::now(), String::new(), None, None, None)); + let ( + created_at, + previous_owner_session_id, + previous_prompt, + previous_max_turns, + previous_model_override, + previous_reasoning_override, + ) = previous_record.unwrap_or((Utc::now(), None, String::new(), None, None, None)); + let owner_session_id = previous_owner_session_id.or_else(|| is_new_record.then(|| parent_session_id.clone())); let prompt = overrides .as_ref() .and_then(|overrides| overrides.prompt.clone()) @@ -493,6 +510,7 @@ impl SubagentController { record_id.clone(), BackgroundRecord { id: record_id.clone(), + owner_session_id, agent_name: spec.name.clone(), display_label: subagent_display_label(&spec), description: spec.description.clone(), @@ -644,7 +662,15 @@ impl SubagentController { .map(BackgroundRecord::into_persisted) .collect() }; - persist_background_state(&self.config.workspace_root, records).await + let _guard = background_state_persistence_lock().lock().await; + if let Some(owner_session_id) = self.background_owner_session_id.as_deref() { + let mut persisted_records = load_background_state(&self.config.workspace_root).await?.records; + persisted_records.retain(|record| record.owner_session_id.as_deref() != Some(owner_session_id)); + persisted_records.extend(records); + persist_background_state(&self.config.workspace_root, persisted_records).await + } else { + persist_background_state(&self.config.workspace_root, records).await + } } pub(super) async fn find_spec(&self, candidate: &str) -> Option { diff --git a/crates/codegen/vtcode-core/src/subagents/mod.rs b/crates/codegen/vtcode-core/src/subagents/mod.rs index d245c0c99..68038a451 100644 --- a/crates/codegen/vtcode-core/src/subagents/mod.rs +++ b/crates/codegen/vtcode-core/src/subagents/mod.rs @@ -16,12 +16,13 @@ mod types; pub use background::{ background_record_id, build_background_subagent_command, extract_tail_lines, load_archive_preview, - subagent_display_label, + normalize_output_tail_lines, subagent_display_label, }; pub use config::{ ResolvedAgentRuntimeView, build_child_config, compose_subagent_instructions, filter_child_tools, normalize_background_child_max_turns, normalize_child_max_turns, prepare_child_runtime_config, }; +pub use constants::{DEFAULT_SUBAGENT_OUTPUT_TAIL_LINES, MAX_SUBAGENT_OUTPUT_TAIL_LINES}; pub use model::{ agent_type_for_spec, load_memory_appendix, load_memory_appendix_async, load_primary_memory_appendix, load_primary_memory_appendix_async, @@ -142,6 +143,7 @@ pub struct SubagentControllerConfig { #[derive(Clone)] pub struct SubagentController { config: Arc, + background_owner_session_id: Option, parent_session_id: Arc>, lifecycle_hooks: Option, state: Arc>, @@ -152,6 +154,19 @@ pub struct SubagentController { impl SubagentController { /// Creates a new controller, discovering subagent specs and loading persisted background state. pub async fn new(config: SubagentControllerConfig) -> Result { + Box::pin(Self::new_with_background_owner(config, None)).await + } + + /// Creates a controller scoped to one persisted background owner. + /// + /// Interactive ACP sessions use this constructor so a per-session + /// controller cannot load or restore another session's background records. + /// Callers such as the CLI use [`Self::new`] to retain restore-all + /// behaviour. + pub async fn new_with_background_owner( + config: SubagentControllerConfig, + owner_session_id: Option<&str>, + ) -> Result { let (progress_tx, _progress_rx) = broadcast::channel(128); let discovered = discover_controller_subagents(&config.workspace_root).await?; let lifecycle_hooks = LifecycleHookEngine::new_with_session( @@ -164,9 +179,11 @@ impl SubagentController { .await? .records .into_iter() + .filter(|record| owner_session_id.is_none_or(|owner| record.owner_session_id.as_deref() == Some(owner))) .map(|record| (record.id.clone(), BackgroundRecord::from_persisted(record))) .collect(); Ok(Self { + background_owner_session_id: owner_session_id.map(str::to_owned), parent_session_id: Arc::new(RwLock::new(config.parent_session_id.clone())), lifecycle_hooks, config: Arc::new(config), diff --git a/crates/codegen/vtcode-core/src/subagents/tests.rs b/crates/codegen/vtcode-core/src/subagents/tests.rs index 531de5d3c..e9ca9dbfb 100644 --- a/crates/codegen/vtcode-core/src/subagents/tests.rs +++ b/crates/codegen/vtcode-core/src/subagents/tests.rs @@ -6,6 +6,7 @@ use crate::llm::provider::ToolDefinition; use crate::tools::exec_session::ExecSessionManager; use crate::tools::registry::PtySessionManager; use anyhow::{Result, anyhow}; +use proptest::prelude::*; use std::collections::BTreeMap; use std::collections::VecDeque; use std::path::PathBuf; @@ -44,6 +45,37 @@ fn test_controller_config(workspace_root: PathBuf, vt_cfg: VTCodeConfig) -> Suba } } +fn persisted_background_record_for_owner(id: &str, owner_session_id: Option<&str>) -> PersistedBackgroundRecord { + serde_json::from_value(serde_json::json!({ + "id": id, + "agent_name": "worker", + "display_label": "worker", + "description": "worker", + "source": "builtin", + "color": null, + "owner_session_id": owner_session_id, + "session_id": format!("session-{id}"), + "exec_session_id": format!("exec-{id}"), + "desired_enabled": false, + "status": "stopped", + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z", + "started_at": null, + "ended_at": "2026-08-28T00:00:00Z", + "pid": null, + "prompt": "work", + "summary": null, + "error": null, + "archive_path": null, + "transcript_path": null, + "max_turns": null, + "model_override": null, + "reasoning_override": null, + "restart_attempts": 0 + })) + .expect("persisted background record") +} + fn test_child_record( id: &str, parent_thread_id: &str, @@ -101,6 +133,219 @@ Run the managed background demo. "#, ) .expect("write background agent"); + + let scripts_dir = workspace_root.join("scripts"); + std::fs::create_dir_all(&scripts_dir).expect("scripts dir"); + let script_path = scripts_dir.join("demo-background-subagent.sh"); + std::fs::write(&script_path, "#!/bin/sh\nexit 0\n").expect("write background script"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&script_path) + .expect("background script metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script_path, permissions).expect("background script permissions"); + } +} + +#[test] +fn persisted_background_owner_round_trips_to_public_status() { + let record: PersistedBackgroundRecord = serde_json::from_value(serde_json::json!({ + "id": "background-worker", + "agent_name": "worker", + "display_label": "worker", + "description": "worker", + "source": "builtin", + "color": null, + "owner_session_id": "parent-session", + "session_id": "child-session", + "exec_session_id": "exec-session", + "desired_enabled": true, + "status": "running", + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z", + "started_at": "2026-08-28T00:00:00Z", + "ended_at": null, + "pid": 42, + "prompt": "work", + "summary": null, + "error": null, + "archive_path": null, + "transcript_path": null, + "max_turns": 4, + "model_override": null, + "reasoning_override": null, + "restart_attempts": 0 + })) + .expect("deserialize persisted record"); + + let json = serde_json::to_string(&record).expect("serialize persisted record"); + let decoded: PersistedBackgroundRecord = serde_json::from_str(&json).expect("deserialize persisted record"); + let runtime = BackgroundRecord::from_persisted(decoded); + + assert_eq!(runtime.owner_session_id.as_deref(), Some("parent-session")); + assert_eq!(runtime.build_status_entry().owner_session_id.as_deref(), Some("parent-session")); +} + +#[test] +fn legacy_background_record_without_owner_is_loadable_and_ownerless() { + let mut value = serde_json::json!({ + "id": "background-worker", + "agent_name": "worker", + "display_label": "worker", + "description": "worker", + "source": "builtin", + "color": null, + "session_id": "child-session", + "exec_session_id": "exec-session", + "desired_enabled": true, + "status": "running", + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z", + "started_at": null, + "ended_at": null, + "pid": null, + "prompt": "work", + "summary": null, + "error": null, + "archive_path": null, + "transcript_path": null, + "max_turns": null, + "model_override": null, + "reasoning_override": null, + "restart_attempts": 0 + }); + assert!(value.as_object_mut().expect("object").remove("owner_session_id").is_none()); + + let decoded: PersistedBackgroundRecord = serde_json::from_value(value).expect("legacy record remains loadable"); + let runtime = BackgroundRecord::from_persisted(decoded); + assert!(runtime.owner_session_id.is_none()); + assert!(runtime.build_status_entry().owner_session_id.is_none()); +} + +#[tokio::test] +async fn owner_scoped_controller_loads_only_exact_owner_and_cli_remains_unscoped() { + let temp = TempDir::new().expect("tempdir"); + let state_dir = temp.path().join(".vtcode/state"); + std::fs::create_dir_all(&state_dir).expect("state dir"); + let records = vec![ + persisted_background_record_for_owner("owned", Some("session-a")), + persisted_background_record_for_owner("foreign", Some("session-b")), + persisted_background_record_for_owner("legacy", None), + ]; + std::fs::write( + state_dir.join("background_subagents.json"), + serde_json::to_string(&PersistedBackgroundState { records }).expect("serialize state"), + ) + .expect("write state"); + + let scoped = SubagentController::new_with_background_owner( + test_controller_config(temp.path().to_path_buf(), VTCodeConfig::default()), + Some("session-a"), + ) + .await + .expect("scoped controller"); + let scoped_entries = scoped.background_status_entries().await; + assert_eq!(scoped_entries.iter().map(|entry| entry.id.as_str()).collect::>(), ["owned"]); + + { + let mut state = scoped.state.write().await; + state.background_children.get_mut("owned").expect("owned record").summary = + Some("updated by session-a".to_string()); + } + scoped.save_background_state().await.expect("save scoped state"); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(state_dir.join("background_subagents.json")).unwrap()) + .expect("persisted state"); + let records = persisted["records"].as_array().expect("records array"); + assert_eq!(records.len(), 3); + let by_id = records + .iter() + .map(|record| (record["id"].as_str().expect("record id"), record)) + .collect::>(); + assert_eq!(by_id["owned"]["summary"], "updated by session-a"); + assert_eq!(by_id["foreign"]["owner_session_id"], "session-b"); + assert!(by_id["legacy"]["owner_session_id"].is_null()); + + let unscoped = SubagentController::new(test_controller_config(temp.path().to_path_buf(), VTCodeConfig::default())) + .await + .expect("unscoped controller"); + assert_eq!(unscoped.background_status_entries().await.len(), 3); +} + +#[cfg(unix)] +#[tokio::test] +async fn new_background_records_capture_controller_parent_session() { + let temp = TempDir::new().expect("tempdir"); + write_test_background_subagent(temp.path()); + let mut cfg = VTCodeConfig::default(); + cfg.subagents.background.enabled = true; + let controller = SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg)) + .await + .expect("controller"); + + let entry = controller + .spawn_background_subprocess(SpawnBackgroundSubprocessRequest { + agent_type: Some("background-demo".to_string()), + ..SpawnBackgroundSubprocessRequest::default() + }) + .await + .expect("background subprocess"); + + assert_eq!(entry.owner_session_id.as_deref(), Some("parent-session")); +} + +#[test] +fn output_tail_uses_default_and_rejects_values_above_hard_bound() { + assert_eq!(normalize_output_tail_lines(None).expect("default tail size"), DEFAULT_SUBAGENT_OUTPUT_TAIL_LINES); + assert!(normalize_output_tail_lines(Some(MAX_SUBAGENT_OUTPUT_TAIL_LINES + 1)).is_err()); +} + +proptest! { + #[test] + fn owner_round_trip_and_output_tail_never_exceeds_hard_bound( + owner_session_id in prop::option::of("[a-z0-9-]{1,32}"), + line_count in 0usize..=20_000, + requested in 0usize..=20_000, + ) { + let record: PersistedBackgroundRecord = serde_json::from_value(serde_json::json!({ + "id": "background-worker", + "agent_name": "worker", + "display_label": "worker", + "description": "worker", + "source": "builtin", + "color": null, + "owner_session_id": owner_session_id.clone(), + "session_id": "child-session", + "exec_session_id": "exec-session", + "desired_enabled": true, + "status": "running", + "created_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-08-28T00:00:00Z", + "started_at": null, + "ended_at": null, + "pid": null, + "prompt": "work", + "summary": null, + "error": null, + "archive_path": null, + "transcript_path": null, + "max_turns": null, + "model_override": null, + "reasoning_override": null, + "restart_attempts": 0 + })).expect("generated persisted record"); + let runtime = BackgroundRecord::from_persisted(record); + prop_assert_eq!(runtime.build_status_entry().owner_session_id, owner_session_id); + + let content = (0..line_count).map(|line| line.to_string()).collect::>().join("\n"); + let effective = normalize_output_tail_lines(Some(requested)); + prop_assert!(effective.is_err() || *effective.as_ref().expect("checked result") <= MAX_SUBAGENT_OUTPUT_TAIL_LINES); + if let Ok(limit) = effective { + prop_assert!(extract_tail_lines(&content, limit).lines().count() <= MAX_SUBAGENT_OUTPUT_TAIL_LINES); + } + } } fn write_test_primary_agent(workspace_root: &std::path::Path) { @@ -1255,6 +1500,7 @@ async fn spawn_background_subprocess_returns_active_record_when_settings_match() description: spec.description.clone(), source: spec.source.label(), color: spec.color.clone(), + owner_session_id: None, session_id: "session-background-demo".to_string(), exec_session_id: "exec-session-background-demo".to_string(), desired_enabled: true, @@ -1316,6 +1562,7 @@ async fn spawn_background_subprocess_rejects_conflicting_active_record_settings( description: spec.description.clone(), source: spec.source.label(), color: spec.color.clone(), + owner_session_id: None, session_id: "session-background-demo".to_string(), exec_session_id: "exec-session-background-demo".to_string(), desired_enabled: true, diff --git a/crates/codegen/vtcode-core/src/subagents/types.rs b/crates/codegen/vtcode-core/src/subagents/types.rs index b78f85e04..d993c29dc 100644 --- a/crates/codegen/vtcode-core/src/subagents/types.rs +++ b/crates/codegen/vtcode-core/src/subagents/types.rs @@ -106,6 +106,8 @@ pub struct SubagentStatusEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackgroundSubprocessEntry { pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_session_id: Option, pub session_id: String, pub exec_session_id: String, pub agent_name: String, @@ -298,6 +300,8 @@ pub(crate) struct ChildRunRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistedBackgroundRecord { pub(crate) id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) owner_session_id: Option, agent_name: String, display_label: String, description: String, @@ -332,6 +336,7 @@ pub struct PersistedBackgroundState { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackgroundRecord { pub(crate) id: String, + pub(crate) owner_session_id: Option, pub(crate) agent_name: String, pub(crate) display_label: String, pub(crate) description: String, @@ -370,6 +375,7 @@ impl StatusEntryBuilder for BackgroundRecord { fn build_status_entry(&self) -> BackgroundSubprocessEntry { BackgroundSubprocessEntry { id: self.id.clone(), + owner_session_id: self.owner_session_id.clone(), session_id: self.session_id.clone(), exec_session_id: self.exec_session_id.clone(), agent_name: self.agent_name.clone(), @@ -489,6 +495,7 @@ impl BackgroundRecord { pub(crate) fn into_persisted(self) -> PersistedBackgroundRecord { PersistedBackgroundRecord { id: self.id, + owner_session_id: self.owner_session_id, agent_name: self.agent_name, display_label: self.display_label, description: self.description, @@ -518,6 +525,7 @@ impl BackgroundRecord { pub(crate) fn from_persisted(record: PersistedBackgroundRecord) -> Self { Self { id: record.id, + owner_session_id: record.owner_session_id, agent_name: record.agent_name, display_label: record.display_label, description: record.description, diff --git a/crates/codegen/vtcode-llm/src/providers/custom_provider.rs b/crates/codegen/vtcode-llm/src/providers/custom_provider.rs index 29ae455a0..6debf765a 100644 --- a/crates/codegen/vtcode-llm/src/providers/custom_provider.rs +++ b/crates/codegen/vtcode-llm/src/providers/custom_provider.rs @@ -464,6 +464,7 @@ mod tests { supports_parallel_tool_calls: None, supports_context_caching: None, supports_responses_compaction: None, + supports_stream_usage: None, supports_context_edits: Some(true), api_key_env: "ANTHROPIC_CUSTOM_API_KEY".to_string(), auth: None, @@ -571,6 +572,104 @@ mod tests { assert_eq!(payload["reasoning_effort"], "high"); } + async fn collect_completed_response( + provider: &CustomProviderBackendRouter, + model: &str, + ) -> crate::provider::LLMResponse { + let mut stream = provider + .stream(LLMRequest { + model: model.to_string(), + messages: vec![Message::user("hello".to_string())].into(), + stream: true, + ..Default::default() + }) + .await + .expect("stream should start"); + + while let Some(event) = stream.next().await { + if let LLMStreamEvent::Completed { response } = event.expect("stream event should decode") { + return *response; + } + } + + panic!("stream should yield a completed response"); + } + + #[tokio::test] + async fn openai_chat_stream_usage_obeys_profile_precedence_and_decodes_terminal_usage() { + const OPTED_IN_MODEL: &str = "baseten/usage"; + const OPTED_OUT_MODEL: &str = "baseten/no-usage"; + + let server = MockServer::start().await; + let captured = Arc::new(Mutex::new(Vec::::new())); + let captured_for_mock = Arc::clone(&captured); + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(move |request: &wiremock::Request| { + captured_for_mock + .lock() + .expect("capture mutex") + .push(serde_json::from_slice(&request.body).expect("valid request JSON")); + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string( + "data: {\"id\":\"chatcmpl-baseten\",\"object\":\"chat.completion.chunk\",\"model\":\"baseten/usage\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"answer\"},\"finish_reason\":null}]}\n\n\ + data: {\"id\":\"chatcmpl-baseten\",\"object\":\"chat.completion.chunk\",\"model\":\"baseten/usage\",\"choices\":[],\"usage\":{\"prompt_tokens\":13,\"completion_tokens\":5,\"total_tokens\":18}}\n\n\ + data: [DONE]\n\n", + ) + }) + .expect(2) + .mount(&server) + .await; + + let config = CustomProviderConfig { + name: "baseten".to_string(), + display_name: "Baseten".to_string(), + base_url: server.uri(), + api_format: CustomProviderApiFormat::OpenAIChat, + supports_stream_usage: Some(true), + model: OPTED_IN_MODEL.to_string(), + models: vec![OPTED_IN_MODEL.to_string(), OPTED_OUT_MODEL.to_string()], + profiles: std::collections::BTreeMap::from([( + OPTED_OUT_MODEL.to_string(), + CustomProviderProfileConfig { + supports_stream_usage: Some(false), + ..Default::default() + }, + )]), + ..Default::default() + }; + let router = CustomProviderBackendRouter::from_config( + config, + Some("fixture-key".to_string()), + Some(OPTED_IN_MODEL.to_string()), + server.uri(), + None, + None, + None, + Some(AnthropicConfig::default()), + None, + None, + ); + + let response = collect_completed_response(&router, OPTED_IN_MODEL).await; + let usage = response.usage.expect("terminal Baseten usage should be retained"); + assert_eq!((usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), (13, 5, 18)); + drop(collect_completed_response(&router, OPTED_OUT_MODEL).await); + + let requests = captured.lock().expect("capture mutex"); + let opted_in = requests + .iter() + .find(|request| request["model"] == OPTED_IN_MODEL) + .expect("opted-in request should be captured"); + let opted_out = requests + .iter() + .find(|request| request["model"] == OPTED_OUT_MODEL) + .expect("opted-out request should be captured"); + assert_eq!(opted_in["stream_options"]["include_usage"], true); + assert!(opted_out.get("stream_options").is_none()); + } + #[tokio::test] async fn openai_chat_stream_reassembles_fragmented_tool_call_playback() { let server = MockServer::start().await; diff --git a/crates/codegen/vtcode-llm/src/providers/openai/provider.rs b/crates/codegen/vtcode-llm/src/providers/openai/provider.rs index d613fd885..a842f591c 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/provider.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/provider.rs @@ -391,6 +391,15 @@ impl OpenAIProvider { self.provider_key_override.is_none() && self.backend_setup.is_native_openai_api() } + fn requests_stream_usage(&self, model: &str) -> bool { + self.is_native_openai_api() + || self + .custom_provider_config + .as_ref() + .and_then(|config| config.resolved_profile(model).supports_stream_usage) + .unwrap_or(false) + } + fn supports_manual_openai_compaction_for_model(&self, model: &str) -> bool { self.is_native_openai_api() && !self.uses_chatgpt_auth() diff --git a/crates/codegen/vtcode-llm/src/providers/openai/provider/streaming.rs b/crates/codegen/vtcode-llm/src/providers/openai/provider/streaming.rs index 84ccf48d0..3208afdda 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/provider/streaming.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/provider/streaming.rs @@ -238,8 +238,7 @@ impl OpenAIProvider { openai_request["stream"] = Value::Bool(true); // Request usage stats in the stream (compatible with newer OpenAI models) // Note: Some proxies do not support stream_options and will return 400. - let is_native_openai = self.is_native_openai_api(); - if is_native_openai { + if self.requests_stream_usage(&model) { openai_request["stream_options"] = json!({ "include_usage": true }); } let url = &self.chat_completions_url[..]; diff --git a/crates/codegen/vtcode-llm/tests/vidaimock_streaming.rs b/crates/codegen/vtcode-llm/tests/vidaimock_streaming.rs index 51f7bfe6d..e8135a0fc 100644 --- a/crates/codegen/vtcode-llm/tests/vidaimock_streaming.rs +++ b/crates/codegen/vtcode-llm/tests/vidaimock_streaming.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use futures::StreamExt; use vtcode_config::core::{AnthropicConfig, CustomProviderApiFormat, CustomProviderConfig}; -use vtcode_llm::provider::{LLMProvider, LLMRequest, LLMStreamEvent, Message}; +use vtcode_llm::provider::{LLMProvider, LLMRequest, LLMStreamEvent, Message, Usage}; use vtcode_llm::providers::CustomProviderBackendRouter; const MODEL: &str = "DeepSeek-V4-Flash-0731"; @@ -81,6 +81,7 @@ struct StreamObservation { output_times: Vec, content: String, completed: bool, + usage: Option, error: Option, } @@ -94,6 +95,7 @@ fn provider(base_url: &str) -> CustomProviderBackendRouter { display_name: "VidaiMock Arli".to_owned(), base_url: base_url.to_owned(), api_format: CustomProviderApiFormat::OpenAIChat, + supports_stream_usage: Some(true), model: MODEL.to_owned(), models: vec![MODEL.to_owned()], ..Default::default() @@ -131,6 +133,7 @@ async fn observe_stream(scenario: &str) -> Result { output_times: Vec::new(), content: String::new(), completed: false, + usage: None, error: Some(error.to_string()), }); } @@ -140,6 +143,7 @@ async fn observe_stream(scenario: &str) -> Result { output_times: Vec::new(), content: String::new(), completed: false, + usage: None, error: None, }; @@ -153,7 +157,10 @@ async fn observe_stream(scenario: &str) -> Result { observation.output_times.push(elapsed); observation.content.push_str(&delta); } - Ok(LLMStreamEvent::Completed { .. }) => observation.completed = true, + Ok(LLMStreamEvent::Completed { response }) => { + observation.completed = true; + observation.usage = response.usage; + } Ok(_) => {} Err(error) => { observation.error = Some(error.to_string()); @@ -175,6 +182,17 @@ async fn vidaimock_baseline_stream_completes_through_the_adapter() -> Result<()> Ok(()) } +#[tokio::test] +#[ignore = "requires the pinned VidaiMock executable and real wall-clock streaming physics"] +async fn vidaimock_baseten_terminal_usage_reaches_the_adapter() -> Result<()> { + let observation = observe_stream("success.toml").await?; + + assert!(observation.error.is_none(), "unexpected stream failure: {observation:?}"); + let usage = observation.usage.context("terminal usage should be retained")?; + assert_eq!((usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), (13, 5, 18)); + Ok(()) +} + #[tokio::test] #[ignore = "requires the pinned VidaiMock executable and real wall-clock streaming physics"] async fn vidaimock_delays_the_first_adapter_output() -> Result<()> { diff --git a/docs/acp/ACP_INTEGRATION.md b/docs/acp/ACP_INTEGRATION.md index 4393f6b1d..40a5311fc 100644 --- a/docs/acp/ACP_INTEGRATION.md +++ b/docs/acp/ACP_INTEGRATION.md @@ -1,18 +1,74 @@ -# ACP (Agent Communication Protocol) Integration Guide +# ACP (Agent Client Protocol) Integration Guide ## Overview -VT Code now supports Agent Communication Protocol (ACP) for inter-agent communication. This enables vtcode to act as an ACP client, discovering and communicating with other agents in a distributed system. +VT Code exposes two ACP-related surfaces. The current integration is a +stdio-based ACP server for editor clients such as Zed. It negotiates the ACP +capabilities during `initialize`, serves sessions, and sends `session/update` +notifications over the protocol connection. This is the integration described +in the [Zed ACP guide](../guides/zed-acp.md). + +This document also retains the original REST-based `AcpClient` reference. That +client is a legacy inter-agent API and is not the transport used by the current +ACP server; its sections are explicitly marked below. **Key Features:** -- REST-based HTTP protocol (no special SDKs required) -- Agent discovery (online and offline metadata) -- Synchronous and asynchronous request handling -- Health monitoring and agent registry management -- Three MCP tools for main agent integration +- stdio ACP server with `session/new`, `session/prompt`, and session resume + support +- capability negotiation for usage, task progress, and subagent management +- streaming message, thought, and tool updates through `session/update` +- the legacy REST client and its registry remain available for existing + inter-agent callers + +## Current ACP server + +### Initialization and capability negotiation + +The server responds to ACP `initialize` with protocol version 1 and advertises +the standard session capabilities it implements. It always includes the Lody +extension capability `_meta.lody.usage = { "version": 1 }`. Usage notifications +are sent only when the provider response includes usage data. + +When the session has a subagent controller, the response additionally includes +`_meta.lody.subagents` version 1 with `lifecycle`, `list`, `cancel`, and `output` +set to `true`. If background subagents are enabled, it also includes +`_meta.lody.tasks = { "version": 1, "background": true }`. These extension +capabilities are conditional; a client must not assume that subagent +management or background tasks are available when they are absent from the +handshake. + +Subagent and background-process progress uses ordinary ACP `session/update` +tool calls and tool-call updates. The update carries the task description and +status in the standard fields, while `meta.lody.task` contains the additional +Lody task snapshot (for example task ID, kind, actor, timestamps, summary, and +error details). + +The following Lody extension requests are available when subagent management +was advertised: + +- `_lody/subagents/list` lists the caller session's owned tasks; +- `_lody/subagents/cancel` requests cancellation of an owned task; and +- `_lody/subagents/output` returns a bounded output tail for an owned task. + +Usage is reported with the `_lody/session/usage_update` extension notification. +Its parameters contain `sessionId`, a `usage` object with normalized input, +output, and cache token counts, and `modelUsage`, keyed by model name. The +notification represents the usage delta for one provider response, not a +running total. + +The current server is launched with `vtcode acp` and communicates over stdio; +it does not expose the legacy `/messages`, `/metadata`, or `/health` HTTP +endpoints described in the reference below. + +## Legacy REST/client reference + +The material in the remaining sections documents the original REST-based +`AcpClient`, agent registry, and MCP wrappers. Keep it for callers that still +use that API, but do not use it as the launch or wire-format documentation for +the current stdio ACP server. -## Architecture +## Legacy REST/client architecture ``` @@ -44,7 +100,7 @@ VT Code now supports Agent Communication Protocol (ACP) for inter-agent communic ``` -## Module Structure +## Legacy REST/client module structure ### `vtcode-acp` Library diff --git a/docs/acp/ACP_QUICK_REFERENCE.md b/docs/acp/ACP_QUICK_REFERENCE.md index 8b730ccbf..6d5aa7de3 100644 --- a/docs/acp/ACP_QUICK_REFERENCE.md +++ b/docs/acp/ACP_QUICK_REFERENCE.md @@ -1,5 +1,64 @@ # ACP Quick Reference +The current VT Code ACP integration is a stdio server. The REST `AcpClient` +snippets below are retained as a legacy client reference and do not describe +the server handshake or transport. + +## Current ACP server + +Launch the server with: + +```bash +vtcode acp +``` + +The server negotiates protocol version 1 during `initialize`. Its Lody usage +capability is always advertised as `_meta.lody.usage = { "version": 1 }`. +When a subagent controller is configured, the handshake also advertises +`_meta.lody.subagents` version 1 with `lifecycle`, `list`, `cancel`, and `output` +operations. The `_meta.lody.tasks = { "version": 1, "background": true }` +capability is included only when background subagents are enabled. + +Subagent and background-process progress is sent through standard ACP +`session/update` tool calls and tool-call updates. The standard task fields +carry the title and status; the additional task snapshot is in +`_meta.lody.task`. + +The conditional Lody management requests are: + +```text +_lody/subagents/list +_lody/subagents/cancel +_lody/subagents/output +``` + +The server reports per-response provider usage through the extension +notification `_lody/session/usage_update`. Its parameters contain: + +```json +{ + "sessionId": "session-id", + "usage": { + "inputTokens": 123, + "outputTokens": 45, + "cacheReadInputTokens": 0 + }, + "modelUsage": { + "model-name": { + "inputTokens": 123, + "outputTokens": 45, + "cacheReadInputTokens": 0 + } + } +} +``` + +`modelUsage` is keyed by the response model. The values are deltas for one +provider response, and no notification is emitted when the provider supplies +no usage data. + +## Legacy REST ACP client reference + ## Initialize ACP Client ```rust diff --git a/docs/config/CONFIG_FIELD_REFERENCE.md b/docs/config/CONFIG_FIELD_REFERENCE.md index ab9c7a594..fba88d0b2 100644 --- a/docs/config/CONFIG_FIELD_REFERENCE.md +++ b/docs/config/CONFIG_FIELD_REFERENCE.md @@ -253,6 +253,7 @@ python3 scripts/generate_config_field_reference.py | `custom_providers[].supports_parallel_tool_calls` | `boolean \| null` | no | `null` | Provider-level default for parallel tool calls when per-model metadata is unavailable. | | `custom_providers[].supports_context_caching` | `boolean \| null` | no | `null` | Provider-level default for context caching when per-model metadata is unavailable. | | `custom_providers[].supports_responses_compaction` | `boolean \| null` | no | `null` | Provider-level default for Responses compaction when per-model metadata is unavailable. | +| `custom_providers[].supports_stream_usage` | `boolean \| null` | no | `null` | Request terminal usage data from compatible OpenAI-chat streams. Omitted or `false` preserves the existing request shape. | | `custom_providers[].supports_context_edits` | `boolean \| null` | no | `null` | Provider-level default for context edits when per-model metadata is unavailable. | | `custom_providers.profiles.""` | `table` | no | `-` | Per-model sparse profile used to override runtime defaults for a specific model identifier. Profiles do NOT add models to the picker; they only alter runtime behavior (capabilities, api_format, context_window, etc.). | | `custom_providers.profiles."".api_format` | `string \| null` | no | `null` | Per-model API format hint. Same allowed values as `custom_providers[].api_format`. Omitted preserves legacy/autodetect behavior for that model. | @@ -265,6 +266,7 @@ python3 scripts/generate_config_field_reference.py | `custom_providers.profiles."".supports_parallel_tool_calls` | `boolean \| null` | no | `null` | Whether the model reliably supports parallel/async tool call batches. | | `custom_providers.profiles."".supports_context_caching` | `boolean \| null` | no | `null` | Whether the model benefits from context caching between turns. | | `custom_providers.profiles."".supports_responses_compaction` | `boolean \| null` | no | `null` | Whether the model supports native Responses-style server compaction. | +| `custom_providers.profiles."".supports_stream_usage` | `boolean \| null` | no | `null` | Per-model override for requesting terminal usage data from compatible OpenAI-chat streams. | | `custom_providers.profiles."".supports_context_edits` | `boolean \| null` | no | `null` | Whether the model supports context edit operations (insert/replace) when the provider API exposes them. | | `custom_providers[].display_name` | `string` | yes | `-` | Human-friendly label shown in the TUI header, footer, and model picker (e.g., "MyCorporateName"). | | `custom_providers[].model` | `string` | no | `""` | Default model to use with this endpoint (e.g., "gpt-5-mini"). When [`models`](Self::models) is empty, this single model is what the `/model` picker offers for this provider. When [`models`](Self::models) is non-empty, this field is used as the default selection but the picker lists every entry in [`models`](Self::models). | diff --git a/docs/config/config.md b/docs/config/config.md index 6396dd3e6..9df465d46 100644 --- a/docs/config/config.md +++ b/docs/config/config.md @@ -169,6 +169,7 @@ api_key_env = "MYCORP_API_KEY" model = "gpt-5.4" # context_window = 256000 # Optional context window size in tokens (provider capability) # api_format = "auto" # Optional provider-level API format hint: auto|openai-chat|openai-responses|anthropic-messages +# supports_stream_usage = true # Opt in only when this OpenAI-chat endpoint emits a terminal usage chunk [custom_providers.request_policy] max_in_flight_requests = 4 @@ -210,7 +211,7 @@ Notes: Capability defaults and per-model profiles -Custom providers may expose a small, conservative set of capability defaults to use when model metadata is absent. These are useful for gateways and aggregators that do not provide per-model descriptors. Set fields such as `supports_tools`, `supports_vision`, `supports_structured_output`, or `supports_parallel_tool_calls` directly on the provider entry. +Custom providers may expose a small, conservative set of capability defaults to use when model metadata is absent. These are useful for gateways and aggregators that do not provide per-model descriptors. Set fields such as `supports_tools`, `supports_vision`, `supports_structured_output`, `supports_parallel_tool_calls`, or `supports_stream_usage` directly on the provider entry. `supports_stream_usage` is optional and defaults to `false`; set it to `true` only when an OpenAI-chat endpoint supports a terminal usage chunk in streamed responses. It enables `stream_options.include_usage = true` for that custom provider's OpenAI-chat requests. Native OpenAI provider behaviour is unchanged. For fine-grained overrides you can declare sparse per-model profiles. Profiles live in `custom_providers.profiles.""` and only modify runtime defaults for that specific model identifier. IMPORTANT: profiles do not add or enable models in the picker — `model` / `models` remain the allowlist/default. A profile only changes how VT Code treats an already-selected model at runtime (capabilities, context window, api_format, etc.). @@ -227,6 +228,7 @@ supports_parallel_tool_calls = true supports_context_caching = false supports_responses_compaction = true supports_context_edits = false +# supports_stream_usage = true # only for endpoints with a terminal usage chunk ``` Precedence and semantics @@ -241,6 +243,7 @@ When determining a model's runtime shape VT Code applies values in the following Additional rules: - An explicit boolean `false` in any overriding layer is honored and prevents a higher-level implicit `true` from taking effect. - Omitting `api_format` preserves legacy autodetection behavior; explicitly setting `api_format` to a value instructs VT Code to use that API shape and not silently fall back. +- `supports_stream_usage` follows the same precedence: a profile value overrides the provider default. When `true`, only custom OpenAI-chat streams request `stream_options.include_usage = true`; the endpoint should return usage in the terminal empty-choices chunk. When omitted or `false`, VT Code does not request streamed usage. Native OpenAI requests are unaffected. - Profiles do not make a model available in the picker — use `model` or `models` to control availability. Store a custom provider key with the same explicit identity used by the diff --git a/docs/development/model-profiles.md b/docs/development/model-profiles.md index 2e3f50b92..e6a4b1e0f 100644 --- a/docs/development/model-profiles.md +++ b/docs/development/model-profiles.md @@ -13,8 +13,9 @@ Provider-level fields (in `[[custom_providers]]`) - Provider capability in tokens. Drives UI context sizing, compaction thresholds, and preflight token checks. - When omitted VT Code uses the provider/model default (commonly 128000 for custom OpenAI-compatible endpoints unless otherwise discovered). -- `supports_tools`, `supports_reasoning`, `supports_reasoning_effort`, `supports_vision`, `supports_structured_output`, `supports_parallel_tool_calls`, `supports_context_caching`, `supports_responses_compaction`, and `supports_context_edits` (optional booleans) +- `supports_tools`, `supports_reasoning`, `supports_reasoning_effort`, `supports_vision`, `supports_structured_output`, `supports_parallel_tool_calls`, `supports_context_caching`, `supports_responses_compaction`, `supports_context_edits`, and `supports_stream_usage` (optional booleans) - Provider-level conservative defaults applied when per-model metadata is unavailable. + - `supports_stream_usage` defaults to `false`. Set it to `true` only when a custom OpenAI-chat endpoint accepts `stream_options.include_usage = true` and sends usage in the terminal streamed chunk; native OpenAI requests are unchanged. Per-model profiles (sparse overrides) @@ -29,12 +30,14 @@ supports_tools = true supports_vision = false supports_structured_output = true supports_parallel_tool_calls = true +# supports_stream_usage = true # only when this endpoint emits terminal stream usage Notes and semantics - model / models remain the allowlist/defaults used to control the `/model` picker and what models are available. Profiles do NOT make a model available; they only modify runtime defaults for a model identifier that is already selectable. - Precedence (highest wins): profile > provider defaults > model metadata / autodetect > conservative fallback. - Explicit boolean `false` is honored and may override an implicit `true` from a lower-precedence layer. +- `supports_stream_usage` uses the same profile-over-provider precedence. When enabled, VT Code requests usage for custom OpenAI-chat streams; when omitted or `false`, it leaves `stream_options.include_usage` disabled. - Omitting `api_format` preserves legacy behavior. Setting `api_format` explicitly instructs VT Code to treat the model with that API shape; it does not cause silent fallbacks. -Keep examples small and conservative: prefer to declare only the fields you need to correct autodetection or to provide conservative capability signals for gateways that omit detailed model descriptors. \ No newline at end of file +Keep examples small and conservative: prefer to declare only the fields you need to correct autodetection or to provide conservative capability signals for gateways that omit detailed model descriptors. diff --git a/docs/execplans/acp-lody-task-lifecycle-negotiation.md b/docs/execplans/acp-lody-task-lifecycle-negotiation.md new file mode 100644 index 000000000..c82d5c287 --- /dev/null +++ b/docs/execplans/acp-lody-task-lifecycle-negotiation.md @@ -0,0 +1,813 @@ +# Negotiate Lody usage, tasks and subagents over ACP + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & discoveries`, +`Decision log`, `Outcomes & retrospective`, `Conformance basis`, and +`Verification plan` must be kept up to date as work proceeds. + +Status: COMPLETE + +## Purpose / big picture + +After this change, a Lody client connected to VTCode over the Agent Client +Protocol (ACP) can negotiate exactly the usage, background-task and subagent +features that VTCode implements. Lody will receive provider token usage, render +delegated agents and managed background processes as first-class tasks, and +list, cancel or inspect output for those tasks. VTCode's model-facing task +tracker remains a standard ACP `Plan`; this work does not create a second task +tracker. + +The visible success case is an ACP session in which initialization advertises +`agentCapabilities._meta.lody`, a Baseten-backed turn produces a canonical +`_lody/session/usage_update`, and subagent progress appears as standard ACP +`tool_call` and `tool_call_update` messages carrying `_meta.lody.task`. Lody's +management requests return only tasks owned by the requested ACP session. + +## Constraints + +- Base the branch on `fix/acp-unresolved-tool-recovery` at + `80bcd6530c58e4f8b03d5d53e99781024d6f4f58`, the head of PR #13. +- Preserve ACP 1.x interoperability. Lody-specific data must live under the + `_meta.lody` namespace or `_lody/*` extension methods; other ACP clients must + continue to work when they ignore those extensions. +- Use `vtcode-exec-events::ThreadEvent` and the existing + `vtcode_core::subagents::SubagentController`; do not introduce a parallel + runtime event or task registry. +- Keep VTCode's `task_tracker` output on the standard ACP `Plan` path. Lody's + `tasks` capability describes background or scheduled work, not plan entries. +- Advertise only implemented operations. Omit `scheduled`, and omit any + `subagents` management flag whose request handler is not present and tested. +- Do not send `stream_options.include_usage` to every OpenAI-compatible proxy. + Some proxies reject it. Custom-provider streaming usage must be an explicit + profile capability and default off. +- Treat every `_lody/subagents/*` request as session-scoped. A task from a + different ACP session must be indistinguishable from an unknown task. +- Do not add an external Rust dependency. Use the pinned ACP SDK, Serde, the + existing subagent controller and existing provider response types. +- Keep changes compatible with Rust 1.88 and the repository's formatting, + Clippy, Whitaker and nextest policies. +- Follow Red-Green-Refactor for every runtime milestone. Commit and gate each + coherent change, and install the user-level binary after each code commit. + +## Tolerances (exception triggers) + +- Scope: stop if the implementation needs more than 16 repository files or + 1,200 net lines outside tests, documentation and this plan. +- Interface: stop if satisfying the Lody contract requires changing a released + public Rust API outside the pre-1.0 ACP/config integration surface. +- Dependencies: stop if a new external crate or JavaScript package is needed. +- Protocol: stop if the current Lody contract cannot be implemented without + advertising a capability before its handler is available. +- Security: stop if task ownership cannot be established from the existing + controller state without exposing cross-session task data. +- Iterations: stop after three unsuccessful fixes for the same focused test + failure and record the competing hypotheses. +- Ambiguity: stop if Lody's published extension contract and its checked-out + client disagree in a way that changes wire compatibility. + +## Risks + +- Risk: the ACP SDK routes all unknown extension requests through one + `ExtRequest` handler, so careless dispatch could consume unrelated methods. + Severity: high. Likelihood: medium. Mitigation: dispatch only exact `_lody` + method constants and return a protocol method-not-found error otherwise. +- Risk: one subagent controller may retain tasks from more than one ACP session. + Severity: high. Likelihood: medium. Mitigation: compute the requested + session's task closure from parent identifiers and test generated mixed-session + forests; validate ownership before list, cancel and output. +- Risk: `SubagentStatus::Closed` and background `Stopped` do not encode every + Lody terminal reason. Severity: medium. Likelihood: high. Mitigation: define + one explicit, documented mapping; use `failed` for a closed child and + `completed` for a normally stopped background process, preserving errors and + raw summaries where the Lody schema permits them. +- Risk: provider usage may be absent or incomplete. Severity: medium. + Likelihood: medium. Mitigation: emit no usage notification when a provider + supplies none, never invent token counts, and represent unavailable optional + fields by omission. +- Risk: Baseten streaming usage is currently absent because VTCode does not + request it for custom providers. Severity: high for the user's providers. + Likelihood: certain. Mitigation: add an opt-in `supports_stream_usage` + profile capability, exercise a Baseten-shaped final usage chunk with + vidai-mock, and leave the default off. +- Risk: lifecycle events can arrive after a session ends. Severity: medium. + Likelihood: low. Mitigation: retain the existing per-session forwarder + lifetime, parent-session filter and abort-on-drop behaviour. + +## Progress + +- [x] (2026-08-28 11:08Z) Create + `fix/acp-lody-task-lifecycle-negotiation` from PR #13 in an independent + worktree. +- [x] (2026-08-28 11:20Z) Verify the VTCode event/controller paths and the + current Lody extension contract. +- [x] (2026-08-28 11:28Z) Confirm Baseten's streamed-usage contract and the + current custom-provider opt-in gap. +- [x] (2026-08-28 12:39Z) Obtain approval for this ExecPlan and begin EP-M1. +- [x] (2026-08-28 13:28Z) Complete EP-M1 red and focused green tests for + standard task lifecycle updates, conditional negotiation and stable IDs. +- [x] (2026-08-28 15:24Z) EP-M1: replace `_vtcode/taskLifecycle` with standard + task-carrying ACP tool updates and negotiate subagent lifecycle. +- [x] (2026-08-28 16:02Z) Resolve the EP-M2 security tolerance: + persisted background records do not carry an explicit owning ACP session, + so foreign-task isolation cannot be proved without expanding the core model. + The user approved the robust expansion: persist explicit ownership, hide + legacy ownerless records from ACP management and add a bounded output-tail + API before registering any management handlers. +- [x] (2026-08-28) EP-M2: implement and negotiate background tasks plus + session-scoped subagent list, cancel and output methods. The core expansion + persists optional `owner_session_id`, hides legacy ownerless records from + ACP, and exposes bounded output tails (default 200, maximum 10,000) while + preserving the existing 24-line preview API. The exact extension dispatcher + and real ACP duplex tests cover negotiation, owned list/output/cancel and + foreign-task indistinguishability from unknown tasks. +- [x] (2026-08-28) EP-M3: emit negotiated provider usage and opt custom + providers into streaming usage. Per-response usage deltas now use the + canonical `_lody/session/usage_update` method, and custom-provider + streaming usage is opt-in through profile resolution. +- [x] (2026-08-28) EP-M4: update documentation, run release gates, install the + binary, push the stack and open the draft PR. + - Current ACP/Lody negotiation, task/subagent management and usage are now + documented alongside the custom-provider streamed-usage opt-in. + - The config-reference generator completed; its unrelated pre-existing + full-file drift was excluded and the two new schema rows were retained. + - Both user-level Baseten profiles now opt into streamed usage. + - The final release gate passed with 6,588 tests and 16 skips; the user-level + binary was reinstalled and resolves through `/home/leynos/.local/bin/vtcode`. + - Draft PR [#14](https://github.com/leynos/vtcode/pull/14) targets + `fix/acp-unresolved-tool-recovery`. PRs #11 through #14 form stack #16, + preserving the intended four-layer base chain. + +## Surprises & discoveries + +- Observation: Lody's version-1 task schema accepts only `pending`, + `in_progress`, `completed` and `failed`; it does not accept `killed`. + Evidence: `packages/shared/src/acp/claude-subagent-task.ts` in the checked-out + Lody source defines the closed status enum used by `_meta.lody.task`. + Impact: a closed child maps to `failed`, with its error retained, rather than + the unsupported `killed` value in the draft risk mitigation. +- Observation: the EP-M1 red run failed at compile time because + `lody_task_session_update` was intentionally absent. + Evidence: `/tmp/red-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm1.out` + reports `E0425` from the new task lifecycle unit test. + Impact: the red test directly proves that the new standard ACP adapter is + required; the existing private notification cannot satisfy it. +- Observation: EP-M1 passed the complete repository release gate and was + committed as `16ba7b516`. + Evidence: `/tmp/check-VTCode-fix-acp-lody-task-lifecycle-negotiation-2.out` + records rustfmt, policy checks, Clippy, build, 6,582 tests, harness + regressions and rustdoc passing. + Impact: the standard lifecycle repair is a complete, independently + releasable plateau. +- Observation: persisted background records are loaded into every controller + for a workspace without an explicit owning ACP session, while background + cancellation itself performs no ACP ownership check. + Evidence: `SubagentController::new` loads `background_children` from + workspace state; `BackgroundSubprocessEntry` contains generated runtime and + exec session IDs but no parent ACP session ID. + Impact: EP-M2 cannot prove `SESSION-ISOLATION` for background list, cancel or + output using the existing controller state. This activates the plan's + security tolerance and requires a deliberate persisted-model/API expansion + or removal of background management from this stack. +- Observation: the current public background snapshot API hard-limits previews + to 24 lines, while the approved Lody output contract specifies a caller + default of 200 and a hard maximum of 10,000. + Evidence: `SUBAGENT_PREVIEW_LINES` and `background_snapshot()` clamp both + live and archived output before the ACP adapter can apply a requested tail. + Impact: meeting the output contract also requires extending the controller + API rather than adding only an ACP adapter. +- Observation: ACP SDK `ExtRequest` is a schema value, not a directly + registerable typed request handler. + Evidence: `ClientRequest::ExtMethodRequest` owns extension dispatch and the + SDK strips the leading underscore before constructing `ExtRequest`. + Impact: EP-M2 must use a custom untyped dispatcher or a carefully scoped + `ClientRequest` catch-all that returns `Handled::No` for standard requests. +- Observation: filtering persisted records only when a controller loads is not + sufficient isolation: an owner-scoped save would otherwise delete foreign + and legacy records from the shared workspace state file. + Evidence: `save_background_state` previously replaced the whole persisted + record set with the controller's in-memory subset. + Impact: scoped controllers now receive their real ACP session ID before + restore and merge only their owner's records under a process-wide + persistence lock; unscoped CLI controllers retain replace-all semantics. + +- Observation: truthful background-task negotiation is controlled by + `background_subagents_enabled`, not by the broader + `managed_background_runtime` predicate. + Evidence: the EP-M2 capability and duplex tests exercise the configured + background-subagent feature flag directly. + Impact: ACP advertises background tasks only when that capability is enabled; + unrelated runtime state cannot cause an unsupported promise. + +- Observation: EP-M2's red tests exposed absent ownership and management + plumbing; the expanded core and ACP suites then passed the contract. + Evidence: `/tmp/red-vtcode-core-epm2.out`, + `/tmp/green-vtcode-core-epm2-final.out` (5/5), + `/tmp/red-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm2-acp.out`, + and `/tmp/test-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm2-final.out` + (6/6). + Impact: EP-M2 is complete; provider usage and custom-provider streaming + opt-in are the remaining implementation work. + +- Observation: VTCode already emits the model's task tracker as a standard ACP + `Plan`, including blocked-task labels and durable replay. + Evidence: `crates/codegen/vtcode-acp/src/zed/agent/task_progress.rs` and its + duplex tests in `zed/agent/handlers.rs`. + Impact: no new plan/task-tracker protocol is required. The Lody `tasks` + capability will advertise only enabled background-subagent work. +- Observation: `_vtcode/taskLifecycle` is neither a standard ACP task update nor + a Lody-recognized legacy extension, and its discriminator is incompatible + with Lody's Claude compatibility parser. + Evidence: `crates/codegen/vtcode-acp/src/zed/agent/task_lifecycle.rs` uses + `message.type`; Lody commit `dd241fd4108ba5de2f7e2d8d627713152d812a06` + recognizes only `_claude/taskLifecycle` and `_kimi/taskLifecycle` legacy + carriers using a different shape. + Impact: the custom notification must be removed rather than renamed. +- Observation: the existing `SubagentController` already exposes status, + close/cancel, child snapshots and background output previews. + Evidence: `vtcode-core/src/subagents/mod.rs`, + `controller_spawn_run.rs` and `controller_background_ops.rs`. + Impact: Lody management is an ACP adapter concern, not a new core subsystem. +- Observation: Baseten documents per-request token usage, cached input tokens + and final streamed usage, but the final usage chunk is conditional on + `stream_options.include_usage: true`. + Evidence: Baseten's Chat Completions reference and GLM-5.3 Flash model page; + VTCode currently adds the option only for native OpenAI in + `vtcode-llm/src/providers/openai/provider/streaming.rs`. + Impact: negotiated Lody usage would remain empty for the user's Baseten + sessions without a provider-profile opt-in. +- Observation: Lody's canonical usage extension is the version-1 + `_lody/session/usage_update` method, and VTCode must advertise + `usage: {version: 1}` even when a provider does not return usage. + Evidence: the Lody Core usage method and the EP-M3 capability and mapping + tests; `/tmp/test-vtcode-acp-fix-acp-lody-task-lifecycle-negotiation-epm3-final.out`. + Impact: usage negotiation is stable across providers, while a response with + no normalized usage produces no notification rather than invented counts. +- Observation: usage is emitted once per provider response, including each + response in a tool loop, and the normalized terminal `choices: []` usage + chunk is retained by the custom OpenAI-compatible adapter. + Evidence: the official ACP duplex test and the custom-provider wiremock and + vidai-mock tests recorded in the EP-M3 artefacts below. + Impact: Lody receives additive deltas for intermediate and final responses, + including Baseten-style terminal usage, without double-counting. +- Observation: custom-provider `supports_stream_usage` is sparse and defaults + to false; resolved profile precedence controls the request, while native + OpenAI request behaviour is unchanged. + Evidence: the configuration precedence and request-shape tests in + `/tmp/test-vtcode-config-fix-acp-lody-task-lifecycle-negotiation-epm3.out` + and `/tmp/test-vtcode-llm-wiremock-fix-acp-lody-task-lifecycle-negotiation-epm3.out`. + Impact: existing proxy configurations remain safe, and only an explicitly + opted-in compatible endpoint receives `stream_options.include_usage`. +- Observation: the first standalone ACP usage duplex check exposed timing and + extension-method normalization assumptions; the final test uses the + official ACP extension channel and passes after those boundaries were made + explicit. + Evidence: the final green transcript is + `/tmp/test-vtcode-acp-usage-duplex-fix-acp-lody-task-lifecycle-negotiation-epm3-green.out`. + Impact: the behavioural test validates the wire contract rather than a + direct helper invocation. +- Observation: Baseten's aggregate `/v1/model_apis/usage` endpoint is bucketed + across requests and API keys. + Evidence: Baseten's model API pricing and limits documentation. + Impact: it is unsuitable for per-turn ACP usage and is out of scope. + +## Decision log + +- Decision: stack this work directly on PR #13 rather than the unpublished + skill-discovery or context-limit branches in the original worktree. + Rationale: the user requested a new stacked PR, and PR #13 is the latest + published stack layer. This avoids silently coupling the integration to + unrelated unpublished commits. + Date/Author: 2026-08-28 / Codex. +- Decision: use standard ACP `ToolCall`/`ToolCallUpdate` messages with stable + `task:` identifiers and `_meta.lody.task` snapshots. + Rationale: this is the Lody contract and remains meaningful to non-Lody ACP + clients, unlike a provider-specific lifecycle notification. + Date/Author: 2026-08-28 / Codex. +- Decision: negotiate `usage: {version: 1}` unconditionally, `tasks: + {version: 1, background: true}` only when `background_subagents_enabled` is + true, + and `subagents` only when a controller exists. Advertise list, cancel and + output only after their handlers land in the same coherent commit. + Rationale: capabilities are promises, not product marketing. + Date/Author: 2026-08-28 / Codex. +- Decision: emit usage as per-response deltas, not cumulative session totals. + Rationale: Lody's usage tracking service sums incoming updates per ACP + session; cumulative snapshots would double-count. + Date/Author: 2026-08-28 / Codex. +- Decision: add `supports_stream_usage` to custom-provider profiles rather than + special-casing Baseten hostnames. + Rationale: the capability is part of the OpenAI-compatible peer contract, + while hostname inference is brittle and would expose other proxies to known + 400 responses. + Date/Author: 2026-08-28 / Codex. +- Decision: extend persisted background records with an optional owning ACP + session identifier, treat a missing identifier as legacy unowned state, and + exclude such records from every ACP list, cancel and output operation. Add a + controller API that returns a caller-requested output tail under a hard + bound, while preserving the existing 24-line preview API for current callers. + Rationale: explicit ownership is the only available basis for proving + session isolation across process restarts. An optional additive field keeps + old records deserializable without granting them new authority, and a new + bounded API avoids silently changing existing preview behaviour. + Date/Author: 2026-08-28 / Codex, approved by the user as option 1. +- Decision: gate the advertised background-task capability with + `background_subagents_enabled`. + Rationale: negotiation must describe the ACP feature that is actually + enabled, rather than infer support from a separate runtime-management + predicate. + Date/Author: 2026-08-28 / Codex. +- Decision: advertise Lody usage version 1 unconditionally, but emit the + canonical `_lody/session/usage_update` only when a provider response carries + normalized usage. Emit one delta per response, including every tool-loop + response, and mirror the delta in the response model's `modelUsage` entry. + Rationale: capability negotiation is stable, absent provider data is not + fabricated, and Lody's accumulator expects additive per-response values. + Date/Author: 2026-08-28 / Codex. +- Decision: make custom-provider streamed usage a sparse profile capability, + defaulting to false and following resolved profile precedence; leave native + OpenAI behaviour unchanged. + Rationale: OpenAI-compatible proxies vary in support for + `stream_options.include_usage`, so an explicit opt-in preserves existing + request compatibility while enabling Baseten-shaped terminal usage. + Date/Author: 2026-08-28 / Codex. +- Decision: keep the generated configuration reference change scoped to the + two new `supports_stream_usage` rows rather than committing the generator's + unrelated full-file drift. + Rationale: `scripts/generate_config_field_reference.py` completed and proved + both schema paths exist, but the checked-in reference had broad pre-existing + drift across hundreds of unrelated fields. A full regeneration would obscure + this feature and violate the stacked PR's review boundary. + Date/Author: 2026-08-28 / Codex. + +## Outcomes & retrospective + +EP-M1 landed as an independently gated lifecycle repair. Standard ACP clients +now receive task-shaped tool calls, Lody receives `_meta.lody.task`, and +capability negotiation is truthful. EP-M2 crossed its explicit security +tolerance only after the user approved an additive persisted-model expansion: +explicit owner identifiers, fail-closed handling of legacy ownerless records, +and bounded caller-selected output tails. The exact dispatcher now serves +negotiated list, output and cancel requests, with real duplex coverage for +owned and foreign tasks. The core and ACP focused suites passed (5/5 and 6/6 +respectively). EP-M3 then added canonical per-response Lody usage updates, +profile-controlled custom-provider streamed usage and local Baseten-shaped +playback/physics coverage. EP-M4 documented the negotiated extensions and +custom-provider opt-in, passed the complete release gate, installed the user +binary and published draft PR #14 as the fourth layer of stack #16. The plan is +complete. Provider limits, compaction metadata, notices, final-answer phase +metadata and scheduled tasks remain explicitly independent follow-ups. + +## Context and orientation + +VTCode's ACP server lives in `crates/codegen/vtcode-acp`. The SACP handler +registration and initialization response are in +`src/zed/agent/handlers.rs`; standard session notifications are sent through +`src/zed/agent/updates.rs` and `src/zed/connection.rs`. Per-session runtime +state is in `src/zed/types.rs`. + +`src/zed/agent/task_lifecycle.rs` subscribes to +`vtcode_core::subagents::SubagentProgressEvent`, filters events by parent ACP +session and currently emits the ignored `_vtcode/taskLifecycle` notification. +The controller that owns those events is in +`crates/codegen/vtcode-core/src/subagents`. It already provides status lists, +child shutdown, child transcript snapshots, background shutdown and background +output previews. + +Lody extensions are negotiated in `agentCapabilities._meta.lody`. Version 1 +defines `usage`, `tasks` and `subagents` independently. Session usage travels +through `_lody/session/usage_update`. Management uses +`_lody/subagents/list`, `_lody/subagents/cancel` and +`_lody/subagents/output`. Lifecycle itself does not use a custom method: it is +a standard ACP tool call whose `_meta.lody.task` value is a complete task +snapshot. + +The word "task" has three distinct meanings here. VTCode's model-facing +`task_tracker` is an ACP `Plan`. Lody's `tasks` capability says whether the +agent supports managed background or scheduled work. Lody task metadata is the +UI/history representation for subagents, background work or scheduled work. +Only the latter two are changed by this plan. + +Provider responses carry `vtcode_commons::llm::Usage` with prompt, completion, +cache-read and cache-creation counts. OpenAI-compatible streams expose usage in +a terminal SSE chunk only when requested. The custom-provider OpenAI router +already parses a received usage chunk, but it does not request one from +Baseten today. + +## Conformance basis + +- User requirement, 2026-08-28: implement negotiated Lody usage, tasks and + subagents, beginning with the standard task-lifecycle repair. +- ACP SDK `agent-client-protocol = 2.0.0`, schema 1.5.0, and ACP v1 + extensibility/tool-call contracts pinned by this repository. +- Lody extension contract `LodyAI/acp-extension-core` main revision + `23c792b910a903b74601e346473827106f991715` (`capabilities.ts`, `methods.ts`, + `session.ts`, `usage.ts`). +- Lody client/parser revision + `dd241fd4108ba5de2f7e2d8d627713152d812a06` in + `/home/leynos/Projects/Lody`. +- Baseten Chat Completions, model API pricing/limits, observability and GLM-5.3 + Flash documentation as observed on 2026-08-28. +- No separate VTCode technical design or ADR governs Lody extensions. This + ExecPlan is the lightweight architecture contract for this branch. + +Trace links: + +```plaintext +USER-LIFECYCLE -> EP-M1 -> acp_duplex_emits_lody_task_updates +USER-SUBAGENTS -> EP-M1 -> EP-M2 -> acp_duplex_manages_only_session_tasks +USER-TASKS -> EP-M2 -> lody_background_capability_is_truthful +USER-USAGE -> EP-M3 -> baseten_usage_reaches_lody +``` + +## Verification plan + +- Obligation: `NEGOTIATION-TRUTH`. Every advertised Lody feature has a working + handler or emission path, disabled subagent/background features are omitted, + and no unimplemented scheduled support is advertised. + Method: parameterized unit tests plus official ACP duplex behavioural tests. + Rationale: the state space is the finite cross-product of controller absent, + controller present and background disabled/enabled. + Domain: all capability combinations VTCode can construct. + Artefact: tests beside `advertised_agent_capabilities` and in the ACP duplex + harness in `zed/agent/handlers.rs`. + Evidence: the red test observes missing `_meta.lody`; the green test parses + exact versioned capabilities and successfully invokes every advertised + method. + Non-vacuity: include witnesses for no controller, child-only controller and + child-plus-background controller; a negative control advertises no + `scheduled` key and rejects an unknown `_lody` method. +- Obligation: `TASK-IDENTITY`. Every lifecycle update for one VTCode task uses + one stable `task:` tool-call identifier, carries a complete valid + `_meta.lody.task` snapshot and maps terminal state monotonically. + Method: table tests for the finite status enums and a property test over + generated valid lifecycle sequences. + Rationale: examples prove wire shape while generated sequences catch + accidental identifier changes, terminal regressions and incomplete metadata. + Domain: child and background statuses, summaries/errors and timestamp + presence across sequences of length 1 through 32. + Artefact: `task_lifecycle.rs` unit/property tests and official ACP duplex + lifecycle test. + Evidence: the existing custom-notification assertion fails after the red + expectation is changed; green observes an initial `ToolCall`, subsequent + `ToolCallUpdate` values and no `_vtcode/taskLifecycle` notification. + Non-vacuity: classify child/background and pending/running/terminal cases; + include a seeded ID-changing mapper that the stable-ID property rejects. +- Obligation: `SESSION-ISOLATION`. List, cancel and output never reveal or + mutate a task outside the requested ACP session, including nested children. + Method: proptest-generated task forests plus behavioural requests over the + duplex ACP connection. + Rationale: ancestry and mixed-session arrangements are combinatorial and + deserve wider coverage than hand-picked examples. + Domain: acyclic forests up to 32 tasks across two to four session roots, + with foreground and background nodes and every status. + Artefact: pure ownership-selection property tests and handler-level ACP + tests. + Evidence: owned nested tasks are listed and manageable; foreign IDs return + the same not-found response as unknown IDs and remain unchanged. + Non-vacuity: require generators to produce direct, nested, foreign and + background tasks; a negative control that filters only direct children must + fail on a generated nested witness. +- Obligation: `USAGE-DELTA`. Each provider usage sample produces exactly one + non-negative Lody delta with the same input, output, cache-read and + cache-creation counts, keyed by the response model; absence produces no + notification. + Method: property tests for numeric conversion and ACP behavioural tests. + Rationale: generated boundaries exercise zero, ordinary and maximum `u32` + values without relying on live billing. + Domain: every field across `0..=u32::MAX`, provider usage present/absent and + model identifiers including Unicode. + Artefact: Lody usage-mapping tests and a duplex notification test. + Evidence: red observes no usage notification; green parses a canonical + `_lody/session/usage_update` whose per-model and aggregate deltas agree. + Non-vacuity: classify zero/non-zero/cache/no-cache samples; a swapped + prompt/completion negative control must fail. +- Obligation: `BASETEN-STREAM-USAGE`. A custom OpenAI-chat provider requests + usage only when its resolved profile opts in, and a Baseten-shaped terminal + usage chunk reaches the ACP Lody notification unchanged. + Method: request-shape unit tests, wiremock-style deterministic provider + playback and a vidai-mock physics test with streamed SSE chunks. + Rationale: unit tests prove configuration, playback proves parser contract, + and vidai-mock proves the assembled streaming path without external cost or + nondeterministic network timing. + Domain: provider default/profile override precedence, opt-in/out, content + chunks followed by terminal usage, and usage absent despite opt-in. + Artefact: custom-provider config/provider tests and the existing ACP provider + fixture environment extended with a Baseten usage scenario. + Evidence: request capture contains `stream_options.include_usage: true` only + for the opted-in profile; Lody receives one delta after the terminal chunk. + Non-vacuity: the opt-out request is asserted to omit `stream_options`; a + fixture with no terminal usage must produce no notification. + +External axioms are limited to ACP's documented extension routing and Lody +Core revision `23c792b9` accepting the stated version-1 shapes. Baseten's +documented final usage chunk is treated as a peer contract; the repository-owned +request construction and parsing are still exercised against faithful local +boundaries. No formal prover or model checker is proportionate: the introduced +invariants are finite serialization, ownership filtering and monotone event +mapping without concurrency shared-memory semantics beyond the existing +broadcast channel. + +## Plan of work + +EP-M1 adds a private `zed/agent/lody.rs` module containing the versioned +subagent-lifecycle capability and task-metadata DTOs, then changes +`task_lifecycle.rs` to retain its broadcast subscription, session filter and +lifetime while replacing the custom extension notification. The forwarder will +remember which stable task IDs it has emitted: +the first snapshot becomes `SessionUpdate::ToolCall`, and later snapshots +become `SessionUpdate::ToolCallUpdate`. Both carry `_meta.lody.task` and a +normal ACP status/title. The obsolete method and legacy-shaped message builders +will be deleted. `handle_initialize` will advertise only +`subagents: {version: 1, lifecycle: true}` when an actual controller exists. + +EP-M2 extends the private Lody module with typed management DTOs and registers +one `ExtRequest` dispatcher in `handlers::install_handlers`. +Exact `_lody/subagents/list`, `/cancel` and `/output` methods will parse typed +parameters, resolve the requested `SessionHandle` and controller, establish +task ownership, then call existing controller operations. List will combine +child and managed-background entries in deterministic ID order. Cancel will +use child `close` or background `force_cancel_background`. Output will render a +bounded tail (default 200 lines, hard maximum 10,000) from the existing child +snapshot or background preview. Unknown or foreign task IDs will share one +not-found response. The same commit will add `list`, `cancel` and `output` +negotiation flags, plus `tasks: {version: 1, background: true}` only when the +`background_subagents_enabled` capability is enabled. + +EP-M3 emits a canonical `_lody/session/usage_update` after each provider +response that contains normalized usage, including every intermediate +tool-loop response. `usage` and the matching `modelUsage[model]` entry are +per-response deltas; a response without usage emits no notification. The +custom-provider profile gains sparse `supports_stream_usage` opt-in with +resolved profile precedence, while native OpenAI request behaviour remains +unchanged. The existing stream decoder preserves terminal `choices: []` +usage. Nested Baseten cached/reasoning detail remains optional: cached tokens +are included only when represented reliably in normalized `Usage`, and +reasoning tokens are not fabricated. + +EP-M4 updates `docs/acp/ACP_INTEGRATION.md`, +`docs/acp/ACP_QUICK_REFERENCE.md`, the custom-provider configuration reference +and any generated schema fixture required by the established config workflow. +It then runs release gates, installs the user binary, updates this plan with +evidence, pushes the branch and opens a draft PR based on +`fix/acp-unresolved-tool-recovery`. + +Each milestone is test-first and ends with focused tests, full commit gates, a +commit and a user-level binary installation before the next milestone begins. + +## Milestones and plateaus + +- Identifier and outcome: EP-M1, standard task lifecycle visible to Lody and + generic ACP clients with truthful lifecycle-only negotiation. + Requirements and gaps: discharges USER-LIFECYCLE and advances + USER-SUBAGENTS. + Acceptance evidence: lifecycle duplex and property tests pass; custom method + is absent; initialize advertises lifecycle only when a controller exists. + Conformance check: existing event/filter/lifetime contracts remain intact, + and no management handler is advertised prematurely. + Recovery: revert the lifecycle commit; no persisted migration exists. + Remaining gaps: management and usage. + Compatibility decision: no compatibility layer for `_vtcode/taskLifecycle`; + it had no documented consumer and Lody ignored it. +- Identifier and outcome: EP-M2, session-scoped Lody subagent/background-task + management and truthful management/task negotiation. + Requirements and gaps: discharges USER-SUBAGENTS and USER-TASKS. + Acceptance evidence: list/cancel/output duplex tests and ownership property + tests pass. + Conformance check: no cross-session data, management flags match handlers, + and scheduled support remains omitted. + Recovery: revert handler registration and remove its advertised flags. + Remaining gaps: provider usage. + Compatibility decision: none; these are new versioned extension methods. +- Identifier and outcome: EP-M3, provider usage reaches Lody, including + explicitly opted-in Baseten streams. + Requirements and gaps: discharges USER-USAGE. + Acceptance evidence: configuration tests (4/4), custom-provider + wiremock playback (1/1), ACP usage and tool-loop tests (9/9), the official + ACP usage duplex test (1/1), and vidai-mock physics (1/1) pass; see the + EP-M3 artefact block below. + Conformance check: usage is canonical and additive, absent metrics stay + absent, and the proxy-safe default remains off. + Recovery: disable the profile flag or revert the usage commit. + Remaining gaps: documentation/publication only. + Compatibility decision: the new optional config field defaults off, so + existing custom-provider request bodies remain byte-for-byte compatible. +- Identifier and outcome: EP-M4, documented, gated, installed and published + stacked PR. + Requirements and gaps: closes documentation and delivery requirements. + Acceptance evidence: release gates, binary version/path, remote SHA, PR base + and draft state. + Conformance check: reconcile every discovery and trace link before marking + COMPLETE. + Recovery: use `gh stack push` after corrections; never force-push manually. + Remaining gaps: provider limits, compaction metadata, notices, final-answer + phase metadata and scheduled tasks remain independent follow-ups. + Compatibility decision: none. + +## Concrete steps + +Work from: + +```bash +cd /home/leynos/Projects/VTCode.worktrees/lody-task-lifecycle-negotiation +``` + +At each red/green/refactor stage, capture output under `/tmp` using the action, +project and branch naming convention. Use focused nextest filters during TDD; +never run `cargo test`. + +```bash +cargo nextest run -p vtcode-acp -E 'test(lody)' 2>&1 \ + | tee /tmp/test-vtcode-fix-acp-lody-task-lifecycle-negotiation.out +``` + +Run all full commit gates sequentially through the `scrutineer` subagent. It +must use repository Make/script entry points, shared Cargo caches and `/tmp` +logs. Do not run formatting, linting and tests in parallel. + +After every code commit, install and verify the user binary: + +```bash +cargo install --path . --locked --force 2>&1 \ + | tee /tmp/install-vtcode-fix-acp-lody-task-lifecycle-negotiation.out +readlink -f /home/leynos/.local/bin/vtcode +/home/leynos/.local/bin/vtcode --version +``` + +Before publication, inspect and submit the complete stack non-interactively: + +```bash +gh stack link 11 12 13 14 +gh stack view --json +``` + +If GitHub stacked PRs reject the link, preserve the branch and create an +ordinary draft PR with base `fix/acp-unresolved-tool-recovery`, then report the +stack-link limitation rather than changing the base. + +## Validation and acceptance + +Red-Green-Refactor evidence will be recorded in `Artefacts and notes` for every +milestone. The final branch is accepted only when: + +- initialization advertises exact, versioned and conditional Lody + capabilities; +- standard ACP plans from `task_tracker` remain unchanged; +- lifecycle produces stable standard tool calls with valid task metadata and + never emits `_vtcode/taskLifecycle`; +- list/cancel/output succeed for owned foreground and background tasks and + reject foreign IDs; +- usage deltas preserve all available normalized counts and absent usage emits + nothing; +- Baseten-shaped streaming usage is requested only under explicit profile + opt-in and reaches Lody through local playback/physics tests; +- `./scripts/check.sh` passes through `scrutineer` before every code commit and + once more at the final branch tip; +- the installed user binary resolves through `/home/leynos/.local/bin/vtcode`; +- the pushed local and remote SHA match; and +- the new draft PR targets `fix/acp-unresolved-tool-recovery` and is linked to + the stack containing PR #13. + +Performance acceptance is qualitative: the lifecycle forwarder remains one +broadcast task per session, usage conversion performs bounded map construction +per provider response, and management list/output work is bounded by the +existing controller task count and requested tail. No network call to Baseten's +aggregate usage API is permitted. + +Security acceptance is the `SESSION-ISOLATION` obligation plus existing ACP +permission behaviour remaining green. No management request may bypass the +controller's cancellation semantics or read an arbitrary transcript path. + +## Idempotence and recovery + +Focused tests, gates and binary installation are idempotent. Extension +notifications are observational and do not change durable session history in +VTCode. List and output are read-only; cancel is intentionally idempotent for a +terminal owned task and never automatically replays work. + +If a rebase or stack operation conflicts, stop, inspect both intents and use +`gh stack rebase`/`gh stack push`; do not use `git push --force`. If a test +fixture leaves a mock server running, terminate only the process started by the +current test script, never unrelated Rust or agent processes. + +## Artefacts and notes + +Initial evidence: + +```plaintext +Branch: fix/acp-lody-task-lifecycle-negotiation +Base: 80bcd6530c58e4f8b03d5d53e99781024d6f4f58 (PR #13) +Lody Core: 23c792b910a903b74601e346473827106f991715 +Lody client: dd241fd4108ba5de2f7e2d8d627713152d812a06 +``` + +EP-M1 Red-Green-Refactor evidence: + +```plaintext +RED: /tmp/red-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm1.out + E0425: lody_task_session_update was absent. +GREEN: /tmp/green-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm1.out + 5 passed: status mapping, stable-ID property, official ACP duplex and + capability present/absent tests. +``` + +EP-M2 Robust Core Expansion and Management evidence: + +```plaintext +RED: /tmp/red-vtcode-core-epm2.out +GREEN: /tmp/green-vtcode-core-epm2-final.out + 5/5 core tests passed, including owner persistence, legacy loading and + bounded output-tail properties. +RED: /tmp/red-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm2-acp.out +GREEN: /tmp/test-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm2-final.out + 6/6 ACP tests passed, including real duplex negotiation, owned + list/output/cancel and foreign-task indistinguishability from unknown. +GREEN: /tmp/rgr-core-owner-scope-green.out + Owner-scoped loading includes only the exact owner while the CLI remains + unscoped. +GREEN: /tmp/rgr-core-owner-persistence-green.out + Scoped persistence updates owned state without deleting foreign or + legacy records. +GREEN: /tmp/rgr-acp-owner-scope-green.out + A new ACP session does not restore another session's background record. +``` + +EP-M3 Provider Usage evidence: + +```plaintext +GREEN: /tmp/test-vtcode-config-fix-acp-lody-task-lifecycle-negotiation-epm3.out + 4/4 configuration parsing and profile-precedence tests passed. +GREEN: /tmp/test-vtcode-llm-wiremock-fix-acp-lody-task-lifecycle-negotiation-epm3.out + 1/1 custom-provider request-shape and terminal-usage playback test passed. +GREEN: /tmp/test-vtcode-acp-fix-acp-lody-task-lifecycle-negotiation-epm3-final.out + 9/9 ACP capability, usage-mapping, tool-loop and recovery tests passed. +GREEN: /tmp/test-vtcode-acp-usage-duplex-fix-acp-lody-task-lifecycle-negotiation-epm3-green.out + 1/1 official ACP extension-channel usage duplex test passed. +GREEN: /tmp/test-vtcode-llm-vidaimock-fix-acp-lody-task-lifecycle-negotiation-epm3.out + 1/1 Baseten-shaped terminal usage physics test passed. +FORMAT: /tmp/fmt-check-VTCode-fix-acp-lody-task-lifecycle-negotiation-epm3.out + Captured the EP-M3 formatting check and its rustfmt diagnostics; the + final release gate remains the authoritative clean-format check. +RED: /tmp/test-vtcode-acp-usage-duplex-fix-acp-lody-task-lifecycle-negotiation-epm3.out + The initial duplex assertion exposed ACP's normalized extension method + name (the leading underscore is stripped) and notification timing. + The official-channel green rerun above covers the corrected boundary. +GATE: /tmp/check-VTCode-fix-acp-lody-task-lifecycle-negotiation-epm3-2.out + Full release gate passed: 6588 tests passed and 16 were skipped, with + formatting, security, logging, governance, Clippy, build, PTY/TUI and + documentation checks green. +INSTALL: /tmp/install-vtcode-fix-acp-lody-task-lifecycle-negotiation-epm3.out + Commit b18a674dc installed successfully at user level; the existing + locked-yanked chacha20 0.10.1 warning was non-fatal. +SCHEMA: /tmp/generate-config-reference-VTCode-fix-acp-lody-task-lifecycle-negotiation-epm4.out + Exported 836 schema fields successfully; only the two feature-specific + reference rows are retained because the remaining generated diff was + unrelated pre-existing drift. +``` + +Baseten's documented streamed-usage request is: + +```json +{ + "stream": true, + "stream_options": { + "include_usage": true + } +} +``` + +`continuous_usage_stats` is model-specific prior art, not required for the +terminal delta this plan consumes. + +## Interfaces and dependencies + +Define private Serde DTOs in `crates/codegen/vtcode-acp/src/zed/agent/lody.rs` +for the version-1 capability map, task metadata, session usage update and three +management request/response shapes. The serialized field names must match Lody +Core revision `23c792b9` exactly. These types are an adapter boundary, not a new +public crate API. + +`advertised_agent_capabilities` will accept the agent/controller state needed +to build `acp::AgentCapabilities.meta`. `task_lifecycle` will construct +`acp::ToolCall` and `acp::ToolCallUpdate` values and send them through the +existing standard session-notification connection. + +The extension dispatcher will accept `acp::ExtRequest` and respond with +`acp::ExtResponse`. It will call only existing `SubagentController` methods: +`status_entries`, `background_status_entries`, `close`, +`force_cancel_background`, `snapshot_for_thread` and `background_snapshot`. + +Add `supports_stream_usage: Option` to +`vtcode_config::core::CustomProviderProfileConfig` and +`ResolvedCustomProviderProfile`. The custom OpenAI-chat provider will consult +the resolved profile for the request model before adding +`stream_options.include_usage`; native OpenAI keeps its existing behaviour. + +No new library dependency or deployed persistent-format migration is planned. + +--- + +Revision note (2026-08-28): EP-M4 is complete. Canonical per-response usage +updates, custom-provider streamed-usage opt-in, wiremock playback, +vidai-mock physics and official ACP duplex coverage are recorded above. The +complete release gate and user installation passed, and draft PR #14 is the +fourth layer of stack #16. diff --git a/docs/guides/zed-acp.md b/docs/guides/zed-acp.md index aefbaad01..156d6a393 100644 --- a/docs/guides/zed-acp.md +++ b/docs/guides/zed-acp.md @@ -120,9 +120,34 @@ fails the tool safely. `SessionEnd` is emitted when the ACP connection actually closes (with a bounded shutdown wait), not after every prompt. Notification hooks are not invented for -ACP protocol messages; they run only for real VT Code notification events. The -current ACP subagent controller does not expose child lifecycle callbacks, so -ACP does not currently emit `SubagentStart` or `SubagentStop`. +ACP protocol messages; they run only for real VT Code notification events. + +### ACP capability negotiation and Lody extensions + +During `initialize`, VT Code always advertises the Lody usage capability at +version 1 (`_meta.lody.usage`). If the session has a subagent controller, it +also advertises version-1 subagent lifecycle and management capabilities: +`lifecycle`, `list`, `cancel`, and `output`. The `_meta.lody.tasks.background` +capability is advertised only when background subagents are enabled. Clients +should inspect the handshake before using any conditional extension. + +Subagent and background-process lifecycle is represented with standard ACP +`session/update` tool calls and tool-call updates. Their standard title, kind, +content, and status fields are accompanied by `_meta.lody.task`, which carries +the VT Code/Lody task snapshot and its additional identifiers, actor, timing, +summary, or error fields. VT Code does not require non-standard +`SubagentStart` or `SubagentStop` update types. + +When subagent management is advertised, the following Lody requests are +available: `_lody/subagents/list`, `_lody/subagents/cancel`, and +`_lody/subagents/output`. They list owned tasks, request cancellation, and +return a bounded output tail respectively. + +Provider usage is sent as the `_lody/session/usage_update` extension +notification. Its parameters contain `sessionId`, a per-response `usage` +delta (input, output, and cache token counts), and `modelUsage` keyed by model +name. No usage notification is sent when the provider response contains no +usage data. MCP connections are scoped to the session that declares them. A subagent does not implicitly inherit its parent's MCP connections; declare the required MCP servers in the subagent's own configuration. @@ -414,9 +439,12 @@ and `[history]` settings do not control ACP audit output. final provider response is sent as an `AgentThoughtChunk` as well. Debug logs distinguish `Sending provider reasoning to ACP client` from `Provider response did not include exposed reasoning for ACP`; they record metadata only and never the reasoning content. -- **Plan tracking** – Every prompt emits an ACP plan describing analysis, optional context gathering, - and final response drafting. VT Code updates each entry as it progresses so Zed can visualise the - bridge's workflow in real time. +- **Plan tracking** – A successful `task_tracker` tool result containing a + checklist is rendered as the standard ACP `Plan` update, with the tracker + details retained in `meta.vtcode.taskTracker`. Blocked items are marked with + a `[blocked]` tag. On `session/load` or `session/resume`, VT Code replays a + persisted tracker plan when one exists; ordinary prompts do not receive a + synthetic plan. - **Tool execution** – The `read_file` tool forwards to Zed when enabled. The `list_files` tool uses VT Code's local workspace access, mirroring the CLI experience. When the model lacks function calling or the tool toggle is disabled, VT Code surfaces a reasoning notice and skips the diff --git a/docs/providers/PROVIDER_GUIDES.md b/docs/providers/PROVIDER_GUIDES.md index da608c45d..3eac84553 100644 --- a/docs/providers/PROVIDER_GUIDES.md +++ b/docs/providers/PROVIDER_GUIDES.md @@ -51,9 +51,9 @@ New fields and model profiles - `api_format` (provider-level): an optional hint describing the provider's preferred API shape. Accepted values are `auto`, `openai-chat`, `openai-responses`, and `anthropic-messages`. When omitted VT Code preserves legacy behavior and will attempt to autodetect; an explicit value is honored and VT Code will not silently fall back to a different format. -- Per-provider capability defaults: custom providers may set fields such as `supports_tools`, `supports_vision`, or `supports_structured_output` to conservative values used when explicit model metadata is unavailable. +- Per-provider capability defaults: custom providers may set fields such as `supports_tools`, `supports_vision`, or `supports_structured_output` to conservative values used when explicit model metadata is unavailable. `supports_stream_usage` is optional and defaults to `false`; set it to `true` only for an OpenAI-chat endpoint that supports `stream_options.include_usage = true` and returns usage in the terminal streamed chunk. This setting applies to custom providers; native OpenAI behaviour is unchanged. -- Per-model profiles: define sparse runtime overrides for specific model identifiers under `custom_providers.profiles.""`. Profiles do not add models to the picker — they only tweak runtime defaults and capabilities for an existing model identifier. See the Configuration guide for examples and precedence rules. +- Per-model profiles: define sparse runtime overrides for specific model identifiers under `custom_providers.profiles.""`. Profiles do not add models to the picker — they only tweak runtime defaults and capabilities for an existing model identifier. A profile's `supports_stream_usage` value overrides the provider-level value. See the Configuration guide for examples and precedence rules. Worked examples: [Atlas Cloud](./atlascloud.md) and [OmniRoute](./omniroute.md). See the [Configuration guide](../config/config.md#custom_providers) for full details. diff --git a/src/agent/runloop/unified/session_setup/ui/tests.rs b/src/agent/runloop/unified/session_setup/ui/tests.rs index 17fcdc06e..23d2600a8 100644 --- a/src/agent/runloop/unified/session_setup/ui/tests.rs +++ b/src/agent/runloop/unified/session_setup/ui/tests.rs @@ -151,6 +151,7 @@ fn apply_persistent_memory_header_guide_sets_badge_and_highlight() { fn background_local_agent_visibility_hides_stopped_entries() { let entry = vtcode_core::subagents::BackgroundSubprocessEntry { id: "background-default".to_string(), + owner_session_id: None, session_id: "session-456".to_string(), exec_session_id: String::new(), agent_name: "default".to_string(), @@ -260,6 +261,7 @@ fn delegated_local_agent_preview_uses_failure_message() { fn background_local_agent_preview_uses_status_placeholder() { let entry = vtcode_core::subagents::BackgroundSubprocessEntry { id: "background-default".to_string(), + owner_session_id: None, session_id: "session-456".to_string(), exec_session_id: String::new(), agent_name: "default".to_string(), diff --git a/src/agent/runloop/unified/turn/session/slash_commands/agents/tests.rs b/src/agent/runloop/unified/turn/session/slash_commands/agents/tests.rs index b3ab893dd..b29069f89 100644 --- a/src/agent/runloop/unified/turn/session/slash_commands/agents/tests.rs +++ b/src/agent/runloop/unified/turn/session/slash_commands/agents/tests.rs @@ -134,6 +134,7 @@ fn summarize_thread_event_preview_uses_latest_live_updates() { fn background_subprocess_summary_reports_waiting_state_without_summary() { let entry = BackgroundSubprocessEntry { id: "background-rust-engineer".to_string(), + owner_session_id: None, session_id: "session-123".to_string(), exec_session_id: "exec-session-123".to_string(), agent_name: "rust-engineer".to_string(), diff --git a/tests/fixtures/vidaimock/templates/arli/physics_stream_stop.j2 b/tests/fixtures/vidaimock/templates/arli/physics_stream_stop.j2 index 69cadd737..34e8c0886 100644 --- a/tests/fixtures/vidaimock/templates/arli/physics_stream_stop.j2 +++ b/tests/fixtures/vidaimock/templates/arli/physics_stream_stop.j2 @@ -1,3 +1,5 @@ +{"id":"chatcmpl-arli-physics","object":"chat.completion.chunk","created":0,"model":"DeepSeek-V4-Flash-0731","choices":[],"usage":{"prompt_tokens":13,"completion_tokens":5,"total_tokens":18}} + {"id":"chatcmpl-arli-physics","object":"chat.completion.chunk","created":0,"model":"DeepSeek-V4-Flash-0731","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: [DONE] diff --git a/vtcode.toml.example b/vtcode.toml.example index 2f2bb73b8..540577f3e 100644 --- a/vtcode.toml.example +++ b/vtcode.toml.example @@ -27,6 +27,7 @@ reasoning_effort = "low" # model = "deepseek-ai/deepseek-v4-flash" # context_window = 256000 # provider capability in tokens # api_format = "auto" # hint: auto|openai-chat|openai-responses|anthropic-messages +# supports_stream_usage = true # opt in only when the endpoint emits terminal stream usage # Optional provider-level conservative defaults (applied when model metadata is missing): # supports_tools = true # supports_vision = false