diff --git a/AGENTS.md b/AGENTS.md index 52d68d42361..d5a9dd47e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,13 @@ Codeg(Code Generation)是一个多智能体编码工作台,它将多个智 ## 代码检查与测试(任务完成后进行必要的检查) +### 测试运行方式(必须遵守) + +- 功能验证只使用 Web 服务(`codeg-server`)及其浏览器页面,不使用 Windows 桌面客户端作为测试对象。 +- 永远不编译、构建或打包桌面端,也不启动桌面客户端进行测试。禁止启用 `tauri-runtime`,禁止运行 `pnpm tauri dev`、`pnpm tauri build` 或默认开启桌面 feature 的 Cargo 命令。 +- 所有后端检查、测试和构建必须显式指定 `--no-default-features`;按需启用 `server-bin` 或 `mcp-bin`。 +- 验证界面时启动 WebServer 并访问其 HTTP 地址。需要隔离数据时使用独立 `CODEG_DATA_DIR`,不得通过测试修改正在使用的桌面数据。 + ### 前端 ```bash @@ -31,11 +38,6 @@ pnpm build # 静态导出构建 ### 后端 Rust(在 `src-tauri/` 目录下执行) ```bash -# 桌面模式(默认 feature) -cargo check -cargo test --features test-utils -cargo clippy --all-targets --features test-utils -- -D warnings - # 服务器模式 cargo check --no-default-features --features server-bin --bin codeg-server cargo test --no-default-features --features server-bin --bin codeg-server --lib @@ -47,7 +49,7 @@ cargo clippy --no-default-features --features mcp-bin --bin codeg-mcp -- -D warn # 解析器快照评审(输出变化时) cargo insta review -INSTA_UPDATE=auto cargo test --features test-utils # 自动写新 .snap +INSTA_UPDATE=auto cargo test --no-default-features --features server-bin --lib ``` ## 架构 diff --git a/docs/development/subsession-messages.md b/docs/development/subsession-messages.md new file mode 100644 index 00000000000..e4a76be7543 --- /dev/null +++ b/docs/development/subsession-messages.md @@ -0,0 +1,98 @@ +# 父子会话多轮沟通 + +父会话先用 `delegate_to_agent` 创建子会话,再用返回的 +`child_conversation_id` 向同一个子会话继续发送消息。每次发送都有独立的 +`message_id`,子会话的 ID、工作目录和代理上下文保持不变。 + +## 发送和管理 + +排队发送是默认行为,当前轮结束后按顺序执行: + +```json +{"child_conversation_id": 42, "message": "完成后补充接口测试", "delivery": "queue"} +``` + +以上参数传给 `send_to_subsession`。需要马上调整时,使用 +`"delivery": "immediate"`:支持原生 steering 的连接直接接收指令;否则取消 +当前轮,等待原 ACP 请求、旧通知和生命周期持久化处理完成,再发送新一轮。 +取消确认超过 30 秒会暂停续发并返回错误,不会并发执行两个 prompt,也不会 +暗中新建空白会话。 + +通过 `manage_subsession_messages` 查询消息及结果: + +```json +{"child_conversation_id": 42, "action": "list"} +``` + +修改或取消仍在排队的消息: + +```json +{"child_conversation_id": 42, "action": "update", "message_id": "返回的消息 ID", "message": "完成后改为补充异常路径测试"} +{"child_conversation_id": 42, "action": "cancel", "message_id": "返回的消息 ID"} +``` + +编辑、取消与出队竞争时,以数据库条件更新的结果为准。已经开始投递或执行 +的消息返回 `not_queued`,不会被队列编辑接口覆盖。 + +## 状态和恢复 + +- `queued`:等待执行,可编辑或取消。 +- `dispatching`:立即消息已被认领,正在 steering 或停止原执行轮。 +- `running`:消息作为新一轮 prompt 执行。 +- `delivered`:指令已通过 steering 投递,不表示代理已完成处理。 +- `completed`、`failed`、`canceled`:该轮的结果。 +- `interrupted`:认领消息的进程已退出;不会自动重放已执行过的操作。 + +消息、顺序和结果保存在 SQLite。刷新浏览器不会丢失队列;父会话重新连接后 +查询或发送消息,会重新检查待执行队列。代理连接已回收时,必须成功加载原 +代理会话才能继续;无法续接时明确拒绝。 + +原 `task_id` 仍用于首次委托和中断恢复。后续消息通过上述管理工具读取结果, +不使用 `resume_delegation` 携带新指令。普通子会话回复仍由父会话查询;需要主动 +汇报时,子会话显式调用下面的 `send_to_parent`。 + +`manage_subsession_messages(action=list)` 只列出 `send_to_subsession` 创建的 +后续消息。首次委派的结果仍通过 `get_delegation_status` 查询。 + +## 子会话主动汇报 + +子会话调用 `send_to_parent`,只传消息内容: + +```json +{"message": "接口已完成,测试发现一个需要你决定的问题:是否保留旧参数?"} +``` + +父会话由当前已认证的子会话和数据库归属关系确定,不能指定其他会话。普通 +会话、已删除的子会话或已删除的父会话不能使用此工具。 + +工具立即返回消息 ID 和 `queued` 回执,不等待父会话回答。父会话忙时,消息 +保持排队;父会话当前轮结束且生命周期处理完成后,系统向原父会话发送带 +来源标记的新一轮消息,自动唤醒父代理处理。多个子会话的汇报按入队顺序 +串行投递,不中断父会话当前工作。父会话可以通过 `send_to_subsession` 回答。 + +汇报记录保存在 `parent_reply` 收件箱中,包含子会话 ID、消息 ID、状态及 +父会话处理结果。父会话连接关闭时保留排队消息;可以从仍活跃的子会话严格 +续接父代理,也可以等待父会话重新打开。无法确认运行时归属时不接管原会话。 +进程退出前已经开始执行的汇报恢复为 `interrupted`,不会自动重放。 + +只有显式汇报才会唤醒父会话。父子代理应在有进展、结果或问题时发送,避免 +互相发送无意义的确认消息,反复启动对方。 + +聊天输入框上方的现有排队区域同时显示手工消息和后端待执行消息。父子会话 +消息带来源会话名称和“排队中”状态;开始执行后从排队区域移除。后端消息由 +`list_pending_session_messages` 读取 SQLite,在当前会话打开时加载、每秒更新, +刷新页面后恢复。前端只展示这些条目,不参与投递,也不提供手工队列的拖动、 +编辑、取消或立即插入按钮。 + +## 验证 + +只使用 `codeg-server` 的浏览器页面、HTTP/WebSocket 服务及 MCP 伴生进程 +验证。遵守 `AGENTS.md`,禁止编译或使用桌面端作为测试对象。 + +浏览器验收包含两类代理:可控 ACP 测试代理用于排队、编辑、取消和立即发送; +真实 Claude Code ACP 代理用于验证模型实际调用工具、完成首次委派,再向 +同一子会话发送后续消息并查询结果,确认跨轮上下文保留。 + +主动汇报使用真实 Claude Code ACP 代理在 Web 页面验证:子会话调用 +`send_to_parent` 后,空闲父会话自动开启新轮处理;父会话正在执行时,报告 +先入队,当前轮完成后自动投递。两条报告均保存了父会话的处理结果。 diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index e0717356fdb..4c511e7fbff 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2,7 +2,10 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; +use crate::acp::agent_process::AcpAgent; +use crate::acp::agent_session::AgentSession; use agent_client_protocol::schema::v1::{ AuthMethod, AuthenticateRequest, BlobResourceContents, CancelNotification, ClientCapabilities, ClientSessionCapabilities, @@ -36,8 +39,6 @@ use agent_client_protocol::{ HandleDispatchFrom, Handled, JsonRpcRequest, RequestCancellation, Responder, Role, UntypedMessage, }; -use crate::acp::agent_process::AcpAgent; -use crate::acp::agent_session::AgentSession; use tokio::sync::{mpsc, oneshot, RwLock}; use tokio_util::sync::CancellationToken; @@ -311,7 +312,10 @@ pub(crate) fn kiro_trust_all_tools_enabled(value: Option<&str>) -> bool { /// Gated on the explicit `CURSOR_AUTH_MODE` knob (written by the Cursor panel), /// so legacy rows and operator-provided container env are left untouched. In /// custom mode the credentials are present and non-empty, so nothing is cleared. -fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { +fn apply_cursor_env_policy( + merged: &mut Vec<(String, String)>, + runtime_env: &BTreeMap, +) { if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) != Some("subscription") { return; } @@ -335,7 +339,10 @@ fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTr /// (`acp::agent_process`) to `env_remove` the inherited var. In api_key mode the key is /// present and non-empty, so nothing is cleared; legacy/no-mode rows are left /// untouched. -fn apply_grok_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { +fn apply_grok_env_policy( + merged: &mut Vec<(String, String)>, + runtime_env: &BTreeMap, +) { if runtime_env.get("GROK_AUTH_MODE").map(String::as_str) != Some("subscription") { return; } @@ -867,9 +874,9 @@ pub fn antigravity_effective_auth_type( .filter(|value| !value.is_empty()) // The server resolves the legacy spelling before it tests membership, // so a caller matching on canonical ids would otherwise miss it. - .map(|value| AntigravityAuthType::Declared( - canonical_antigravity_auth_method(value).to_string(), - )) + .map(|value| { + AntigravityAuthType::Declared(canonical_antigravity_auth_method(value).to_string()) + }) .unwrap_or(AntigravityAuthType::Absent) } @@ -1365,6 +1372,10 @@ pub enum ConnectionCommand { reply: Option>, }, Cancel, + /// Acknowledges actual completion of the canceled ACP prompt request. + AwaitPromptQuiescence { + reply: oneshot::Sender>, + }, RespondPermission { request_id: String, option_id: String, @@ -1978,9 +1989,7 @@ async fn record_turn_end( /// boolean — see `config_option_already_holds`). /// /// Used to carry a session's selectors across a fork. -fn current_config_option_values( - opts: &[SessionConfigOptionInfo], -) -> BTreeMap { +fn current_config_option_values(opts: &[SessionConfigOptionInfo]) -> BTreeMap { opts.iter() .map(|opt| { let value = match &opt.kind { @@ -2585,9 +2594,8 @@ async fn build_agent( let agent_name = meta.name.to_string(); let tail = Arc::clone(stderr_tail); Ok( - AcpAgent::new(agent_client_protocol::schema::v1::McpServer::Stdio(server)).with_debug( - agent_debug_callback(agent_name, tail, stdio_debug_enabled), - ), + AcpAgent::new(agent_client_protocol::schema::v1::McpServer::Stdio(server)) + .with_debug(agent_debug_callback(agent_name, tail, stdio_debug_enabled)), ) } AgentDistribution::Uvx { @@ -2665,14 +2673,15 @@ async fn build_agent( // by the frontend catch block in // `src/contexts/acp-connections-context.tsx` to surface a // localized install prompt. Do not change the wording. - let binary_path = - crate::commands::acp::resolve_system_agent_binary_for(agent_type, cmd) - .ok_or_else(|| { - AcpError::SdkNotInstalled(format!( - "{} is not installed. Please install it in Agent Settings.", - meta.name - )) - })?; + let binary_path = crate::commands::acp::resolve_system_agent_binary_for( + agent_type, cmd, + ) + .ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; let binary_str = binary_path.to_string_lossy().to_string(); let mut cmd_args: Vec = args.iter().map(|a| (*a).to_string()).collect(); // Kiro's permission mode: `acp --trust-all-tools` auto-approves @@ -2718,9 +2727,8 @@ async fn build_agent( let agent_name = meta.name.to_string(); let tail = Arc::clone(stderr_tail); Ok( - AcpAgent::new(agent_client_protocol::schema::v1::McpServer::Stdio(server)).with_debug( - agent_debug_callback(agent_name, tail, stdio_debug_enabled), - ), + AcpAgent::new(agent_client_protocol::schema::v1::McpServer::Stdio(server)) + .with_debug(agent_debug_callback(agent_name, tail, stdio_debug_enabled)), ) } }?; @@ -2797,6 +2805,10 @@ pub async fn spawn_agent_connection( None, // folder_id 由后续 prompt handler 在首次 send 时绑定 (Phase 2) ); + initial_state.require_session_resume = runtime_env + .get("CODEG_ACP_REQUIRE_RESUME") + .map(String::as_str) + == Some("1"); // Install the SessionStarted dedup signal BEFORE wrapping into Arc so the // first event (StatusChanged{Connecting} below) doesn't race with the // installer. The receiver is returned to `spawn_agent`, which holds the @@ -3041,71 +3053,71 @@ pub async fn spawn_agent_connection( ) .await; - // Revoke the per-launch token + cascade cancel any still-pending - // delegations AND questions owned by this parent connection. All are - // best-effort: a missing token entry is a no-op, and both - // `cancel_by_parent` calls are safe on an empty pending map. - if let Some(inj) = delegation_for_cleanup { - let token = { - let snap = state_clone.read().await; - snap.delegation_token.clone() - }; - if let Some(tok) = token { - inj.tokens.revoke(&tok).await; - } - inj.broker.cancel_by_parent(&conn_id).await; - // Reclaim a parked `ask_user_question` instead of waiting for the - // companion's ask socket to close (which a reparented/hard-killed - // agent may never do); the dropped sender declines the tool cleanly. - inj.questions.cancel_questions_by_parent(&conn_id).await; - // Likewise reclaim a parked Grok `exit_plan_mode` approval; the - // dropped sender replies disconnect so grok keeps plan mode active. - inj.plan_approvals - .cancel_plan_approvals_by_parent(&conn_id) - .await; - } + // Revoke the per-launch token + cascade cancel any still-pending + // delegations AND questions owned by this parent connection. All are + // best-effort: a missing token entry is a no-op, and both + // `cancel_by_parent` calls are safe on an empty pending map. + if let Some(inj) = delegation_for_cleanup { + let token = { + let snap = state_clone.read().await; + snap.delegation_token.clone() + }; + if let Some(tok) = token { + inj.tokens.revoke(&tok).await; + } + inj.broker.cancel_by_parent(&conn_id).await; + // Reclaim a parked `ask_user_question` instead of waiting for the + // companion's ask socket to close (which a reparented/hard-killed + // agent may never do); the dropped sender declines the tool cleanly. + inj.questions.cancel_questions_by_parent(&conn_id).await; + // Likewise reclaim a parked Grok `exit_plan_mode` approval; the + // dropped sender replies disconnect so grok keeps plan mode active. + inj.plan_approvals + .cancel_plan_approvals_by_parent(&conn_id) + .await; + } - if let Err(e) = result { - let code = e.code().map(String::from); - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::Error { - message: e.to_string(), - agent_type: agent_type.to_string(), - code, - details: None, - // The only genuinely terminal emit site: `run_connection` - // is unwinding and the next event is `Disconnected`. - // The lifecycle worker uses this flag to decide whether - // to flip the conversation row to Cancelled and to - // buffer the detail for the broker's cancel reason. - terminal: true, - }, - ) - .await; - // Drive the state machine through `Error` before `Disconnected` - // so the frontend's error-handling effect (cancelled-on-error) - // engages — without this hop the connection would jump straight - // to Disconnected and look like a clean shutdown. - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::StatusChanged { - status: ConnectionStatus::Error, - }, - ) - .await; - } + if let Err(e) = result { + let code = e.code().map(String::from); + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::Error { + message: e.to_string(), + agent_type: agent_type.to_string(), + code, + details: None, + // The only genuinely terminal emit site: `run_connection` + // is unwinding and the next event is `Disconnected`. + // The lifecycle worker uses this flag to decide whether + // to flip the conversation row to Cancelled and to + // buffer the detail for the broker's cancel reason. + terminal: true, + }, + ) + .await; + // Drive the state machine through `Error` before `Disconnected` + // so the frontend's error-handling effect (cancelled-on-error) + // engages — without this hop the connection would jump straight + // to Disconnected and look like a clean shutdown. + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Error, + }, + ) + .await; + } - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::StatusChanged { - status: ConnectionStatus::Disconnected, - }, - ) - .await; + emit_with_state( + &state_clone, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Disconnected, + }, + ) + .await; // Connection loop ended; `block_on` returns and `_cleanup` // (bound at the top of the thread body) drops next, removing // the manager map entry — same as on a panic unwind. @@ -5536,7 +5548,9 @@ async fn execute_kiro_command( .get("message") .and_then(serde_json::Value::as_str) .unwrap_or("Kiro rejected the command"); - return Err(agent_client_protocol::util::internal_error(message.to_string())); + return Err(agent_client_protocol::util::internal_error( + message.to_string(), + )); } Ok(()) } @@ -5566,7 +5580,13 @@ async fn set_kiro_effort( session_id: &SessionId, level: &str, ) -> Result<(), agent_client_protocol::Error> { - execute_kiro_command(cx, session_id, "effort", serde_json::json!({ "level": level })).await + execute_kiro_command( + cx, + session_id, + "effort", + serde_json::json!({ "level": level }), + ) + .await } /// Point one of the selectors in `opts` at `value` (a no-op when absent). @@ -5677,7 +5697,9 @@ async fn apply_kiro_preferred_options( set_select_current(opts, KIRO_MODEL_OPTION_ID, pref); model_switched = true; } - Err(e) => tracing::error!("[ACP] failed to apply preferred kiro model '{pref}': {e}"), + Err(e) => { + tracing::error!("[ACP] failed to apply preferred kiro model '{pref}': {e}") + } } } } @@ -5695,7 +5717,9 @@ async fn apply_kiro_preferred_options( ) .await; } - Err(e) => tracing::error!("[ACP] failed to apply preferred kiro agent '{pref}': {e}"), + Err(e) => { + tracing::error!("[ACP] failed to apply preferred kiro agent '{pref}': {e}") + } } } } @@ -5732,9 +5756,9 @@ async fn apply_kiro_preferred_options( } else { state.read().await.kiro_reasoning.clone() }; - let applicable = known.as_ref().is_none_or(|r| { - r.levels.contains(pref) && r.current.as_deref() != Some(pref.as_str()) - }); + let applicable = known + .as_ref() + .is_none_or(|r| r.levels.contains(pref) && r.current.as_deref() != Some(pref.as_str())); if applicable { match set_kiro_effort(cx, session_id, pref).await { Ok(()) => { @@ -5742,7 +5766,9 @@ async fn apply_kiro_preferred_options( r.current = Some(pref.clone()); } } - Err(e) => tracing::error!("[ACP] failed to apply preferred kiro effort '{pref}': {e}"), + Err(e) => { + tracing::error!("[ACP] failed to apply preferred kiro effort '{pref}': {e}") + } } } } @@ -5770,7 +5796,10 @@ async fn apply_kiro_metadata( /// selector, thinking then effort (the order of Kiro's own `/model` panel) — /// replacing any already there. The ones `reasoning` does not offer are /// removed: which exist depends on the model. -fn place_kiro_reasoning_selectors(opts: &mut Vec, reasoning: &KiroReasoning) { +fn place_kiro_reasoning_selectors( + opts: &mut Vec, + reasoning: &KiroReasoning, +) { opts.retain(|o| o.id != KIRO_THINKING_OPTION_ID && o.id != KIRO_EFFORT_OPTION_ID); let at = opts .iter() @@ -5778,9 +5807,12 @@ fn place_kiro_reasoning_selectors(opts: &mut Vec, reaso .map_or(opts.len(), |pos| pos + 1); opts.splice( at..at, - [kiro_thinking_option(reasoning), kiro_effort_option(reasoning)] - .into_iter() - .flatten(), + [ + kiro_thinking_option(reasoning), + kiro_effort_option(reasoning), + ] + .into_iter() + .flatten(), ); } @@ -6303,9 +6335,8 @@ async fn send_steer_request( blocks: &[PromptInputBlock], ) -> Result { let params = build_steer_params(session_id.0.as_ref(), blocks); - let untyped_req = UntypedMessage::new("_session/steering", params).map_err(|e| { - AcpError::protocol(format!("Failed to build steering request: {e}")) - })?; + let untyped_req = UntypedMessage::new("_session/steering", params) + .map_err(|e| AcpError::protocol(format!("Failed to build steering request: {e}")))?; let raw = cx .send_request_to(Agent, untyped_req) .block_task() @@ -6348,9 +6379,8 @@ async fn send_stop_async_task_request( "sessionId": session_id.0.as_ref(), "asyncTaskId": task_id, }); - let untyped_req = UntypedMessage::new("_session/async_task/stop", params).map_err(|e| { - AcpError::protocol(format!("Failed to build async task stop request: {e}")) - })?; + let untyped_req = UntypedMessage::new("_session/async_task/stop", params) + .map_err(|e| AcpError::protocol(format!("Failed to build async task stop request: {e}")))?; let raw = cx .send_request_to(Agent, untyped_req) .block_task() @@ -7184,8 +7214,7 @@ fn build_client_capabilities( client_capabilities = client_capabilities.terminal(true).fs( FileSystemCapabilities::new() .read_text_file(true) - .write_text_file(true), - ); + .write_text_file(true)); } // Form elicitation is advertised only to agents that are KNOWN to send // spec-conformant `elicitation/create` forms `classify_elicitation` can @@ -7244,7 +7273,10 @@ fn build_client_capabilities( // convention is to advertise nothing an agent hasn't implemented. let mut meta = serde_json::Map::new(); if agent_type == AgentType::ClaudeCode { - meta.insert("subagent-transcript".to_string(), serde_json::Value::Bool(true)); + meta.insert( + "subagent-transcript".to_string(), + serde_json::Value::Bool(true), + ); } // claude-agent-acp 0.73.0 added "asyncTasks", and codex-acp 1.10.0 joined // it, so BOTH are advertised. It publishes the lifecycle of an agent's @@ -7468,7 +7500,12 @@ fn build_client_capabilities( // only. See (iiii) of the claude entry in `registry.rs`. if matches!(agent_type, AgentType::ClaudeCode | AgentType::Codex) { let capabilities: &[&str] = if agent_type == AgentType::Codex { - &["sessionFailure", "asyncTasks", "recommendedValue", "diffPatch"] + &[ + "sessionFailure", + "asyncTasks", + "recommendedValue", + "diffPatch", + ] } else { &["sessionFailure", "asyncTasks", "recommendedValue"] }; @@ -7584,9 +7621,8 @@ fn build_client_capabilities( // A sub-agent's own compaction rides `opencode/session/child_update` and is // dropped (`opencode_child_update_as_session_update`). if agent_type == AgentType::OpenCode { - client_capabilities = client_capabilities.session( - ClientSessionCapabilities::new().compaction(CompactionCapabilities::new()), - ); + client_capabilities = client_capabilities + .session(ClientSessionCapabilities::new().compaction(CompactionCapabilities::new())); } // Two more client capabilities are deliberately NOT advertised, because each // would change a surface codeg reads today with nothing ready to take it @@ -8495,7 +8531,10 @@ fn canonical_spec_to_mcp_server(name: &str, spec: &serde_json::Value) -> Result< if let Some(env_obj) = obj.get("env").and_then(serde_json::Value::as_object) { let env_vars: Vec = env_obj .iter() - .filter_map(|(k, v)| v.as_str().map(|s| agent_client_protocol::schema::v1::EnvVariable::new(k, s))) + .filter_map(|(k, v)| { + v.as_str() + .map(|s| agent_client_protocol::schema::v1::EnvVariable::new(k, s)) + }) .collect(); if !env_vars.is_empty() { server = server.env(env_vars); @@ -9851,6 +9890,9 @@ async fn run_connection( .await } Err(e) => { + if state.read().await.require_session_resume { + return Err(e); + } // session/load failed. Classify it: an unrecoverable // historical session — the agent has no record of it // (ResourceNotFound, -32002) or the agent process/session @@ -10442,7 +10484,10 @@ fn codeg_submit_auto_allow_option(req: &RequestPermissionRequest, given: bool) - if !given { return None; } - codeg_tool_auto_allow_option(req, crate::cli_run::output_schema::is_codeg_submit_tool_name) + codeg_tool_auto_allow_option( + req, + crate::cli_run::output_schema::is_codeg_submit_tool_name, + ) } /// Answer a permission request that only gates `submit_result`, on the one @@ -10661,9 +10706,9 @@ async fn try_bridge_pi_select_ask( ) .await; let outcome = match option_id { - Some(option_id) => { - RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)) - } + Some(option_id) => RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + ), None => RequestPermissionOutcome::Cancelled, }; let _ = responder.respond(RequestPermissionResponse::new(outcome)); @@ -10796,8 +10841,14 @@ async fn try_bridge_antigravity_ask( }; // Before answering: Antigravity's closing frame for this id // follows the answer and must find the card recorded. - record_antigravity_answer_card(&state, &emitter, tool_call_id, &ask.spec, &recorded) - .await; + record_antigravity_answer_card( + &state, + &emitter, + tool_call_id, + &ask.spec, + &recorded, + ) + .await; let response_outcome = match option_id { Some(id) => { @@ -10945,24 +10996,30 @@ async fn handle_cursor_ask_question( // that apart from "this host never showed it" will proceed as if the user // had a say. The reason text is structural — never any of the payload. let Some((questions, ask_cfg)) = access else { - let _ = responder.respond(crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( - "the host's question bridge is unavailable; the user was not asked", - )); + let _ = responder.respond( + crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( + "the host's question bridge is unavailable; the user was not asked", + ), + ); return; }; if !ask_cfg.is_enabled().await { - let _ = responder.respond(crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( - "the host's interactive question card is disabled; the user was not asked", - )); + let _ = responder.respond( + crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( + "the host's interactive question card is disabled; the user was not asked", + ), + ); return; } let parsed = match crate::acp::cursor_ext::parse_cursor_ask_questions(&req.0) { Ok(parsed) => parsed, Err(e) => { tracing::warn!("[cursor ask] rejecting malformed ext request: {e}"); - let _ = responder.respond(crate::acp::cursor_ext::cursor_ask_skip_response_with_reason( - &format!("the host could not render this ask: {e}"), - )); + let _ = responder.respond( + crate::acp::cursor_ext::cursor_ask_skip_response_with_reason(&format!( + "the host could not render this ask: {e}" + )), + ); return; } }; @@ -11095,9 +11152,7 @@ fn handle_cursor_generate_image( "[cursor image] cursor/generate_image keys={:?}", crate::acp::cursor_ext::param_keys(&req.0) ); - let _ = responder.respond(crate::acp::cursor_ext::build_cursor_generate_image_response( - &req.0, - )); + let _ = responder.respond(crate::acp::cursor_ext::build_cursor_generate_image_response(&req.0)); } #[allow(clippy::too_many_arguments)] @@ -11864,9 +11919,10 @@ fn config_option_rejects_value(option: &SessionConfigOption, value: &str) -> boo // Grouped and ungrouped are one flat namespace here, the same way // `config_option_rejection` reads them. let mut advertised = match &select.options { - SessionConfigSelectOptions::Ungrouped(options) => { - options.iter().map(|o| o.value.to_string()).collect::>() - } + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|o| o.value.to_string()) + .collect::>(), SessionConfigSelectOptions::Grouped(groups) => groups .iter() .flat_map(|group| group.options.iter().map(|o| o.value.to_string())) @@ -12481,10 +12537,7 @@ async fn apply_preferred_session_options( // `config_option_never_replayed`, which the option probe shares. let (pinned, agent_type) = { let guard = state.read().await; - ( - guard.env_pinned_config_option_ids.clone(), - guard.agent_type, - ) + (guard.env_pinned_config_option_ids.clone(), guard.agent_type) }; let never_replayed = |config_id: &str| config_option_never_replayed(agent_type, &pinned, config_id); @@ -13785,6 +13838,35 @@ fn stop_reason_to_str(reason: StopReason) -> &'static str { } } +enum CanceledPromptDrain { + Waiting(tokio::task::JoinHandle<()>), + Failed(String), +} + +/// Wait for the canceled wire request without consuming the handle on timeout. +async fn await_canceled_prompt( + pending: &mut Option, + timeout: Duration, +) -> Result<(), String> { + let drain = match pending.as_mut() { + None => return Ok(()), + Some(CanceledPromptDrain::Failed(error)) => return Err(error.clone()), + Some(CanceledPromptDrain::Waiting(drain)) => drain, + }; + match tokio::time::timeout(timeout, drain).await { + Ok(Ok(())) => { + *pending = None; + Ok(()) + } + Ok(Err(error)) => { + let error = format!("canceled prompt drain failed: {error}"); + *pending = Some(CanceledPromptDrain::Failed(error.clone())); + Err(error) + } + Err(_) => Err("agent has not acknowledged prompt cancellation; continuation paused".into()), + } +} + /// How long after the answer to a prompt that a Stop cancelled codeg holds /// CodeBuddy's next prompt. /// @@ -14607,9 +14689,9 @@ impl EmptyTurnCause { "{agent_type} produced output that codeg could not parse — \ the agent version may not match the protocol." ), - EmptyTurnCause::MetadataOnly => format!( - "{agent_type} sent only status updates this turn and no reply." - ), + EmptyTurnCause::MetadataOnly => { + format!("{agent_type} sent only status updates this turn and no reply.") + } } } } @@ -14869,7 +14951,115 @@ async fn run_conversation_loop( // A Stop codeg sent CodeBuddy mid-turn, and the prompt held back until its // barrier has passed (see `CODEBUDDY_CANCEL_BARRIER`). let mut codebuddy_cancel: Option = None; + let mut canceled_prompt_drain: Option = None; let mut held_prompt: Option = None; + // Reuse the exact idle notification path while a cancellation barrier waits. + // The response and its preceding notifications must both be consumed before + // admitting the next prompt, otherwise old deltas would enter the new turn. + macro_rules! process_idle_update { + ($update:expr) => {{ + let update = $update; + + // An `Err` is the session's router going away, not one bad + // message (see `AgentSession::read_update`): the connection + // is gone, so the loop goes with it. + let dispatch = match update { + Ok(dispatch) => fix_usage_update_nulls(dispatch), + Err(e) => return Err(defer_to_connection_report(e).await), + }; + let h = emitter.clone(); + let st = Arc::clone(state); + let cwd_opt = Some(cwd); + // Background work outlives the turn that started it, so + // these frames arrive on the IDLE loop as often as inside + // one. + if let Some(delta) = air_async_task_delta(&dispatch) + .or_else(|| grok_workflow_task_delta(&dispatch, agent_type)) + { + emit_with_state(&st, &h, AcpEvent::AsyncTask { delta }).await; + } else if let Some(notice) = session_notice(&dispatch) { + // Advisories land outside a turn as readily as inside + // one (a config warning at startup, a model reroute + // between prompts). + emit_with_state(&st, &h, AcpEvent::SessionNotice { notice }).await; + } else if let Some(event) = session_compaction_event(&dispatch) { + emit_with_state(&st, &h, event).await; + } else if let Some(completed) = grok_turn_completed(&dispatch, agent_type) { + // Grok's own end of a turn. Only the turn opened below + // ends here; one codeg started ended on its response. + if grok_turn_completed_ends(cb_state.grok_agent_turn.as_deref(), &completed) { + close_grok_agent_turn( + &st, + &h, + perms, + agent_type, + &agent_turn_session_id, + completed.stop_reason, + &mut cb_state, + ) + .await; + } + } else { + // A turn Grok runs on its own (a background workflow's + // follow-up) gets a turn here, opened before its first + // chunk is emitted. + if let Some(prompt_id) = grok_agent_turn_opener(&dispatch, agent_type, &cb_state) { + open_grok_agent_turn(&st, &h, prompt_id, &mut cb_state).await; + } + let drift = &mut config_drift_to_reassert; + if let Err(e) = MatchDispatch::new(dispatch) + .if_notification(async |notif: SessionNotification| { + // BEFORE the emit: it overwrites the very + // state the comparison reads. + if let SessionUpdate::ConfigOptionUpdate(update) = ¬if.update { + drift.extend( + take_asserted_config_drift(&st, &update.config_options).await, + ); + } + // A custom agent's history is this + // transcript and nothing else, and what + // arrives here is part of it: a turn the + // agent runs on its own (a background job's + // follow-up, with no `session/prompt` around + // it), the late completion of a call an + // earlier turn started, or the last frames + // of a turn whose response won the turn + // loop's select. No-op for every other agent. + if prompted { + record_transcript_update( + agent_type, + ¬if.session_id.0, + ¬if.update, + ); + } + emit_conversation_update( + &st, + &h, + agent_type, + notif.update, + cwd_opt, + &mut raw_output_cache, + &mut cb_state, + ) + .await; + Ok(()) + }) + .await + .otherwise(async |dispatch| { + maybe_emit_ext_notification(&st, &h, agent_type, dispatch, &mut cb_state) + .await; + Ok(()) + }) + .await + { + // Both closures are infallible, so this is an update + // whose params did not deserialize — the same drop the + // in-turn loop counts, logged under the same throttle. + log_dropped_update(&mut drop_log_throttle, "idle", &e); + } + } + }}; + } loop { // Wait for either a user command or a session update (e.g. available_commands_update) let cmd = loop { @@ -14891,92 +15081,7 @@ async fn run_conversation_loop( } } update = session.read_update() => { - // An `Err` is the session's router going away, not one bad - // message (see `AgentSession::read_update`): the connection - // is gone, so the loop goes with it. - let dispatch = match update { - Ok(dispatch) => fix_usage_update_nulls(dispatch), - Err(e) => return Err(defer_to_connection_report(e).await), - }; - let h = emitter.clone(); - let st = Arc::clone(state); - let cwd_opt = Some(cwd); - // Background work outlives the turn that started it, so - // these frames arrive on the IDLE loop as often as inside - // one. - if let Some(delta) = air_async_task_delta(&dispatch) - .or_else(|| grok_workflow_task_delta(&dispatch, agent_type)) - { - emit_with_state(&st, &h, AcpEvent::AsyncTask { delta }).await; - } else if let Some(notice) = session_notice(&dispatch) { - // Advisories land outside a turn as readily as inside - // one (a config warning at startup, a model reroute - // between prompts). - emit_with_state(&st, &h, AcpEvent::SessionNotice { notice }).await; - } else if let Some(event) = session_compaction_event(&dispatch) { - emit_with_state(&st, &h, event).await; - } else if let Some(completed) = grok_turn_completed(&dispatch, agent_type) { - // Grok's own end of a turn. Only the turn opened below - // ends here; one codeg started ended on its response. - if grok_turn_completed_ends(cb_state.grok_agent_turn.as_deref(), &completed) { - close_grok_agent_turn( - &st, - &h, - perms, - agent_type, - &agent_turn_session_id, - completed.stop_reason, - &mut cb_state, - ) - .await; - } - } else { - // A turn Grok runs on its own (a background workflow's - // follow-up) gets a turn here, opened before its first - // chunk is emitted. - if let Some(prompt_id) = grok_agent_turn_opener(&dispatch, agent_type, &cb_state) { - open_grok_agent_turn(&st, &h, prompt_id, &mut cb_state).await; - } - let drift = &mut config_drift_to_reassert; - if let Err(e) = MatchDispatch::new(dispatch) - .if_notification( - async |notif: SessionNotification| { - // BEFORE the emit: it overwrites the very - // state the comparison reads. - if let SessionUpdate::ConfigOptionUpdate(update) = ¬if.update { - drift.extend( - take_asserted_config_drift(&st, &update.config_options).await, - ); - } - // A custom agent's history is this - // transcript and nothing else, and what - // arrives here is part of it: a turn the - // agent runs on its own (a background job's - // follow-up, with no `session/prompt` around - // it), the late completion of a call an - // earlier turn started, or the last frames - // of a turn whose response won the turn - // loop's select. No-op for every other agent. - if prompted { - record_transcript_update(agent_type, ¬if.session_id.0, ¬if.update); - } - emit_conversation_update(&st, &h, agent_type, notif.update, cwd_opt, &mut raw_output_cache, &mut cb_state).await; - Ok(()) - }, - ) - .await - .otherwise(async |dispatch| { - maybe_emit_ext_notification(&st, &h, agent_type, dispatch, &mut cb_state).await; - Ok(()) - }) - .await - { - // Both closures are infallible, so this is an update - // whose params did not deserialize — the same drop the - // in-turn loop counts, logged under the same throttle. - log_dropped_update(&mut drop_log_throttle, "idle", &e); - } - } + process_idle_update!(update); } } // Reached on the update arm, or right after a prompt was held (the @@ -14988,9 +15093,15 @@ async fn run_conversation_loop( ); let cx = session.connection(); let sid = session.session_id().clone(); - if let Err(e) = - set_session_config_option(&cx, &sid, state, emitter, config_id.clone(), value_id) - .await + if let Err(e) = set_session_config_option( + &cx, + &sid, + state, + emitter, + config_id.clone(), + value_id, + ) + .await { // Advisory: the agent is running what it pushed and has already // told the frontend so. Failing the connection over a selector @@ -16093,14 +16204,17 @@ async fn run_conversation_loop( }); answered }); - tokio::spawn(async move { + canceled_prompt_drain = Some(CanceledPromptDrain::Waiting(tokio::spawn(async move { let _ = prompt_response.await; if let Some(answered) = answered { let _ = answered.send(tokio::time::Instant::now()); } - }); + }))); break; } + Some(ConnectionCommand::AwaitPromptQuiescence { reply }) => { + let _ = reply.send(Err("prompt is still running; cancel it before waiting".into())); + } Some(ConnectionCommand::Disconnect) | None => { tracing::info!( "[ACP] disconnect requested during prompting; connection_id={conn_id}" @@ -16267,6 +16381,24 @@ async fn run_conversation_loop( let sid = session.session_id().clone(); let _ = reply.send(send_stop_async_task_request(&cx, &sid, &task_id).await); } + Some(ConnectionCommand::AwaitPromptQuiescence { reply }) => { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let result = loop { + if tokio::time::Instant::now() >= deadline { + break Err("agent cancellation/update drain did not settle after 30 seconds; continuation paused".into()); + } + tokio::select! { + biased; + update = session.read_update() => { process_idle_update!(update); } + result = await_canceled_prompt( + &mut canceled_prompt_drain, + deadline.saturating_duration_since(tokio::time::Instant::now()), + ) => break result, + } + }; + let _ = reply.send(result); + } + Some(ConnectionCommand::Cancel) => { let cx = session.connection(); let sid = session.session_id().clone(); @@ -16412,8 +16544,11 @@ pub(crate) fn serialize_tool_call_content( // card through the synthesized input or not at all. ToolCallContent::Diff(diff) if include_diffs - && crate::acp::air_contract::air_meta_value(diff.meta.as_ref(), "diffPatch") - .is_none() => + && crate::acp::air_contract::air_meta_value( + diff.meta.as_ref(), + "diffPatch", + ) + .is_none() => { let path = diff.path.display(); let mut diff_text = format!("--- {path}\n+++ {path}\n"); @@ -16790,9 +16925,9 @@ fn c_unquote(inner: &str) -> String { i += 2; continue; } - let octal = bytes.get(i + 1..i + 4).filter(|digits| { - digits.iter().all(|d| (b'0'..=b'7').contains(d)) - }); + let octal = bytes + .get(i + 1..i + 4) + .filter(|digits| digits.iter().all(|d| (b'0'..=b'7').contains(d))); if let Some(digits) = octal { let value = digits .iter() @@ -16907,7 +17042,9 @@ fn build_new_file_diff(path: &str, new_text: &str) -> String { /// on `AcpEvent::ToolCall(Update)` stays absent for non-image tool calls /// (preserves replace-on-update semantics: an absent field means "keep /// prior", a `Some(vec)` replaces). -pub(crate) fn extract_tool_call_images(content: &[ToolCallContent]) -> Option> { +pub(crate) fn extract_tool_call_images( + content: &[ToolCallContent], +) -> Option> { let mut imgs: Vec = Vec::new(); for item in content { if let ToolCallContent::Content(c) = item { @@ -17530,7 +17667,10 @@ fn opencode_completion_meta( name: Option<&str>, raw_output: &Option, ) -> serde_json::Map { - match (name, raw_output.as_ref().and_then(|raw| raw.get("metadata"))) { + match ( + name, + raw_output.as_ref().and_then(|raw| raw.get("metadata")), + ) { (Some(name), Some(metadata)) => opencode_metadata_meta(name, metadata), _ => serde_json::Map::new(), } @@ -17931,9 +18071,7 @@ impl OpenCodeLiveState { let index = unbound .iter() .copied() - .find(|&i| { - child.title.is_some() && self.subagents[i].description == child.title - }) + .find(|&i| child.title.is_some() && self.subagents[i].description == child.title) .or_else(|| unbound.first().copied())?; let launch = &mut self.subagents[index]; launch.child = Some(OpenCodeChild::new(&child.id)); @@ -18818,7 +18956,10 @@ fn is_subagent_invocation(agent_type: AgentType, raw_input: &Option) -> /// historical unwrap in `parsers/codebuddy.rs`. `raw_input` is left untouched /// (the cards peel `params` themselves, and that keeps `inferFromInput` from /// misclassifying `cancel_delegation`'s `{task_id}` as a generic task). -fn codebuddy_deferred_tool_name(agent_type: AgentType, raw_input: &Option) -> Option { +fn codebuddy_deferred_tool_name( + agent_type: AgentType, + raw_input: &Option, +) -> Option { if agent_type != AgentType::CodeBuddy { return None; } @@ -19937,7 +20078,10 @@ fn version_at_least(version: &str, min: &str) -> bool { /// the pinned npx package (see `commands::acp::acp_get_agent_status_core`) — /// report an `agent_info.version` at or above the registry minimum? Fail /// closed on a missing `agent_info` or an unparseable version. -fn steering_version_ok(agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, min: &str) -> bool { +fn steering_version_ok( + agent_info: Option<&agent_client_protocol::schema::v1::Implementation>, + min: &str, +) -> bool { agent_info.is_some_and(|info| version_at_least(&info.version, min)) } @@ -21660,7 +21804,12 @@ fn map_grok_subagent_notification_inner( .get("output") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) - .map(|s| crate::parsers::truncate_str(s, crate::parsers::claude::BACKGROUND_RESULT_MAX_CHARS)); + .map(|s| { + crate::parsers::truncate_str( + s, + crate::parsers::claude::BACKGROUND_RESULT_MAX_CHARS, + ) + }); Some(vec![AcpEvent::BackgroundActivity { session_id: session_id.to_string(), turns: Vec::new(), @@ -22038,7 +22187,9 @@ async fn apply_grok_model_catalog( /// [`handle_auth_status_update`]). Only `authStatus` is modelled; the payload is /// kept as a raw value so a new `kind` or an added field can never turn a /// well-formed push into a deserialization failure. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification, +)] #[notification(method = "_auth/status_update")] #[serde(rename_all = "camelCase")] struct AuthStatusUpdateNotification { @@ -22051,7 +22202,9 @@ struct AuthStatusUpdateNotification { /// Kiro runs a command it receives as prompt TEXT (`/usage`, `/model `, /// `/compact`, … each answer with an `agent_message_chunk` and `end_turn`), so /// listing them is all the composer's `/` menu needs. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification, +)] #[notification(method = "_kiro.dev/commands/available")] #[serde(rename_all = "camelCase")] struct KiroCommandsAvailableNotification { @@ -22068,7 +22221,9 @@ struct KiroCommandsAvailableNotification { /// `_kiro.dev/metadata {sessionId, contextUsagePercentage, reasoning}` — sent /// after `session/new` and after every model or effort change. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification, +)] #[notification(method = "_kiro.dev/metadata")] #[serde(rename_all = "camelCase")] struct KiroMetadataNotification { @@ -22080,7 +22235,9 @@ struct KiroMetadataNotification { /// `_kiro.dev/agent/switched {sessionId, agentName, previousAgentName, …}` — /// Kiro's report that the session's agent config (its ACP mode) changed. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification)] +#[derive( + Debug, Clone, serde::Serialize, serde::Deserialize, agent_client_protocol::JsonRpcNotification, +)] #[notification(method = "_kiro.dev/agent/switched")] #[serde(rename_all = "camelCase")] struct KiroAgentSwitchedNotification { @@ -22333,7 +22490,13 @@ mod kiro_ext_tests { let commands = kiro_available_commands(¬if); let rows: Vec<_> = commands .iter() - .map(|c| (c.name.as_str(), c.description.as_str(), c.input_hint.as_deref())) + .map(|c| { + ( + c.name.as_str(), + c.description.as_str(), + c.input_hint.as_deref(), + ) + }) .collect(); assert_eq!( rows, @@ -25177,17 +25340,21 @@ async fn emit_conversation_update( // its child session: it belongs inside the Agent card of the call // that launched it, or nowhere (see `opencode_frame_route`). No-op // for every other agent. - let opencode_parent = - match opencode_frame_route(agent_type, meta.as_ref(), state, &mut cb_state.opencode) - .await - { - OpenCodeFrameRoute::Main => None, - OpenCodeFrameRoute::Subagent { - parent_tool_call_id, - .. - } => Some(parent_tool_call_id), - OpenCodeFrameRoute::Drop => return, - }; + let opencode_parent = match opencode_frame_route( + agent_type, + meta.as_ref(), + state, + &mut cb_state.opencode, + ) + .await + { + OpenCodeFrameRoute::Main => None, + OpenCodeFrameRoute::Subagent { + parent_tool_call_id, + .. + } => Some(parent_tool_call_id), + OpenCodeFrameRoute::Drop => return, + }; // Gemini reports an approval-mode switch as a prose chunk reading // `[MODE_UPDATE] `. Turn it back into the mode event it should // have been — see `gemini_mode_update_chunk` for the two guards that @@ -25286,17 +25453,21 @@ async fn emit_conversation_update( .. }) => { // Same routing for an OpenCode sub-agent's reasoning. - let opencode_parent = - match opencode_frame_route(agent_type, meta.as_ref(), state, &mut cb_state.opencode) - .await - { - OpenCodeFrameRoute::Main => None, - OpenCodeFrameRoute::Subagent { - parent_tool_call_id, - .. - } => Some(parent_tool_call_id), - OpenCodeFrameRoute::Drop => return, - }; + let opencode_parent = match opencode_frame_route( + agent_type, + meta.as_ref(), + state, + &mut cb_state.opencode, + ) + .await + { + OpenCodeFrameRoute::Main => None, + OpenCodeFrameRoute::Subagent { + parent_tool_call_id, + .. + } => Some(parent_tool_call_id), + OpenCodeFrameRoute::Drop => return, + }; // Same suppression for a sub-agent's interleaved reasoning. if !should_suppress_subagent_chunk( agent_type, @@ -25395,8 +25566,11 @@ async fn emit_conversation_update( // is orchestration bookkeeping, so it is replaced wholesale); a // terminal marker is folded back onto that capsule; the rest stay // dropped. See `classify_codex_subagent_activity`. - let activity_meta = - codex_activity_classification_meta(agent_type, tc.meta.as_ref(), tc.raw_input.as_ref()); + let activity_meta = codex_activity_classification_meta( + agent_type, + tc.meta.as_ref(), + tc.raw_input.as_ref(), + ); let mut codex_subagent_thread = None; let codex_subagent = match classify_codex_subagent_activity( agent_type, @@ -25890,11 +26064,13 @@ async fn emit_conversation_update( agent_type, activity_meta.as_ref().or(tcu.meta.as_ref()), ) { - CodexSubagentActivity::None => match cb_state.codex_activity_calls.get(&tool_call_id) { - Some(Some(launch_input)) => Some(launch_input.clone()), - Some(None) => return, - None => None, - }, + CodexSubagentActivity::None => { + match cb_state.codex_activity_calls.get(&tool_call_id) { + Some(Some(launch_input)) => Some(launch_input.clone()), + Some(None) => return, + None => None, + } + } CodexSubagentActivity::Started { thread_id, input } => { codex_subagent_thread = thread_id; Some(input) @@ -26052,7 +26228,10 @@ async fn emit_conversation_update( }; let content = content_blocks .and_then(|c| { - serialize_tool_call_content(c, synthesized_edit.is_none() && kiro_tool.is_none()) + serialize_tool_call_content( + c, + synthesized_edit.is_none() && kiro_tool.is_none(), + ) }) .map(|c| unwrap_codebuddy_deferred_output(agent_type, &c).unwrap_or(c)) // Symmetric with the ToolCall arm — and the arm that matters: @@ -26215,7 +26394,13 @@ async fn emit_conversation_update( } // Symmetric with the ToolCall arm: an update may carry the terminal // status (and, on grok, usually re-carries the `x.ai/tool` meta). - track_grok_spawn_call(cb_state, grok_spawn, status.as_deref(), &tool_call_id, &raw_input); + track_grok_spawn_call( + cb_state, + grok_spawn, + status.as_deref(), + &tool_call_id, + &raw_input, + ); // Ordering variant: `subagent_spawned` can pair BEFORE the launch // call's terminal frame arrives. The pairing site skipped its // outstanding emission then (call not yet settled), so surface the @@ -28625,9 +28810,7 @@ mod tests { assert!(!init_advertises_steering(Some(&off))); // Wrong nesting (e.g. another convention's namespace) must not count. - let nested = meta_map( - serde_json::json!({"symposium": {"steering": {"supported": true}}}), - ); + let nested = meta_map(serde_json::json!({"symposium": {"steering": {"supported": true}}})); assert!(!init_advertises_steering(Some(&nested))); // Non-bool / absent → false. @@ -28848,9 +29031,8 @@ mod tests { serde_json::json!({"codex": {"goal": {"objective": "legacy", "status": "active"}}}), ); assert!(session_info_goal_value(true, Some(&legacy_only)).is_none()); - let neutral_only = meta_map( - serde_json::json!({"goal": {"objective": "neutral", "status": "active"}}), - ); + let neutral_only = + meta_map(serde_json::json!({"goal": {"objective": "neutral", "status": "active"}})); assert!(session_info_goal_value(false, Some(&neutral_only)).is_none()); // `goal: null` IS a value (the clear signal), not an absent key. @@ -30407,7 +30589,11 @@ mod tests { "Yes", PermissionOptionKind::AllowOnce, ), - agent_client_protocol::schema::v1::PermissionOption::new("reject", "No", PermissionOptionKind::RejectOnce), + agent_client_protocol::schema::v1::PermissionOption::new( + "reject", + "No", + PermissionOptionKind::RejectOnce, + ), ] } @@ -31224,10 +31410,7 @@ mod tests { } fn antigravity_runtime(method: &str) -> BTreeMap { - BTreeMap::from([( - ANTIGRAVITY_AUTH_METHOD_ENV.to_string(), - method.to_string(), - )]) + BTreeMap::from([(ANTIGRAVITY_AUTH_METHOD_ENV.to_string(), method.to_string())]) } #[test] @@ -31427,8 +31610,11 @@ mod tests { // whole enum exists for. A caller that treated it as "nothing there" // would sign out of a `gemini-api-key` connection, clear nothing, and // be told `{}`. - std::fs::write(&path, "{\n // mine\n \"auth\": {\"type\": \"oauth-personal\"},\n}\n") - .unwrap(); + std::fs::write( + &path, + "{\n // mine\n \"auth\": {\"type\": \"oauth-personal\"},\n}\n", + ) + .unwrap(); assert_eq!( antigravity_effective_auth_type(&home()), AntigravityAuthType::Unreadable @@ -31567,9 +31753,14 @@ mod tests { // No file at all: created from scratch. (A non-object ROOT never gets // here — the read side already refused it.) - let created = merge_antigravity_settings(None, "oauth-personal", GcpField::Set("p"), GcpField::Set("global")) - .expect("editable") - .expect("created"); + let created = merge_antigravity_settings( + None, + "oauth-personal", + GcpField::Set("p"), + GcpField::Set("global"), + ) + .expect("editable") + .expect("created"); assert_eq!(created["auth"]["type"], "oauth-personal"); assert_eq!(created["gcp"]["project"], "p"); assert_eq!(created["gcp"]["location"], "global"); @@ -31587,18 +31778,21 @@ mod tests { // into it. With no project or location supplied, a strange `gcp` is // none of codeg's business and must not block the `auth.type` update. let odd_gcp = serde_json::json!({ "gcp": ["not", "an", "object"] }); - assert!( - merge_antigravity_settings( - Some(odd_gcp.clone()), - "oauth-personal", - GcpField::Set("p"), - GcpField::Keep, - ) - .is_err() - ); - let untouched = merge_antigravity_settings(Some(odd_gcp), "oauth-personal", GcpField::Keep, GcpField::Keep) - .expect("editable") - .expect("auth.type still written"); + assert!(merge_antigravity_settings( + Some(odd_gcp.clone()), + "oauth-personal", + GcpField::Set("p"), + GcpField::Keep, + ) + .is_err()); + let untouched = merge_antigravity_settings( + Some(odd_gcp), + "oauth-personal", + GcpField::Keep, + GcpField::Keep, + ) + .expect("editable") + .expect("auth.type still written"); assert_eq!(untouched["auth"]["type"], "oauth-personal"); assert_eq!(untouched["gcp"], serde_json::json!(["not", "an", "object"])); @@ -31635,10 +31829,14 @@ mod tests { }; // The panel owns both fields for this method and both are now empty. - let cleared = - merge_antigravity_settings(Some(existing()), "oauth-business", GcpField::Clear, GcpField::Clear) - .expect("editable") - .expect("the gcp block changed, so this is a real write"); + let cleared = merge_antigravity_settings( + Some(existing()), + "oauth-business", + GcpField::Clear, + GcpField::Clear, + ) + .expect("editable") + .expect("the gcp block changed, so this is a real write"); assert!( cleared.get("gcp").is_none(), "an emptied block should go rather than linger as {{}}: {cleared}" @@ -31647,10 +31845,14 @@ mod tests { assert_eq!(cleared["keep"], 1, "foreign keys still survive a clear"); // One cleared, one set. - let partial = - merge_antigravity_settings(Some(existing()), "oauth-business", GcpField::Set("new"), GcpField::Clear) - .expect("editable") - .expect("changed"); + let partial = merge_antigravity_settings( + Some(existing()), + "oauth-business", + GcpField::Set("new"), + GcpField::Clear, + ) + .expect("editable") + .expect("changed"); assert_eq!(partial["gcp"]["project"], "new"); assert!(partial["gcp"].get("location").is_none()); @@ -31671,10 +31873,14 @@ mod tests { // take the `auth.type` update down with it — the one part of this file // the agent cannot start without. let odd = serde_json::json!({ "gcp": ["not", "an", "object"] }); - let still_written = - merge_antigravity_settings(Some(odd), "oauth-business", GcpField::Clear, GcpField::Clear) - .expect("a clear must not refuse a block it cannot edit") - .expect("auth.type still written"); + let still_written = merge_antigravity_settings( + Some(odd), + "oauth-business", + GcpField::Clear, + GcpField::Clear, + ) + .expect("a clear must not refuse a block it cannot edit") + .expect("auth.type still written"); assert_eq!(still_written["auth"]["type"], "oauth-business"); assert_eq!( still_written["gcp"], @@ -32176,7 +32382,11 @@ mod tests { // sends one per `structuredPatch` hunk after a multi-site Edit. Keyed // by path, every hunk but the last used to vanish. let content = vec![ - diff_content("/a.rs", Some("fn a() {\n 1\n}\n"), "fn a() {\n 2\n}\n"), + diff_content( + "/a.rs", + Some("fn a() {\n 1\n}\n"), + "fn a() {\n 2\n}\n", + ), diff_content("/a.rs", Some("fn z() {\n 1\n}"), "fn z() {\n 3\n}"), ]; let v: serde_json::Value = @@ -32331,7 +32541,11 @@ mod tests { fn claude_edit_input_gets_its_text_back_from_the_diff() { // claude-agent-acp 0.82.0 (AIR), recorded: `rawInput` is `{file_path}` // alone and the model's strings ride only in the diff block. - let content = vec![diff_content("/w/app.ts", Some("const value = 1;"), "const value = 2;")]; + let content = vec![diff_content( + "/w/app.ts", + Some("const value = 1;"), + "const value = 2;", + )]; let merged = claude_complete_file_edit_input( AgentType::ClaudeCode, Some(&claude_meta("Edit")), @@ -32731,7 +32945,12 @@ mod tests { #[test] fn prepend_path_windows_seeds_from_fallback_with_semicolon() { let mut env = BTreeMap::new(); - prepend_dir_to_path_env(&mut env, r"C:\OfficeCLI", r"C:\Windows;C:\Windows\System32", true); + prepend_dir_to_path_env( + &mut env, + r"C:\OfficeCLI", + r"C:\Windows;C:\Windows\System32", + true, + ); // No prior key → default `Path` casing on Windows. assert_eq!(env.get("Path").unwrap(), r"C:\OfficeCLI;C:\Windows;C:\Windows\System32"); } @@ -34463,7 +34682,11 @@ mod tests { /// nothing to show, and a toast with an empty body is worse than silence. #[test] fn a_notice_without_a_usable_title_is_dropped() { - for title in [serde_json::json!(""), serde_json::json!(" "), serde_json::Value::Null] { + for title in [ + serde_json::json!(""), + serde_json::json!(" "), + serde_json::Value::Null, + ] { assert!( session_notice(&async_task_notif(serde_json::json!({ "sessionUpdate": "notice", @@ -35090,7 +35313,11 @@ mod tests { .unwrap(), ) }; - for variant in ["image_dropped", "auto_compact_completed", "auto_compact_failed"] { + for variant in [ + "image_dropped", + "auto_compact_completed", + "auto_compact_failed", + ] { assert!( grok_ext_notification_skipped_on_replay(¬if(variant), AgentType::Grok), "{variant} must not replay into the live state" @@ -36320,6 +36547,14 @@ mod tests { async fn start_reopening( client_end: agent_client_protocol::Channel, session_id: Option<&str>, + ) -> Self { + Self::start_reopening_policy(client_end, session_id, false).await + } + + async fn start_reopening_policy( + client_end: agent_client_protocol::Channel, + session_id: Option<&str>, + strict: bool, ) -> Self { let session_id = session_id.map(str::to_string); let dir = tempfile::tempdir().expect("tempdir"); @@ -36334,6 +36569,7 @@ mod tests { "test-window".to_string(), None, ))); + state.write().await.require_session_resume = strict; let events = state.read().await.event_stream().subscribe(); let (cmd_tx, cmd_rx) = mpsc::channel(32); let handshake = CancellationToken::new(); @@ -36424,6 +36660,30 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn strict_messaging_resume_refuses_load_failure_without_new_session() { + let (client_end, agent_end) = agent_client_protocol::Channel::duplex(); + let mut agent = HandshakeAgent::start( + agent_end, + HandshakeScript { + load_error: Some( + serde_json::json!({ "code": -32603, "message": "cannot reload original context" }), + ), + ..Default::default() + }, + ); + let run = HandshakeRun::start_reopening_policy(client_end, Some("original"), true).await; + assert_eq!(agent.next_request().await, "initialize"); + assert_eq!(agent.next_request().await, "session/load"); + let (ended, emitted) = run.ended().await; + assert!(ended.is_err()); + assert!(!emitted + .iter() + .any(|e| matches!(e, AcpEvent::SessionStarted { .. }))); + assert!(!matches!(agent.seen.try_recv(), Ok(request) if request == "session/new")); + agent.closed().await; + } + /// A connection let go while its agent has not answered Initialize stops /// there. The `Disconnect` command goes unread until the conversation loop /// starts and Initialize waits up to 60 s, so it used to sit that wait out @@ -37052,7 +37312,9 @@ mod tests { let servers = vec![stdio_server("codeg")]; let tagged = tag_mcp_suspect( - agent_client_protocol::util::internal_error("session/new failed: unknown field `mcpServers`"), + agent_client_protocol::util::internal_error( + "session/new failed: unknown field `mcpServers`", + ), AgentType::Custom("my-agent"), &servers, ); @@ -37153,7 +37415,9 @@ mod tests { #[test] fn a_non_auth_refusal_still_takes_the_mcp_path() { let tagged = tag_new_session_failure( - agent_client_protocol::util::internal_error("session/new failed: unknown field `mcpServers`"), + agent_client_protocol::util::internal_error( + "session/new failed: unknown field `mcpServers`", + ), AgentType::Custom("my-agent"), &[stdio_server("codeg")], ) @@ -37282,13 +37546,15 @@ mod tests { // Exact shape Grok returns when switching to a model whose agentType // differs from the established conversation's (captured from a live // `session/set_model` probe against grok 0.2.94). - let err = agent_client_protocol::Error::new(-32600, "Cannot switch to model ...").data(serde_json::json!({ - "code": "MODEL_SWITCH_INCOMPATIBLE_AGENT", - "activeAgentType": "grok-build-plan", - "requiredAgentType": "cursor", - "modelId": "grok-composer-2.5-fast", - "suggestion": "start_new_session" - })); + let err = agent_client_protocol::Error::new(-32600, "Cannot switch to model ...").data( + serde_json::json!({ + "code": "MODEL_SWITCH_INCOMPATIBLE_AGENT", + "activeAgentType": "grok-build-plan", + "requiredAgentType": "cursor", + "modelId": "grok-composer-2.5-fast", + "suggestion": "start_new_session" + }), + ); assert!(is_grok_incompatible_agent_switch(&err)); // A different data.code, or no data at all, must NOT be swallowed — @@ -39202,7 +39468,13 @@ mod tests { .read() .await .recent_events_after(0) - .map(|events| events.iter().skip(before).map(|e| e.payload.clone()).collect()) + .map(|events| { + events + .iter() + .skip(before) + .map(|e| e.payload.clone()) + .collect() + }) .unwrap_or_default() } @@ -39425,9 +39697,13 @@ mod tests { // The turn ends (`TurnComplete` clears the turn's calls) while the // background child keeps working. state.write().await.active_tool_calls.clear(); - let late = - opencode_emit_more(&state, &mut cache, &mut cb, &oc_child_frames(OC_CHILD, "Background child")) - .await; + let late = opencode_emit_more( + &state, + &mut cache, + &mut cb, + &oc_child_frames(OC_CHILD, "Background child"), + ) + .await; assert!(late.is_empty(), "{late:#?}"); } @@ -40616,7 +40892,12 @@ mod tests { .expect("events recorded") .into_iter() .map(|e| e.payload.clone()) - .filter(|e| matches!(e, AcpEvent::ToolCall { .. } | AcpEvent::ToolCallUpdate { .. })) + .filter(|e| { + matches!( + e, + AcpEvent::ToolCall { .. } | AcpEvent::ToolCallUpdate { .. } + ) + }) .collect(); (state, events) } @@ -40649,7 +40930,9 @@ mod tests { let metas: Vec> = events .iter() .map(|e| match e { - AcpEvent::ToolCall { meta, .. } | AcpEvent::ToolCallUpdate { meta, .. } => meta.clone(), + AcpEvent::ToolCall { meta, .. } | AcpEvent::ToolCallUpdate { meta, .. } => { + meta.clone() + } _ => unreachable!(), }) .collect(); @@ -40715,7 +40998,8 @@ mod tests { &mut cb, vec![serde_json::json!({"sessionUpdate": "tool_call", "toolCallId": "toolu_read", "_meta": {"claudeCode": {"toolName": "Read"}}, "content": [], "kind": "read", - "locations": [], "name": "Read", "status": "pending", "title": "Read File"})], + "locations": [], "name": "Read", "status": "pending", "title": "Read File"}), + ], ) .await; stash_claude_viewed_results( @@ -40729,8 +41013,10 @@ mod tests { let (_, events) = drive_tool_frames( AgentType::ClaudeCode, &mut cb, - vec![serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "toolu_read", - "status": "completed"})], + vec![ + serde_json::json!({"sessionUpdate": "tool_call_update", "toolCallId": "toolu_read", + "status": "completed"}), + ], ) .await; let AcpEvent::ToolCallUpdate { raw_output, .. } = &events[0] else { @@ -41339,12 +41625,7 @@ mod tests { cache: &mut ToolCallOutputCache, cb: &mut CodeBuddyLiveState, wire: serde_json::Value, - ) -> ( - Option, - Option, - Option, - Option, - ) { + ) -> (Option, Option, Option, Option) { let st = SessionState::new( "conn-pi".to_string(), agent_type, @@ -41369,12 +41650,7 @@ mod tests { raw_input, raw_output, .. - } => Some(( - content.clone(), - raw_input.clone(), - raw_output.clone(), - None, - )), + } => Some((content.clone(), raw_input.clone(), raw_output.clone(), None)), AcpEvent::ToolCallUpdate { content, raw_input, @@ -43007,8 +43283,11 @@ mod tests { /// own counters so the banner can render its localized line. #[tokio::test] async fn pi_retry_chunk_becomes_the_retry_banner_with_counters() { - let events = - pi_emit_chunk(AgentType::Pi, pi_chunk("Retrying (attempt 2/3, waiting 4s)...")).await; + let events = pi_emit_chunk( + AgentType::Pi, + pi_chunk("Retrying (attempt 2/3, waiting 4s)..."), + ) + .await; assert!( !events .iter() @@ -43744,12 +44023,8 @@ mod tests { /// first. #[test] fn scratch_dir_sets_every_temp_variable_the_child_might_read() { - let merged = merge_agent_env_with_color( - false, - &[], - &BTreeMap::new(), - Some(Path::new("/scratch/x")), - ); + let merged = + merge_agent_env_with_color(false, &[], &BTreeMap::new(), Some(Path::new("/scratch/x"))); for key in crate::acp::scratch_dir::TEMP_ENV_KEYS { assert_eq!(merged_value(&merged, key), Some("/scratch/x"), "{key}"); } @@ -43865,10 +44140,8 @@ mod tests { /// machine's environment; `*` is what an inherited `NO_PROXY=*` yields. #[test] fn a_proxied_launch_env_carries_the_loopback_exception() { - let runtime_env = BTreeMap::from([( - "HTTP_PROXY".to_string(), - "http://10.0.0.2:3128".to_string(), - )]); + let runtime_env = + BTreeMap::from([("HTTP_PROXY".to_string(), "http://10.0.0.2:3128".to_string())]); let merged = merge_agent_env_with_color(false, &[], &runtime_env, None); for key in ["NO_PROXY", "no_proxy"] { let value = merged_value(&merged, key).unwrap_or_default(); @@ -44827,10 +45100,8 @@ mod tests { #[test] fn pi_enabled_models_filter_only_the_model_selector() { - let filtered = filter_pi_enabled_model_options( - &["openai/*".to_string()], - pi_model_selector_options(), - ); + let filtered = + filter_pi_enabled_model_options(&["openai/*".to_string()], pi_model_selector_options()); assert_eq!( pi_select_values(&filtered, "model"), @@ -46630,7 +46901,10 @@ mod tests { let count = |log: &std::sync::Mutex>| { log.lock().unwrap().iter().filter(|c| *c == "conn-grok").count() }; - (count(&self.asks.questions), count(&self.asks.plan_approvals)) + ( + count(&self.asks.questions), + count(&self.asks.plan_approvals), + ) } /// Wait for the prompt's turn to end, then let the frames the idle @@ -46651,7 +46925,9 @@ mod tests { loop { let envelope = tokio::time::timeout(deadline, self.events.recv()) .await - .unwrap_or_else(|_| panic!("timed out waiting for {what}; saw {:#?}", self.seen)) + .unwrap_or_else(|_| { + panic!("timed out waiting for {what}; saw {:#?}", self.seen) + }) .expect("event stream"); self.seen.push(envelope.payload.clone()); if matches(&envelope.payload) { @@ -46719,7 +46995,11 @@ mod tests { // random, so this ordering is what the idle loop reads whenever // the response wins. after_response: vec![ - grok_chunk("agent_message_chunk", " Watch it in /workflow runs.", Some("p1")), + grok_chunk( + "agent_message_chunk", + " Watch it in /workflow runs.", + Some("p1"), + ), grok_turn_completed_frame("p1", "end_turn"), grok_workflow_update(5, "active", Some("Alpha"), Some("probe-agent")), grok_barrier(), @@ -46737,7 +47017,11 @@ mod tests { "**codeg-probe** finished", Some(GROK_FOLLOW_UP_ID), ), - grok_chunk("agent_message_chunk", " successfully.", Some(GROK_FOLLOW_UP_ID)), + grok_chunk( + "agent_message_chunk", + " successfully.", + Some(GROK_FOLLOW_UP_ID), + ), grok_turn_completed_frame(GROK_FOLLOW_UP_ID, "end_turn"), grok_barrier(), ], @@ -46834,7 +47118,9 @@ mod tests { ); let follow_up_start = seen .iter() - .position(|e| matches!(e, AcpEvent::Thinking { text, .. } if text == "Report the result.")) + .position( + |e| matches!(e, AcpEvent::Thinking { text, .. } if text == "Report the result."), + ) .expect("the follow-up's first chunk is emitted"); let opened: Vec = prompting_edges(&seen) .into_iter() @@ -46889,7 +47175,11 @@ mod tests { ] { let mut grok = GrokLoop::start(GrokScript { later: vec![ - grok_chunk("agent_message_chunk", "Partial report", Some(GROK_FOLLOW_UP_ID)), + grok_chunk( + "agent_message_chunk", + "Partial report", + Some(GROK_FOLLOW_UP_ID), + ), grok_turn_completed_frame(GROK_FOLLOW_UP_ID, grok_reason), grok_barrier(), ], @@ -46924,7 +47214,11 @@ mod tests { async fn stopping_a_grok_follow_up_turn_ends_it_once_and_ignores_the_stragglers() { let mut grok = GrokLoop::start(GrokScript { later: vec![ - grok_chunk("agent_thought_chunk", "The user wants", Some(GROK_FOLLOW_UP_ID)), + grok_chunk( + "agent_thought_chunk", + "The user wants", + Some(GROK_FOLLOW_UP_ID), + ), grok_barrier(), ], on_cancel: vec![ @@ -46985,7 +47279,11 @@ mod tests { prompt_id: "p2", after_response: vec![grok_barrier()], later: vec![ - grok_chunk("agent_thought_chunk", "Report the result.", Some(GROK_FOLLOW_UP_ID)), + grok_chunk( + "agent_thought_chunk", + "Report the result.", + Some(GROK_FOLLOW_UP_ID), + ), grok_barrier(), ], ..GrokScript::default() @@ -47000,9 +47298,10 @@ mod tests { matches!(e, AcpEvent::TurnComplete { .. }) }) .await; - grok.until("the prompt's reply", |e| { - matches!(e, AcpEvent::ContentDelta { text, .. } if text == "Hi.") - }) + grok.until( + "the prompt's reply", + |e| matches!(e, AcpEvent::ContentDelta { text, .. } if text == "Hi."), + ) .await; // The prompt's turn is still open (its response is held), and the // follow-up's end did not release the gate out from under it. @@ -47048,7 +47347,11 @@ mod tests { async fn a_fork_is_refused_while_a_grok_follow_up_turn_runs() { let mut grok = GrokLoop::start(GrokScript { later: vec![ - grok_chunk("agent_thought_chunk", "Report the result.", Some(GROK_FOLLOW_UP_ID)), + grok_chunk( + "agent_thought_chunk", + "Report the result.", + Some(GROK_FOLLOW_UP_ID), + ), grok_barrier(), ], forks: true, @@ -48840,7 +49143,10 @@ mod tests { s.apply_event(&AcpEvent::AsyncTask { delta }); }; apply(&mut s, grok_workflow_update(1, "active", None, None)); - apply(&mut s, grok_workflow_update(5, "active", Some("Alpha"), Some("probe-agent"))); + apply( + &mut s, + grok_workflow_update(5, "active", Some("Alpha"), Some("probe-agent")), + ); let (method, mut second) = grok_workflow_update(1, "active", None, None); second["update"]["run_id"] = "wf_second".into(); apply(&mut s, (method, second)); @@ -48968,9 +49274,8 @@ mod tests { let responses = usages .into_iter() .map(|usage| { - let mut response = agent_client_protocol::schema::v1::PromptResponse::new( - StopReason::EndTurn, - ); + let mut response = + agent_client_protocol::schema::v1::PromptResponse::new(StopReason::EndTurn); response.usage = usage; response }) @@ -49516,6 +49821,35 @@ mod tests { .await; } + #[tokio::test] + async fn subsession_barrier_processes_old_deltas_before_acknowledging_cancel() { + let mut lp = + CustomLoop::start(vec![vec![custom_text("old")], vec![custom_text("new")]]).await; + lp.open_turn("one", "old").await; + lp.cmd_tx.send(ConnectionCommand::Cancel).await.unwrap(); + lp.until("eager cancel", |e| matches!(e, AcpEvent::TurnComplete { stop_reason, .. } if stop_reason == "cancelled")).await; + let (reply, mut settled) = oneshot::channel(); + lp.cmd_tx + .send(ConnectionCommand::AwaitPromptQuiescence { reply }) + .await + .unwrap(); + lp.agent_sends(vec![custom_text("old tail")]); + lp.until_text("old tail").await; + assert!(matches!( + settled.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + lp.respond.notify_one(); + tokio::time::timeout(Duration::from_secs(5), settled) + .await + .unwrap() + .unwrap() + .unwrap(); + lp.open_turn("two", "new").await; + lp.close_turn().await; + lp.shutdown().await; + } + /// A local cancel lets go of the prompt without waiting for the agent, so /// the cancelled reply's last frames land on the idle loop. No turn end is /// recorded ahead of them (as for every agent whose history is codeg's @@ -51764,3 +52098,48 @@ mod trae_handshake_tests { } } } + +#[cfg(test)] +mod subsession_quiescence_tests { + use super::*; + + #[tokio::test] + async fn failed_cancellation_drain_remains_unusable_without_repolling_join() { + let task = tokio::spawn(async { std::future::pending::<()>().await }); + task.abort(); + let mut pending = Some(CanceledPromptDrain::Waiting(task)); + let first = await_canceled_prompt(&mut pending, Duration::from_secs(1)) + .await + .unwrap_err(); + let second = await_canceled_prompt(&mut pending, Duration::from_secs(1)) + .await + .unwrap_err(); + assert_eq!(first, second); + assert!(matches!(pending, Some(CanceledPromptDrain::Failed(_)))); + } + + #[tokio::test] + async fn cancellation_barrier_waits_for_actual_prompt_answer() { + let (answer, response) = oneshot::channel::<()>(); + let mut pending = Some(CanceledPromptDrain::Waiting(tokio::spawn(async move { + let _ = response.await; + }))); + assert!( + await_canceled_prompt(&mut pending, Duration::from_millis(1)) + .await + .is_err() + ); + assert!( + pending.is_some(), + "timeout must retain the drain, never permit a new prompt" + ); + answer.send(()).unwrap(); + await_canceled_prompt(&mut pending, Duration::from_secs(1)) + .await + .unwrap(); + assert!(pending.is_none()); + await_canceled_prompt(&mut pending, Duration::from_secs(1)) + .await + .unwrap(); + } +} diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 847b7c1c52d..00def21f452 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -34,11 +34,13 @@ //! [`DelegationBroker::cancel_by_parent_turn`]), or the LLM's own //! [`DelegationBroker::cancel_task_by_id`]. //! -//! v1 is explicitly one-shot — no session reuse. The one exception is -//! [`DelegationBroker::resume_delegation`]: an INTERRUPTED task (canceled, or -//! stranded `in_progress` by a crash) can be brought back under its original -//! `task_id` by reloading the child's recorded agent session — a continuation -//! of the original task, never a new iteration on it. +//! The broker tracks the original delegation task. Subsequent prompts in the +//! same child session are managed by `SubsessionMessages`, which retains a +//! live child connection when it takes ownership. Separately, +//! [`DelegationBroker::resume_delegation`] restores an INTERRUPTED task +//! (canceled, or stranded `in_progress` by a crash) under its original `task_id` +//! by reloading the child's recorded agent session — a continuation of the +//! original task, never a new iteration on it. //! //! Result durability: child output is NOT stored in codeg's DB, so the broker //! caches the completed text in `completed` (parent-scoped, FIFO-capped). Once @@ -59,14 +61,14 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::{Mutex, Notify}; +use crate::acp::agent_mentions::render_assistant_brief; +use crate::acp::delegation::assistants::{AssistantLookup, AssistantRosterEntry, NoAssistants}; use crate::acp::delegation::event_emitter::{DelegationEventEmitter, NoopEventEmitter}; use crate::acp::delegation::live_reply::{ChildLiveReplyLookup, NoopChildLiveReplyLookup}; use crate::acp::delegation::meta_writer::{ build_delegation_meta, is_synthetic_parent_tool_use_id, DelegationMetaWriter, NoopMetaWriter, }; use crate::acp::delegation::spawner::{ConnectionSpawner, DelegationLink}; -use crate::acp::agent_mentions::render_assistant_brief; -use crate::acp::delegation::assistants::{AssistantLookup, AssistantRosterEntry, NoAssistants}; use crate::acp::delegation::types::{ AgentDelegationDefaults, AssistantRef, BlockedKind, BlockedOn, DelegationAssistant, DelegationError, DelegationOutcome, DelegationProgress, DelegationRequest, @@ -1501,6 +1503,7 @@ const IDENTITYLESS_RENAME_POLL_ATTEMPTS: usize = 30; /// gymnastics. #[derive(Clone)] pub struct DelegationBroker { + retained_children: Arc>>>, spawner: Arc, depth_lookup: Arc, /// Writer for `meta["codeg.delegation"]` on the parent's active @@ -1543,6 +1546,20 @@ pub struct DelegationBroker { } impl DelegationBroker { + /// The caller must first validate direct conversation ancestry in the database. + /// Holding this lock through teardown makes claiming ownership and disconnect atomic. + pub async fn retain_child_for_messaging( + &self, + child_conversation_id: i32, + original_turn: Option, + ) { + self.retained_children + .lock() + .await + .entry(child_conversation_id) + .or_insert(original_turn); + } + pub fn new( spawner: Arc, depth_lookup: Arc, @@ -1584,6 +1601,7 @@ impl DelegationBroker { event_emitter: Arc, ) -> Self { Self { + retained_children: Arc::new(Mutex::new(std::collections::HashMap::new())), spawner, depth_lookup, meta_writer, @@ -2745,13 +2763,12 @@ impl DelegationBroker { parent_conversation_id: req.parent_conversation_id, parent_tool_use_id: req.parent_tool_use_id.clone(), delegation_call_id: call_id.clone(), - assistant: req - .assistant - .as_ref() - .map(|a| crate::acp::delegation::spawner::AssistantLink { + assistant: req.assistant.as_ref().map(|a| { + crate::acp::delegation::spawner::AssistantLink { id: a.id, snapshot: a.run_snapshot(), - }), + } + }), }; // The assistant's standing instructions ride the child's first prompt // as a trailing internal frame (see `render_assistant_brief`). @@ -3274,8 +3291,10 @@ impl DelegationBroker { outcome_to_summary(outcome, duration_ms), ) .await; - // v1 one-shot: always tear down the child. - let _ = self.spawner.disconnect(child_connection_id).await; + let retained = self.retained_children.lock().await; + if !retained.contains_key(&child_conversation_id) { + let _ = self.spawner.disconnect(child_connection_id).await; + } } /// Internal helper — apply the meta write iff the parent's @@ -3622,10 +3641,20 @@ impl DelegationBroker { }, ) .await; - if cancel_turn { - let _ = self.spawner.cancel(&task.child_connection_id).await; + let retained = self.retained_children.lock().await; + if let Some(original_turn) = retained.get(&task.child_conversation_id) { + if let Some(turn) = original_turn.filter(|_| cancel_turn) { + let _ = self + .spawner + .cancel_if_turn(&task.child_connection_id, turn) + .await; + } + } else { + if cancel_turn { + let _ = self.spawner.cancel(&task.child_connection_id).await; + } + let _ = self.spawner.disconnect(&task.child_connection_id).await; } - let _ = self.spawner.disconnect(&task.child_connection_id).await; } /// Backs the `get_delegation_status` tool for a single task id — a thin @@ -4139,13 +4168,16 @@ impl DelegationBroker { if inner.resuming.insert(req.task_id.clone()) { MemGate::Proceed { claimed: true } } else { - MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - status, - Some(child_conversation_id), - Some(agent_type), - "a resume of this task is already in progress.", - ).with_assistant(assistant.as_ref()))) + MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + status, + Some(child_conversation_id), + Some(agent_type), + "a resume of this task is already in progress.", + ) + .with_assistant(assistant.as_ref()), + )) } } TaskStatus::Completed => MemGate::Refuse(Box::new(not_resumable_report( @@ -4154,19 +4186,24 @@ impl DelegationBroker { Some(child_conversation_id), Some(agent_type), "the task already completed — resume only applies to a \ - canceled or interrupted task. Start a new \ - delegate_to_agent call for follow-up work.", - ).with_assistant(assistant.as_ref()))), - _ => MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - status, - Some(child_conversation_id), - Some(agent_type), - "the task ended in failure rather than being \ + canceled or interrupted task. Use send_to_subsession \ + with child_conversation_id for follow-up work in the same session.", + ) + .with_assistant(assistant.as_ref()), + )), + _ => MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + status, + Some(child_conversation_id), + Some(agent_type), + "the task ended in failure rather than being \ interrupted — resume only applies to a canceled or \ - interrupted task. Start a new delegate_to_agent call \ - to retry it.", - ).with_assistant(assistant.as_ref()))), + interrupted task. Use send_to_subsession with \ + child_conversation_id to retry in the same session.", + ) + .with_assistant(assistant.as_ref()), + )), } } } else if let Some(r) = inner.running.get(&req.task_id) { @@ -4182,7 +4219,9 @@ impl DelegationBroker { canceled or interrupted task. Poll it with \ get_delegation_status (or stop it with cancel_delegation) \ instead.", - ).with_assistant(r.assistant.as_ref()))) + ) + .with_assistant(r.assistant.as_ref()), + )) } } else { let claimed = inner.resuming.insert(req.task_id.clone()); @@ -4336,8 +4375,8 @@ impl DelegationBroker { Some(ctx.child_conversation_id), Some(ctx.agent_type), "the task already completed — resume only applies to a \ - canceled or interrupted task. Start a new delegate_to_agent \ - call for follow-up work.", + canceled or interrupted task. Use send_to_subsession with \ + child_conversation_id for follow-up work in the same session.", ); } TaskStatus::Failed | TaskStatus::Unknown => { @@ -4724,10 +4763,8 @@ impl DelegationBroker { let canceled_duration_ms = { let mut inner = self.pending.inner.lock().await; if inner.running.remove(&call_id).is_some() { - let outcome = canceled_outcome( - ctx.child_conversation_id, - "canceled before await", - ); + let outcome = + canceled_outcome(ctx.child_conversation_id, "canceled before await"); let duration_ms = started_at.elapsed().as_millis() as u64; inner.insert_completed( &call_id, @@ -5179,6 +5216,68 @@ mod tests { assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-conn-1"]); } + #[tokio::test] + async fn messaging_ownership_keeps_completed_child_connection() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("retained-child".into())).await; + mock.queue_send(Ok(42)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-retain")).await }) + }; + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::task::yield_now().await; + }; + broker.retain_child_for_messaging(42, Some(0)).await; + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "original result".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 1, + token_usage: None, + }), + ) + .await; + assert!(matches!(driver.await.unwrap(), DelegationOutcome::Ok(_))); + assert!(mock.disconnects.lock().await.is_empty()); + assert_eq!(broker.pending_count().await, 0); + } + + #[tokio::test] + async fn retained_original_task_is_still_canceled_without_disconnect() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("retained-child".into())).await; + mock.queue_send(Ok(42)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + let driver = { + let broker = broker.clone(); + tokio::spawn(async move { broker.handle_request(request(1, "pt-retain-cancel")).await }) + }; + loop { + if broker.peek_first_pending_call_id().await.is_some() { + break; + } + tokio::task::yield_now().await; + } + broker.retain_child_for_messaging(42, Some(0)).await; + broker.cancel_by_parent("parent-conn").await; + let _ = tokio::time::timeout(Duration::from_secs(1), driver).await; + assert_eq!(mock.cancels.lock().await.as_slice(), &["retained-child"]); + assert!(mock.disconnects.lock().await.is_empty()); + } + /// `StatusWait::Infinite` (the explicit `wait_ms = 0` escape hatch) must /// park while the task is still running rather than returning the running /// snapshot, then resolve to the terminal report once the task completes — @@ -7662,7 +7761,9 @@ mod tests { "parent-conn", "tc-keyed".into(), Some(DelegationMatchKey { - target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::ClaudeCode), + target: crate::acp::delegation::types::DelegationTarget::Agent( + AgentType::ClaudeCode, + ), task: "do x".into(), working_dir: None, }), @@ -10006,11 +10107,9 @@ mod tests { let mock = Arc::new(MockSpawner::new()); let lookup = Arc::new(MockResumeLookup::default()); *lookup.ctx.lock().await = ctx; - let broker = DelegationBroker::new( - mock.clone() as Arc, - shallow_lookup(), - ) - .with_status_lookup(lookup.clone() as Arc); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup.clone() as Arc); enable_delegation(&broker).await; (mock, lookup, broker) } @@ -10538,7 +10637,12 @@ mod tests { // Status keeps naming it; the terminal report and event do too. let report = broker - .get_task_status("parent-conn", Some(1), ack.task_id.as_deref().unwrap(), StatusWait::Immediate) + .get_task_status( + "parent-conn", + Some(1), + ack.task_id.as_deref().unwrap(), + StatusWait::Immediate, + ) .await; assert_eq!(report.assistant_id, Some(3)); broker @@ -10555,7 +10659,12 @@ mod tests { ) .await; let done = broker - .get_task_status("parent-conn", Some(1), ack.task_id.as_deref().unwrap(), StatusWait::Immediate) + .get_task_status( + "parent-conn", + Some(1), + ack.task_id.as_deref().unwrap(), + StatusWait::Immediate, + ) .await; assert_eq!(done.status, TaskStatus::Completed); assert_eq!(done.assistant_name.as_deref(), Some("Reviewer")); diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index a07065072b9..36eaddbe840 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -47,6 +47,7 @@ use tokio::sync::{oneshot, Mutex}; use crate::acp::chat_authoring::{ NewAutomationSpec, NewWorkTaskSpec, MAX_PROMPT_CHARS, MAX_TITLE_CHARS, }; +use crate::acp::delegation::assistants::{roster_for_schema, AssistantRosterEntry}; use crate::acp::delegation::transport::{ client_ask_round_trip, client_browser_act_round_trip, client_browser_capture_round_trip, client_browser_console_round_trip, client_browser_eval_round_trip, @@ -60,21 +61,24 @@ use crate::acp::delegation::transport::{ client_feedback_round_trip, client_list_assistants_round_trip, client_ping, client_result_schema_round_trip, client_resume_task_round_trip, client_round_trip, client_session_round_trip, client_status_round_trip, client_submit_result_round_trip, - client_task_complete_round_trip, client_task_progress_round_trip, BrokerAskRequest, - BrokerBrowserActRequest, BrokerBrowserCaptureRequest, BrokerBrowserConsoleRequest, - BrokerBrowserEvalRequest, BrokerBrowserSnapshotRequest, BrokerBrowserTabOpRequest, - BrokerBrowserTabsRequest, BrokerCancelRequest, BrokerCancelTaskRequest, - BrokerCommitFeedbackRequest, BrokerComputerActRequest, BrokerComputerAppsRequest, - BrokerComputerCaptureRequest, BrokerComputerClipboardRequest, BrokerComputerLaunchRequest, - BrokerComputerSnapshotRequest, BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, - BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, - BrokerListAssistantsRequest, BrokerRequest, BrokerResponse, BrokerResultSchemaRequest, - BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerSubmitResultRequest, + client_subsession_message_round_trip, client_task_complete_round_trip, + client_task_progress_round_trip, BrokerAskRequest, BrokerBrowserActRequest, + BrokerBrowserCaptureRequest, BrokerBrowserConsoleRequest, BrokerBrowserEvalRequest, + BrokerBrowserSnapshotRequest, BrokerBrowserTabOpRequest, BrokerBrowserTabsRequest, + BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, + BrokerComputerActRequest, BrokerComputerAppsRequest, BrokerComputerCaptureRequest, + BrokerComputerClipboardRequest, BrokerComputerLaunchRequest, BrokerComputerSnapshotRequest, + BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, BrokerCreateAutomationRequest, + BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerRequest, + BrokerResponse, BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, + BrokerStatusRequest, BrokerSubmitResultRequest, BrokerSubsessionMessageRequest, BrokerTaskCompleteRequest, BrokerTaskProgressRequest, }; -use crate::acp::delegation::assistants::{roster_for_schema, AssistantRosterEntry}; use crate::acp::question::parse_questions; use crate::acp::session_info::MAX_SESSION_MESSAGES; +use crate::acp::subsession_messages::{ + SubsessionDelivery, SubsessionMessageAction, SubsessionMessageRequest, +}; use crate::models::AutomationAction; /// Upper bound on one broker-side cancel round-trip. Bounds both @@ -317,7 +321,10 @@ impl CompanionFeatures { "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" - | "resume_delegation" => self.delegation, + | "resume_delegation" + | "send_to_subsession" + | "manage_subsession_messages" + | "send_to_parent" => self.delegation, _ => false, } } @@ -1051,6 +1058,67 @@ async fn build_tools_call_spawn( ) .await } + "send_to_subsession" | "manage_subsession_messages" => { + let input = match parse_subsession_message_request(&name, &arguments) { + Ok(input) => input, + Err(message) => return LineAction::Respond(err(id, -32602, message)), + }; + let req = BrokerSubsessionMessageRequest { + token: ctx.token.clone(), + input, + }; + let round_trip = + Box::pin(async move { client_subsession_message_round_trip(&socket, &req).await }); + // Persisted messages change only through explicit message management. + register_and_spawn( + inflight, + id, + None, + round_trip, + render_subsession_message_result, + ) + .await + } + "send_to_parent" => { + let args = match arguments.as_object() { + Some(args) => args, + None => return LineAction::Respond(err(id, -32602, "arguments must be an object")), + }; + if args.keys().any(|key| key != "message") { + return LineAction::Respond(err( + id, + -32602, + "send_to_parent accepts only message; the parent is resolved from your session", + )); + } + let message = match args.get("message").and_then(Value::as_str) { + Some(text) if !text.trim().is_empty() && text.chars().count() <= 32_000 => { + text.to_string() + } + _ => { + return LineAction::Respond(err( + id, + -32602, + "message must contain 1 to 32000 characters", + )) + } + }; + let req = super::transport::BrokerParentReplyRequest { + token: ctx.token.clone(), + message, + }; + let round_trip = Box::pin(async move { + super::transport::client_parent_reply_round_trip(&socket, &req).await + }); + register_and_spawn( + inflight, + id, + None, + round_trip, + render_subsession_message_result, + ) + .await + } "check_user_feedback" => { let req = BrokerFeedbackRequest { token: ctx.token.clone(), @@ -1146,7 +1214,14 @@ async fn build_tools_call_spawn( // activity strip, so a canceled call cannot read a page invisibly. let round_trip = Box::pin(async move { client_browser_snapshot_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_browser_snapshot_result).await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_browser_snapshot_result, + ) + .await } "browser_click" | "browser_hover" | "browser_type" | "browser_press_key" | "browser_select_option" => { @@ -1183,7 +1258,14 @@ async fn build_tools_call_spawn( // strip line are in there, as for the snapshot. let round_trip = Box::pin(async move { client_browser_console_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_browser_console_result).await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_browser_console_result, + ) + .await } "browser_screenshot" => { let (tab_id, request) = match browser_capture_request(&arguments) { @@ -1199,7 +1281,14 @@ async fn build_tools_call_spawn( // the codeg side, which is what leaves the line on the strip. let round_trip = Box::pin(async move { client_browser_capture_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_browser_capture_result).await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_browser_capture_result, + ) + .await } "browser_eval" => { let (tab_id, request) = match browser_eval_request(&arguments) { @@ -1298,8 +1387,14 @@ async fn build_tools_call_spawn( }; let round_trip = Box::pin(async move { client_computer_windows_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_computer_windows_result) - .await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_computer_windows_result, + ) + .await } "computer_screenshot" => { let (target_id, max_dimension) = match computer_capture_request(&arguments) { @@ -1315,8 +1410,14 @@ async fn build_tools_call_spawn( // on the codeg side, which is what writes the activity line. let round_trip = Box::pin(async move { client_computer_capture_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_computer_capture_result) - .await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_computer_capture_result, + ) + .await } "computer_snapshot" => { let (target_id, request) = match computer_snapshot_request(&arguments) { @@ -1330,8 +1431,14 @@ async fn build_tools_call_spawn( }; let round_trip = Box::pin(async move { client_computer_snapshot_round_trip(&socket, &req).await }); - register_and_spawn(inflight, id, None, round_trip, render_computer_snapshot_result) - .await + register_and_spawn( + inflight, + id, + None, + round_trip, + render_computer_snapshot_result, + ) + .await } "computer_verify" => { let (target_id, request) = match computer_verify_request(&arguments) { @@ -1480,6 +1587,77 @@ async fn build_tools_call_spawn( } } +fn parse_subsession_message_request( + name: &str, + arguments: &Value, +) -> Result { + let args = arguments.as_object().ok_or("arguments must be an object")?; + let allowed: &[&str] = if name == "send_to_subsession" { + &["child_conversation_id", "message", "delivery"] + } else { + &["child_conversation_id", "action", "message_id", "message"] + }; + if let Some(key) = args.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!("unknown argument: {key}")); + } + let child_conversation_id = args + .get("child_conversation_id") + .and_then(Value::as_i64) + .and_then(|id| i32::try_from(id).ok()) + .filter(|id| *id > 0) + .ok_or("child_conversation_id must be a positive integer")?; + let text = |key: &str| -> Result, String> { + match args.get(key) { + None => Ok(None), + Some(Value::String(value)) if !value.trim().is_empty() => Ok(Some(value.clone())), + _ => Err(format!("{key} must be a non-empty string")), + } + }; + let action = if name == "send_to_subsession" { + SubsessionMessageAction::Send + } else { + match args.get("action").and_then(Value::as_str) { + Some("list") => SubsessionMessageAction::List, + Some("update") => SubsessionMessageAction::Update, + Some("cancel") => SubsessionMessageAction::Cancel, + _ => return Err("action must be list, update, or cancel".into()), + } + }; + let message = text("message")?; + let message_id = text("message_id")?; + if matches!( + action, + SubsessionMessageAction::Send | SubsessionMessageAction::Update + ) && message.is_none() + { + return Err("message is required".into()); + } + if matches!( + action, + SubsessionMessageAction::Update | SubsessionMessageAction::Cancel + ) && message_id.is_none() + { + return Err("message_id is required".into()); + } + let delivery = match args.get("delivery") { + None => SubsessionDelivery::Queue, + Some(Value::String(value)) if value == "queue" => SubsessionDelivery::Queue, + Some(Value::String(value)) if value == "immediate" => SubsessionDelivery::Immediate, + _ => return Err("delivery must be queue or immediate".into()), + }; + Ok(SubsessionMessageRequest { + child_conversation_id, + action, + message, + message_id, + delivery, + }) +} + +fn render_subsession_message_result(outcome: &Value) -> Value { + json!({"content": [{"type": "text", "text": outcome.to_string()}], "structuredContent": outcome, "isError": false}) +} + /// Register the inflight entry and build the [`SpawnedCall`] that races the /// broker round-trip against the cancel signal. `external_handle` is `Some` /// only for the child-starting tools — `delegate_to_agent` and @@ -2404,12 +2582,10 @@ pub fn browser_console_query( // The schema says at least one; "zero lines" is not a number of lines // to ask for, and quietly reading it as the default would be doing // something other than what was asked. - Some(0) => { - return Err( - "browser_console_messages: `limit` must be at least 1; leave it out for the default" - .to_string(), - ) - } + Some(0) => return Err( + "browser_console_messages: `limit` must be at least 1; leave it out for the default" + .to_string(), + ), Some(n) => Some(usize::try_from(n).unwrap_or(usize::MAX)), }; let min_level = match arguments.get("minLevel").or_else(|| arguments.get("min_level")) { @@ -2468,19 +2644,18 @@ pub fn browser_capture_request( )), } }; - let (generation, target) = match (text("generation")?, text("ref")?) { - (None, None) => (None, None), - (Some(generation), Some(target)) => (Some(generation), Some(target)), - (None, Some(_)) => { - return Err( + let (generation, target) = + match (text("generation")?, text("ref")?) { + (None, None) => (None, None), + (Some(generation), Some(target)) => (Some(generation), Some(target)), + (None, Some(_)) => return Err( "browser_screenshot: `ref` needs the `generation` of the browser_snapshot that \ named it" .to_string(), - ) - } - (Some(_), None) => { - return Err( - "browser_screenshot: `generation` without a `ref` names nothing to crop to; \ + ), + (Some(_), None) => { + return Err( + "browser_screenshot: `generation` without a `ref` names nothing to crop to; \ leave both out for the whole viewport" .to_string(), ) @@ -2490,7 +2665,11 @@ pub fn browser_capture_request( None | Some(Value::Null) => None, Some(v) => Some( v.as_u64() - .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && *f > 0.0).map(|f| f as u64)) + .or_else(|| { + v.as_f64() + .filter(|f| f.fract() == 0.0 && *f > 0.0) + .map(|f| f as u64) + }) .filter(|n| *n > 0) .and_then(|n| u32::try_from(n).ok()) .ok_or_else(|| { @@ -2574,7 +2753,9 @@ pub fn browser_tab_op( match arguments.get(key) { Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.trim().to_string()), _ => Err(match key { - "tabId" => format!("{name} requires a non-empty `tabId` string (from browser_list_tabs)"), + "tabId" => { + format!("{name} requires a non-empty `tabId` string (from browser_list_tabs)") + } _ => format!("{name} requires a non-empty `{key}` string"), }), } @@ -3062,7 +3243,11 @@ fn computer_optional_u32(arguments: &Value, tool: &str, key: &str) -> Result Ok(None), Some(v) => v .as_u64() - .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && *f >= 0.0).map(|f| f as u64)) + .or_else(|| { + v.as_f64() + .filter(|f| f.fract() == 0.0 && *f >= 0.0) + .map(|f| f as u64) + }) .and_then(|n| u32::try_from(n).ok()) .map(Some) .ok_or_else(|| format!("{tool}: `{key}` must be a whole non-negative number, not {v}")), @@ -3120,8 +3305,9 @@ pub fn computer_verify_request( .iter() .enumerate() .map(|(i, item)| { - serde_json::from_value::(item.clone()) - .map_err(|e| format!("{tool}: `expect[{i}]` is not a predicate this tool knows: {e}")) + serde_json::from_value::(item.clone()).map_err(|e| { + format!("{tool}: `expect[{i}]` is not a predicate this tool knows: {e}") + }) }) .collect::>()?, _ => { @@ -3258,7 +3444,11 @@ pub fn render_computer_windows_result(outcome: &Value) -> Value { for w in windows { let s = |k: &str| w.get(k).and_then(Value::as_str).unwrap_or(""); let app = w.get("app"); - let app_s = |k: &str| app.and_then(|a| a.get(k)).and_then(Value::as_str).unwrap_or(""); + let app_s = |k: &str| { + app.and_then(|a| a.get(k)) + .and_then(Value::as_str) + .unwrap_or("") + }; let b = |k: &str| { w.get("bounds") .and_then(|b| b.get(k)) @@ -4498,17 +4688,27 @@ mod tests { } #[tokio::test] - async fn tools_list_returns_four_delegation_tools() { + async fn tools_list_returns_seven_delegation_tools() { let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; let resp = unwrap_respond(dispatch_for_test(line).await); let result = resp.result.unwrap(); let tools = result["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 4); + assert_eq!(tools.len(), 7); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"delegate_to_agent")); assert!(names.contains(&"get_delegation_status")); assert!(names.contains(&"cancel_delegation")); assert!(names.contains(&"resume_delegation")); + assert!(names.contains(&"send_to_subsession")); + assert!(names.contains(&"manage_subsession_messages")); + let send = tools + .iter() + .find(|tool| tool["name"] == "send_to_subsession") + .unwrap(); + assert_eq!( + send["inputSchema"]["properties"]["delivery"]["default"], + "queue" + ); // resume_delegation requires only task_id; reason is optional and // there is deliberately NO task-text parameter (no new iterations). let resume = tools @@ -5172,7 +5372,7 @@ mod tests { dispatch_for_test(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await, ); assert!(!names.contains(&"check_user_feedback".to_string())); - assert_eq!(names.len(), 4); + assert_eq!(names.len(), 7); } #[tokio::test] @@ -5181,7 +5381,7 @@ mod tests { dispatch_with_features(BOTH, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await, ); assert!(names.contains(&"check_user_feedback".to_string())); - assert_eq!(names.len(), 5); + assert_eq!(names.len(), 8); } #[tokio::test] @@ -5261,6 +5461,115 @@ mod tests { assert_eq!(resp.error.unwrap().code, -32602); } + #[test] + fn subsession_parsing_defaults_to_queue_and_accepts_immediate() { + let args = json!({"child_conversation_id": 42, "message": "continue"}); + let request = parse_subsession_message_request("send_to_subsession", &args).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap()["delivery"], "queue"); + let args = + json!({"child_conversation_id": 42, "message": "change", "delivery": "immediate"}); + let request = parse_subsession_message_request("send_to_subsession", &args).unwrap(); + assert_eq!( + serde_json::to_value(request).unwrap()["delivery"], + "immediate" + ); + let outcome = json!({"accepted": true, "messages": [{"id": "m1", "state": "queued", "result": null}]}); + let rendered = render_subsession_message_result(&outcome); + assert_eq!(rendered["structuredContent"], outcome); + assert_eq!( + serde_json::from_str::(rendered["content"][0]["text"].as_str().unwrap()) + .unwrap(), + outcome + ); + } + + #[tokio::test] + async fn subsession_validation_and_delegation_gating() { + for (name, arguments) in [ + ( + "send_to_subsession", + json!({"child_conversation_id": 0, "message": "hi"}), + ), + ( + "send_to_subsession", + json!({"child_conversation_id": "1", "message": "hi"}), + ), + ( + "send_to_subsession", + json!({"child_conversation_id": 1, "message": " "}), + ), + ( + "send_to_subsession", + json!({"child_conversation_id": 1, "message": "hi", "delivery": "now"}), + ), + ( + "send_to_subsession", + json!({"child_conversation_id": 1, "message": "hi", "extra": true}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "send"}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "cancel"}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "update", "message_id": "m1"}), + ), + ] { + let line = json!({"jsonrpc":"2.0", "id":88, "method":"tools/call", "params":{"name":name,"arguments":arguments}}).to_string(); + assert_eq!( + unwrap_respond(dispatch_for_test(&line).await) + .error + .unwrap() + .code, + -32602 + ); + } + for name in ["send_to_subsession", "manage_subsession_messages"] { + let line = json!({"jsonrpc":"2.0", "id":89, "method":"tools/call", "params":{"name":name,"arguments":{"child_conversation_id":1,"message":"hi","action":"list"}}}).to_string(); + let response = unwrap_respond(dispatch_with_features(FEEDBACK_ONLY, &line).await); + assert!(response.error.unwrap().message.contains("unknown tool")); + } + for (name, args) in [ + ( + "send_to_subsession", + json!({"child_conversation_id": 1, "message": "hi"}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "list"}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "cancel", "message_id":"m1"}), + ), + ( + "manage_subsession_messages", + json!({"child_conversation_id": 1, "action": "update", "message_id":"m1", "message":"new"}), + ), + ] { + let line = json!({"jsonrpc":"2.0", "id":90, "method":"tools/call", "params":{"name":name,"arguments":args}}).to_string(); + assert!(matches!( + dispatch_for_test(&line).await, + LineAction::Spawn(_) + )); + } + } + + #[tokio::test] + async fn parent_reports_cannot_select_a_recipient_and_require_delegation() { + for arguments in [json!({}), json!({"message":" "}), json!({"message":42}), json!({"message":"report", "parent_conversation_id":99})] { + let line = json!({"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"send_to_parent","arguments":arguments}}).to_string(); + assert_eq!(unwrap_respond(dispatch_for_test(&line).await).error.unwrap().code, -32602); + } + let line = json!({"jsonrpc":"2.0","id":82,"method":"tools/call","params":{"name":"send_to_parent","arguments":{"message":"report"}}}).to_string(); + assert!(matches!(dispatch_for_test(&line).await, LineAction::Spawn(_))); + assert!(unwrap_respond(dispatch_with_features(FEEDBACK_ONLY, &line).await).error.unwrap().message.contains("unknown tool")); + } + // -- resume_delegation validation + feature gating ----------------------- #[tokio::test] @@ -6551,14 +6860,14 @@ mod tests { assert!(browser_eval_request(&json!({ "tabId": "t1" })) .unwrap_err() .contains("`code`")); - assert!(browser_eval_request(&json!({ "tabId": "t1", "code": " " })) - .unwrap_err() - .contains("`code`")); assert!( - browser_eval_request(&json!({ "tabId": "t1", "code": 42 })) + browser_eval_request(&json!({ "tabId": "t1", "code": " " })) .unwrap_err() - .contains("must be a string") + .contains("`code`") ); + assert!(browser_eval_request(&json!({ "tabId": "t1", "code": 42 })) + .unwrap_err() + .contains("must be a string")); let long = "a".repeat(MAX_EVAL_CODE_CHARS + 1); assert!( browser_eval_request(&json!({ "tabId": "t1", "code": long })) @@ -6865,11 +7174,9 @@ mod tests { ); // A single string is taken as a list of one, since that is how a model // that forgot the brackets meant it. - let (_, req) = browser_action_request( - "browser_select_option", - &with(json!({ "values": "l" })), - ) - .unwrap(); + let (_, req) = + browser_action_request("browser_select_option", &with(json!({ "values": "l" }))) + .unwrap(); assert_eq!( req.action, ActionKind::Select { @@ -6906,7 +7213,12 @@ mod tests { .contains("`values`")); // Every tool but press needs a ref; every tool needs the generation. - for name in ["browser_click", "browser_hover", "browser_type", "browser_select_option"] { + for name in [ + "browser_click", + "browser_hover", + "browser_type", + "browser_select_option", + ] { let err = browser_action_request( name, &json!({ "tabId": "t1", "generation": "g", "text": "x", "values": ["v"] }), @@ -6958,7 +7270,11 @@ mod tests { ("browser_open_tab", json!({}), "`url`"), ("browser_open_tab", json!({ "url": " " }), "`url`"), ("browser_open_tab", json!({ "url": 7 }), "`url`"), - ("browser_navigate", json!({ "url": "https://x.test/" }), "`tabId`"), + ( + "browser_navigate", + json!({ "url": "https://x.test/" }), + "`tabId`", + ), ("browser_navigate", json!({ "tabId": "t1" }), "`url`"), ("browser_close_tab", json!({}), "`tabId`"), ] { @@ -7454,10 +7770,9 @@ mod tests { ); assert!(computer_capture_request(&json!({ "targetId": "w3", "maxDimension": 0 })).is_err()); - let (id, req) = computer_snapshot_request( - &json!({ "targetId": "w1", "maxChars": 0, "query": "Save" }), - ) - .unwrap(); + let (id, req) = + computer_snapshot_request(&json!({ "targetId": "w1", "maxChars": 0, "query": "Save" })) + .unwrap(); assert_eq!(id, "w1"); // 0 is "no cap", and passes through as such. assert_eq!(req.max_chars, Some(0)); @@ -7698,8 +8013,16 @@ mod tests { } ); for (tool, bad, says) in [ - ("computer_click", json!({ "targetId": "w1", "ref": 3 }), "generation"), - ("computer_click", json!({ "targetId": "w1" }), "requires `ref`"), + ( + "computer_click", + json!({ "targetId": "w1", "ref": 3 }), + "generation", + ), + ( + "computer_click", + json!({ "targetId": "w1" }), + "requires `ref`", + ), ( "computer_click", json!({ "targetId": "w1", "ref": 3, "coordinate": [1, 2], "generation": "1.1" }), @@ -8229,7 +8552,11 @@ mod tests { assert_eq!(*seen.lock().await, vec!["ping"], "never forwarded"); // A plain agent call is forwarded as always, without another probe. - let result = drive_call(&ctx, &assistant_call(json!({"agent_type": "codex", "task": "x"}))).await; + let result = drive_call( + &ctx, + &assistant_call(json!({"agent_type": "codex", "task": "x"})), + ) + .await; assert_eq!(result["structuredContent"]["status"], "running"); assert_eq!(*seen.lock().await, vec!["ping", "call"]); } @@ -8260,7 +8587,11 @@ mod tests { #[tokio::test] async fn an_assistant_call_with_no_answer_from_the_parent_is_retryable() { // The default test socket does not exist: no answer at all. - let result = drive_call(&ctx(), &assistant_call(json!({"assistant_id": 3, "task": "x"}))).await; + let result = drive_call( + &ctx(), + &assistant_call(json!({"assistant_id": 3, "task": "x"})), + ) + .await; assert_eq!(result["isError"], true); assert_eq!( result["structuredContent"]["error_code"], diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 2645604487c..884280e5383 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -15,41 +15,40 @@ use async_trait::async_trait; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::RwLock; -use crate::acp::delegation::broker::{DelegationBroker, StatusWait}; use crate::acp::browser_tools::{ BrowserActOutcome, BrowserCaptureOutcome, BrowserConsoleOutcome, BrowserEvalOutcome, BrowserSnapshotOutcome, BrowserTabOutcome, BrowserTabsOutcome, BrowserToolAccess, ERROR_NO_SUCH_TAB, }; +use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess}; use crate::acp::computer_tools::{ ComputerActOutcome, ComputerAppsOutcome, ComputerCaptureOutcome, ComputerSnapshotOutcome, ComputerToolAccess, ComputerVerifyOutcome, ComputerWindowsOutcome, ERROR_NO_SUCH_TARGET, }; +use crate::acp::delegation::assistants::assistant_not_found_message; +use crate::acp::delegation::broker::{DelegationBroker, StatusWait}; use crate::acp::delegation::transport::{ read_frame, write_frame, BrokerAskRequest, BrokerBrowserActRequest, BrokerBrowserCaptureRequest, BrokerBrowserConsoleRequest, BrokerBrowserEvalRequest, BrokerBrowserSnapshotRequest, BrokerBrowserTabOpRequest, BrokerBrowserTabsRequest, - BrokerCancelRequest, BrokerCancelTaskRequest, BrokerComputerActRequest, - BrokerComputerAppsRequest, BrokerComputerCaptureRequest, BrokerComputerLaunchRequest, - BrokerComputerSnapshotRequest, - BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, BrokerCommitFeedbackRequest, + BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, + BrokerComputerActRequest, BrokerComputerAppsRequest, BrokerComputerCaptureRequest, + BrokerComputerLaunchRequest, BrokerComputerSnapshotRequest, BrokerComputerVerifyRequest, + BrokerComputerWindowsRequest, BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerMessage, BrokerRequest, - BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerResponse, - BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, + BrokerResponse, BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerSubmitResultRequest, BrokerTaskCompleteRequest, BrokerTaskProgressRequest, }; -use crate::acp::delegation::assistants::assistant_not_found_message; use crate::acp::delegation::types::{ delegation_target_from_args, DelegationRequest, DelegationTarget, DelegationTargetError, DelegationTaskReport, ResumeDelegationRequest, TaskStatus, }; use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess}; use crate::acp::question::{QuestionOutcome, SessionQuestionAccess}; +use crate::acp::result_tool::ResultToolAccess; #[cfg(unix)] use crate::acp::scratch_dir::{is_inside_sweep_scope, SUN_PATH_CAP}; -use crate::acp::chat_authoring::{AuthoringContext, AuthoringOutcome, ChatAuthoringAccess}; -use crate::acp::result_tool::ResultToolAccess; use crate::acp::session_info::{SessionInfo, SessionInfoAccess}; use crate::acp::work_task_tools::{TaskReportAck, WorkTaskToolAccess}; use serde_json::Value; @@ -60,7 +59,6 @@ use serde_json::Value; /// `wait_ms = 0` opts out of the ceiling and blocks until the task is terminal. const STATUS_WAIT_MAX_MS: u64 = 60_000; - /// The bound-but-not-yet-served socket handed from [`DelegationListener::bind`] /// to [`DelegationListener::accept_loop`]. A UDS listener on unix; on Windows, /// the first named-pipe server instance (the loop creates each subsequent one @@ -141,6 +139,7 @@ pub struct TokenStats { } pub struct DelegationListener { + pub subsession_messages: Arc, pub broker: Arc, pub tokens: Arc, pub parent_lookup: Arc, @@ -195,8 +194,10 @@ impl DelegationListener { browser: Arc, computer: Arc, results: Arc, + subsession_messages: Arc, ) -> Arc { Arc::new(Self { + subsession_messages, broker, tokens, parent_lookup, @@ -477,6 +478,18 @@ impl DelegationListener { } BrokerMessage::CancelTask(req) => report_response(self.process_cancel_task(req).await)?, BrokerMessage::ResumeTask(req) => report_response(self.process_resume_task(req).await)?, + BrokerMessage::SubsessionMessage(req) => { + let outcome = self.process_subsession_message(req).await; + BrokerResponse { + outcome: serde_json::to_value(outcome).map_err(std::io::Error::other)?, + } + } + BrokerMessage::ParentReply(req) => { + let outcome = self.process_parent_reply(req).await; + BrokerResponse { + outcome: serde_json::to_value(outcome).map_err(std::io::Error::other)?, + } + } BrokerMessage::Feedback(req) => { // at-least-once delivery: READ pending notes (no mutation), // WRITE the response, and COMMIT them delivered ONLY on a @@ -847,6 +860,57 @@ impl DelegationListener { .await } + async fn process_subsession_message( + &self, + req: super::transport::BrokerSubsessionMessageRequest, + ) -> crate::acp::subsession_messages::SubsessionMessageOutcome { + use crate::acp::subsession_messages::SubsessionMessageOutcome; + let Some(entry) = self.tokens.lookup(&req.token).await else { + return SubsessionMessageOutcome::refused("unavailable", "Subsession is unavailable"); + }; + if !self.broker.config_snapshot().await.enabled + && req.input.action == crate::acp::subsession_messages::SubsessionMessageAction::Send + { + return SubsessionMessageOutcome::refused("disabled", "Delegation is disabled"); + } + let Some(parent) = self + .parent_lookup + .current_conversation_id(&entry.parent_connection_id) + .await + else { + return SubsessionMessageOutcome::refused( + "unavailable", + "Parent session is unavailable", + ); + }; + self.subsession_messages + .request(&entry.parent_connection_id, parent, req.input) + .await + } + + async fn process_parent_reply( + &self, + req: super::transport::BrokerParentReplyRequest, + ) -> crate::acp::parent_replies::ParentReplyOutcome { + use crate::acp::parent_replies::ParentReplyOutcome; + let Some(entry) = self.tokens.lookup(&req.token).await else { + return ParentReplyOutcome::refused("Parent messaging is unavailable"); + }; + if !self.broker.config_snapshot().await.enabled { + return ParentReplyOutcome::refused("Delegation is disabled"); + } + let Some(child) = self + .parent_lookup + .current_conversation_id(&entry.parent_connection_id) + .await + else { + return ParentReplyOutcome::refused("Child session is unavailable"); + }; + self.subsession_messages + .send_to_parent(child, req.message) + .await + } + /// Validate the token and resolve the `check_user_feedback` target: the /// caller's parent connection id. `None` on an invalid token — the LLM can't /// usefully distinguish "no notes" from "bad token", and we don't leak which. @@ -1409,9 +1473,7 @@ fn browser_tabs_response(outcome: BrowserTabsOutcome) -> std::io::Result std::io::Result { +fn browser_snapshot_response(outcome: BrowserSnapshotOutcome) -> std::io::Result { Ok(BrokerResponse { outcome: serde_json::to_value(&outcome).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) @@ -1754,16 +1816,16 @@ pub fn default_socket_path(_temp_dir: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; - #[cfg(unix)] - use crate::acp::scratch_dir::short_root; + use crate::acp::browser_tools::{NoBrowserTabs, ERROR_GRANT_REQUIRED}; + use crate::acp::computer_tools::NoComputerDesktop; use crate::acp::delegation::broker::{ConversationDepthLookup, DelegationConfig}; - use crate::models::AgentType; use crate::acp::delegation::spawner::{ mock::MockSpawner, ConnectionSpawner, ResumedSpawn, SpawnerError, }; - use crate::acp::browser_tools::{NoBrowserTabs, ERROR_GRANT_REQUIRED}; - use crate::acp::computer_tools::NoComputerDesktop; use crate::acp::delegation::types::{DelegationError, DelegationOutcome, DelegationSuccess}; + #[cfg(unix)] + use crate::acp::scratch_dir::short_root; + use crate::models::AgentType; use serde_json::json; use std::time::Duration; use tokio::io::duplex; @@ -1797,10 +1859,7 @@ mod tests { } #[async_trait] impl SessionFeedbackAccess for StubFeedback { - async fn read_pending_feedback( - &self, - parent_connection_id: &str, - ) -> Vec { + async fn read_pending_feedback(&self, parent_connection_id: &str) -> Vec { *self.read_conn.lock().await = Some(parent_connection_id.to_string()); self.items.lock().await.clone() } @@ -1820,9 +1879,7 @@ mod tests { #[derive(Default)] struct StubQuestion { pending: tokio::sync::Mutex>>, - registered: tokio::sync::Mutex< - Vec<(String, Vec)>, - >, + registered: tokio::sync::Mutex)>>, canceled: tokio::sync::Mutex>, } #[async_trait] @@ -1907,11 +1964,7 @@ mod tests { note: None, } } - async fn snapshot( - &self, - tab_id: &str, - max_chars: Option, - ) -> BrowserSnapshotOutcome { + async fn snapshot(&self, tab_id: &str, max_chars: Option) -> BrowserSnapshotOutcome { self.calls .lock() .await @@ -1965,10 +2018,7 @@ mod tests { )); BrowserActOutcome::control_required(tab_id) } - async fn tab_op( - &self, - op: crate::acp::browser_tools::BrowserTabOp, - ) -> BrowserTabOutcome { + async fn tab_op(&self, op: crate::acp::browser_tools::BrowserTabOp) -> BrowserTabOutcome { self.calls .lock() .await @@ -2082,6 +2132,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2107,6 +2158,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2133,6 +2185,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2158,6 +2211,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2185,6 +2239,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2211,6 +2266,7 @@ mod tests { browser, Arc::new(NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -2263,6 +2319,124 @@ mod tests { assert!(report.message.unwrap().contains("does not match")); } + #[tokio::test] + async fn subsession_messages_resolve_the_authenticated_parent_and_allow_queue_cancel_when_disabled( + ) { + use crate::acp::subsession_messages::{ + SubsessionDelivery, SubsessionMessageAccess, SubsessionMessageAction, + SubsessionMessageOutcome, SubsessionMessageRequest, + }; + #[derive(Default)] + struct Access(std::sync::atomic::AtomicUsize); + #[async_trait] + impl SubsessionMessageAccess for Access { + async fn request( + &self, + owner: &str, + parent: i32, + _: SubsessionMessageRequest, + ) -> SubsessionMessageOutcome { + assert_eq!(owner, "parent-conn"); + assert_eq!(parent, 42); + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + SubsessionMessageOutcome { + accepted: true, + messages: vec![], + error_code: None, + message: None, + } + } + } + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let broker = make_broker(Arc::new(MockSpawner::new())).await; + let access = Arc::new(Access::default()); + let mut listener = make_listener(broker.clone(), tokens, Some(42)); + Arc::get_mut(&mut listener).unwrap().subsession_messages = access.clone(); + let make_request = + |token: &str, action| super::super::transport::BrokerSubsessionMessageRequest { + token: token.into(), + input: SubsessionMessageRequest { + child_conversation_id: 77, + action, + message: Some("next".into()), + message_id: Some("queued-id".into()), + delivery: SubsessionDelivery::Queue, + }, + }; + assert!( + !listener + .process_subsession_message(make_request("bad", SubsessionMessageAction::Send)) + .await + .accepted + ); + assert_eq!(access.0.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!( + listener + .process_subsession_message(make_request("tok", SubsessionMessageAction::Send)) + .await + .accepted + ); + broker + .set_config(DelegationConfig { + enabled: false, + ..Default::default() + }) + .await; + assert!( + !listener + .process_subsession_message(make_request("tok", SubsessionMessageAction::Send)) + .await + .accepted + ); + assert!( + listener + .process_subsession_message(make_request("tok", SubsessionMessageAction::Cancel)) + .await + .accepted + ); + assert_eq!(access.0.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn parent_reports_use_authenticated_child_and_reject_invalid_tokens() { + use crate::acp::{parent_replies::ParentReplyOutcome, subsession_messages::{SubsessionMessageAccess, SubsessionMessageOutcome, SubsessionMessageRequest}}; + #[derive(Default)] + struct Access(std::sync::atomic::AtomicUsize); + #[async_trait] + impl SubsessionMessageAccess for Access { + async fn request(&self, _: &str, _: i32, _: SubsessionMessageRequest) -> SubsessionMessageOutcome { + unreachable!() + } + async fn send_to_parent(&self, child: i32, message: String) -> ParentReplyOutcome { + assert_eq!(child, 42); + assert_eq!(message, "report"); + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + ParentReplyOutcome { accepted: true, message: None, error: None } + } + } + let tokens = Arc::new(TokenRegistry::default()); + tokens.register("tok".into(), TokenEntry { parent_connection_id: "child-conn".into(), working_dir: PathBuf::from("/tmp") }).await; + let broker = make_broker(Arc::new(MockSpawner::new())).await; + let access = Arc::new(Access::default()); + let mut listener = make_listener(broker.clone(), tokens, Some(42)); + Arc::get_mut(&mut listener).unwrap().subsession_messages = access.clone(); + let request = |token: &str| super::super::transport::BrokerParentReplyRequest { token: token.into(), message: "report".into() }; + assert!(!listener.process_parent_reply(request("invalid")).await.accepted); + assert!(listener.process_parent_reply(request("tok")).await.accepted); + broker.set_config(DelegationConfig { enabled: false, ..Default::default() }).await; + assert!(!listener.process_parent_reply(request("tok")).await.accepted); + assert_eq!(access.0.load(std::sync::atomic::Ordering::SeqCst), 1); + } + #[tokio::test] async fn missing_parent_conversation_rejected() { let tokens = Arc::new(TokenRegistry::default()); @@ -3549,7 +3723,9 @@ mod tests { let listed = browser_round_trip( listener.clone(), - BrokerMessage::BrowserTabs(BrokerBrowserTabsRequest { token: "tok".into() }), + BrokerMessage::BrowserTabs(BrokerBrowserTabsRequest { + token: "tok".into(), + }), ) .await; assert_eq!(listed.outcome["tabs"][0]["tabId"], "t1"); @@ -4126,6 +4302,7 @@ mod tests { Arc::new(NoBrowserTabs), computer, Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ) } @@ -4313,7 +4490,9 @@ mod tests { mock as Arc, Arc::new(AlwaysRootLookup) as Arc, ) - .with_assistants(Arc::new(StubAssistants(vec![assistant_info(3, "Reviewer")]))), + .with_assistants(Arc::new(StubAssistants(vec![assistant_info( + 3, "Reviewer", + )]))), ); broker .set_config(DelegationConfig { @@ -4386,7 +4565,11 @@ mod tests { async fn an_unusable_assistant_is_refused_with_the_usable_roster() { let mock = Arc::new(MockSpawner::new()); let (listener, _tokens) = assistant_listener(mock.clone()).await; - let refusal = serve(listener.clone(), call(json!({"assistant_id": 9, "task": "x"}))).await; + let refusal = serve( + listener.clone(), + call(json!({"assistant_id": 9, "task": "x"})), + ) + .await; assert_eq!(refusal["status"], "failed"); assert_eq!(refusal["error_code"], "assistant_not_found"); let message = refusal["message"].as_str().unwrap(); @@ -4511,6 +4694,7 @@ mod tests { Arc::new(NoBrowserTabs), Arc::new(NoComputerDesktop), results.clone(), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ); let ask = |token: &str| { BrokerMessage::ResultSchema(BrokerResultSchemaRequest { diff --git a/src-tauri/src/acp/delegation/service.rs b/src-tauri/src/acp/delegation/service.rs index b038e6e7077..9ffdfbb6f6f 100644 --- a/src-tauri/src/acp/delegation/service.rs +++ b/src-tauri/src/acp/delegation/service.rs @@ -500,6 +500,7 @@ mod tests { Arc::new(Stub), Arc::new(crate::acp::computer_tools::NoComputerDesktop), Arc::new(crate::acp::result_tool::NoResultTool), + Arc::new(crate::acp::subsession_messages::NoSubsessionMessages), ); DelegationService::new(listener, socket_path) } diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs index 739a63b588b..74267c7906a 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -184,6 +184,15 @@ pub trait ConnectionSpawner: Send + Sync { /// calling on a connection with nothing in flight is a no-op success. async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError>; + /// Cancel only the retained original turn, never a later messaging prompt. + async fn cancel_if_turn( + &self, + conn_id: &str, + _turns_completed: u64, + ) -> Result<(), SpawnerError> { + self.cancel(conn_id).await + } + /// Tear down the child connection. Always called after the broker has /// resolved (or failed) the pending call, to enforce v1's one-shot /// semantics. @@ -400,9 +409,7 @@ pub mod mock { .lock() .await .pop_front() - .unwrap_or_else(|| { - Err(SpawnerError::Spawn("no queued resume spawn result".into())) - }) + .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued resume spawn result".into()))) } async fn send_resume_prompt( diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 13eb0c243bd..d1fad599718 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -1,7 +1,7 @@ [ { "name": "delegate_to_agent", - "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", + "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. For follow-up work in this SAME child context, use send_to_subsession with its child_conversation_id. Manage queued messages and collect follow-up results with manage_subsession_messages; do not create another child for each reply. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", "inputSchema": { "type": "object", "required": [ @@ -89,7 +89,7 @@ }, { "name": "resume_delegation", - "description": "Resume a delegated task that was INTERRUPTED — canceled, or cut off when a session/app terminated unexpectedly — so its sub-agent picks the ORIGINAL task back up in its own session with all prior context and work intact, under the SAME task_id (keep collecting it with get_delegation_status, or stop it again with cancel_delegation). STRICT SCOPE: this tool ONLY revives interrupted work; it takes no task text and must NOT be used to iterate on, extend, retry, or redo a task. A task that is still running, or that already finished on its own (completed or failed), is refused with error_code \"not_resumable\" and its actual status — for follow-up or retry work, start a fresh delegate_to_agent call instead. The optional `reason` is context about WHY the run is being resumed (e.g. \"the app was killed mid-run\"), passed to the sub-agent as interruption context — never new instructions.", + "description": "Resume a delegated task that was INTERRUPTED — canceled, or cut off when a session/app terminated unexpectedly — so its sub-agent picks the ORIGINAL task back up in its own session with all prior context and work intact, under the SAME task_id (keep collecting it with get_delegation_status, or stop it again with cancel_delegation). STRICT SCOPE: this tool ONLY revives interrupted work; it takes no task text and must NOT be used to iterate on, extend, retry, or redo a task. A task that is still running, or that already finished on its own (completed or failed), is refused with error_code \"not_resumable\" and its actual status — for follow-up or retry work in the same child context, use send_to_subsession with its child_conversation_id instead. The optional `reason` is context about WHY the run is being resumed (e.g. \"the app was killed mid-run\"), passed to the sub-agent as interruption context — never new instructions.", "inputSchema": { "type": "object", "required": [ @@ -117,7 +117,7 @@ }, { "name": "ask_user_question", - "description": "Ask the user one or more multiple-choice questions and BLOCK until they answer. The questions render as interactive cards above the user's input box; this call returns only once the user submits their choices (or dismisses the card). Use ONLY when you are blocked on a decision that is genuinely the user's to make — one you cannot resolve from the request, the code, or sensible defaults (e.g. which approach, which format, which library, which of several files). Do NOT use it to ask 'should I proceed?' or to confirm an obvious default, and do NOT use it for open-ended questions that aren't a small set of discrete choices. The user can always pick 'Other' and type a custom answer, so don't add your own 'Other' option. If you want to recommend a choice, put it first and append ' (Recommended)' to its label. Prefer ONE call with a few related questions over many sequential calls. Once the user has answered \u2014 including picking a recommended option or telling you to proceed \u2014 act on the answer immediately. Do NOT call this tool again to confirm execution or to ask whether you should proceed.", + "description": "Ask the user one or more multiple-choice questions and BLOCK until they answer. The questions render as interactive cards above the user's input box; this call returns only once the user submits their choices (or dismisses the card). Use ONLY when you are blocked on a decision that is genuinely the user's to make — one you cannot resolve from the request, the code, or sensible defaults (e.g. which approach, which format, which library, which of several files). Do NOT use it to ask 'should I proceed?' or to confirm an obvious default, and do NOT use it for open-ended questions that aren't a small set of discrete choices. The user can always pick 'Other' and type a custom answer, so don't add your own 'Other' option. If you want to recommend a choice, put it first and append ' (Recommended)' to its label. Prefer ONE call with a few related questions over many sequential calls. Once the user has answered — including picking a recommended option or telling you to proceed — act on the answer immediately. Do NOT call this tool again to confirm execution or to ask whether you should proceed.", "inputSchema": { "type": "object", "required": [ @@ -1392,5 +1392,126 @@ } } } + }, + { + "name": "send_to_parent", + "description": "Proactively report progress, a question, or a result to the parent that delegated this session. Only delegated child sessions can use this tool. The parent is inferred from your authenticated session; do not supply any conversation ID. Returns immediately with a durable queued message, so neither side waits for the other. If the parent is busy, the report waits until its current turn finishes; if idle, it automatically starts a new turn in the SAME parent session. The parent's reply is not returned by this tool: the parent can answer with send_to_subsession. This is an explicit report, not automatic forwarding of every child response. Avoid acknowledgment loops: report when there is useful information or a request for input.", + "inputSchema": { + "type": "object", + "required": [ + "message" + ], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 32000, + "description": "Report content. Codeg labels it with your child conversation ID." + } + } + } + }, + { + "name": "send_to_subsession", + "description": "Send a new message to an existing child conversation in the same child context, agent, and working directory. Returns message_id and per-message state/result. delivery defaults to queue: wait until the current round completes. Explicit immediate uses native steering when available; otherwise cancel the current round, wait for it to finish, then send in the same context. Read subsequent state/results with manage_subsession_messages.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "child_conversation_id", + "message" + ], + "properties": { + "child_conversation_id": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "message": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "delivery": { + "type": "string", + "enum": [ + "queue", + "immediate" + ], + "default": "queue" + } + } + } + }, + { + "name": "manage_subsession_messages", + "description": "List messages and their individual states/results for an existing child conversation. Update or cancel only queued messages that have not started; message_id is required for update/cancel and message is required for update. Started messages cannot be edited or canceled by this queue-management tool. The child keeps its existing context.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [ + "child_conversation_id", + "action" + ], + "properties": { + "child_conversation_id": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "action": { + "type": "string", + "enum": [ + "list", + "update", + "cancel" + ] + }, + "message_id": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "message": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + } + }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "enum": [ + "update", + "cancel" + ] + } + } + }, + "then": { + "required": [ + "message_id" + ] + } + }, + { + "if": { + "properties": { + "action": { + "const": "update" + } + } + }, + "then": { + "required": [ + "message" + ] + } + } + ] + } } ] diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index c9bdab29da3..7a307a60d46 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -62,6 +62,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::acp::chat_authoring::{NewAutomationSpec, NewWorkTaskSpec}; use crate::acp::question::QuestionSpec; +use crate::acp::subsession_messages::SubsessionMessageRequest; /// One delegation call's worth of input forwarded from the companion to the /// main process. The main process re-validates `token` and maps @@ -154,6 +155,19 @@ pub struct BrokerResumeTaskRequest { pub external_handle: Option, } +/// Explicit persistent child-message operation; cancellation only suppresses its reply. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerSubsessionMessageRequest { + pub token: String, + pub input: SubsessionMessageRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerParentReplyRequest { + pub token: String, + pub message: String, +} + /// Pull the pending live-feedback notes for the parent session. Backs the /// `check_user_feedback` MCP tool. Authenticated by the same per-launch /// `token`; the listener resolves the parent connection from it and scopes the @@ -442,6 +456,8 @@ pub enum BrokerMessage { Status(BrokerStatusRequest), CancelTask(BrokerCancelTaskRequest), ResumeTask(BrokerResumeTaskRequest), + SubsessionMessage(BrokerSubsessionMessageRequest), + ParentReply(BrokerParentReplyRequest), Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), @@ -939,6 +955,20 @@ pub async fn client_list_assistants_round_trip( message_round_trip(socket_path, &BrokerMessage::ListAssistants(req.clone())).await } +pub async fn client_subsession_message_round_trip( + socket_path: &str, + req: &BrokerSubsessionMessageRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::SubsessionMessage(req.clone())).await +} + +pub async fn client_parent_reply_round_trip( + socket_path: &str, + req: &BrokerParentReplyRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::ParentReply(req.clone())).await +} + /// Total budget for `open()` retries on Windows named pipes. Has to be /// short enough that it nests comfortably inside the companion's /// `BROKER_CANCEL_BUDGET` (500 ms) — leaving ≥ 300 ms for the actual @@ -1021,6 +1051,40 @@ mod tests { use serde_json::json; use tokio::io::duplex; + #[tokio::test] + async fn subsession_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let input = serde_json::from_value(json!({ + "child_conversation_id": 42, "action": "send", "message": "continue" + })) + .unwrap(); + let message = BrokerMessage::SubsessionMessage(BrokerSubsessionMessageRequest { + token: "tok".into(), + input, + }); + assert_eq!( + serde_json::to_value(&message).unwrap()["kind"], + "subsession_message" + ); + write_frame(&mut a, &message).await.unwrap(); + let received: BrokerMessage = read_frame(&mut b).await.unwrap(); + let BrokerMessage::SubsessionMessage(request) = received else { + panic!("wrong message") + }; + assert_eq!(request.token, "tok"); + assert_eq!(request.input.child_conversation_id, 42); + assert_eq!( + serde_json::to_value(request.input).unwrap()["delivery"], + "queue" + ); + let response = BrokerResponse { + outcome: json!({"accepted":true,"messages":[{"state":"queued","result":null}]}), + }; + write_frame(&mut b, &response).await.unwrap(); + let received: BrokerResponse = read_frame(&mut a).await.unwrap(); + assert_eq!(received.outcome, response.outcome); + } + #[tokio::test] async fn frame_round_trip_in_memory() { let (mut a, mut b) = duplex(8 * 1024); diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 8ecbf260866..54c1fae91f9 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -150,6 +150,29 @@ const HANDLE_EVENT_RETRY_BACKOFFS: &[Duration] = /// /// Final failure is logged at ERROR — this is the only signal the /// subscriber is dropping correctness on the floor, so it must be noisy. +async fn acknowledge_turn_lifecycle( + manager: &ConnectionManager, + envelope: &EventEnvelope, + success: bool, +) { + if !matches!(envelope.payload, AcpEvent::TurnComplete { .. }) { + return; + } + let Some(state) = manager.get_state(&envelope.connection_id).await else { + return; + }; + let mut state = state.write().await; + if success { + state.lifecycle_completed_turn_seq = state.lifecycle_completed_turn_seq.max(envelope.seq); + } else { + state.lifecycle_turn_error = Some(( + envelope.seq, + "child turn lifecycle persistence failed; continuation paused".into(), + )); + } + state.lifecycle_turn_notify.notify_waiters(); +} + async fn handle_event_with_retry( db_conn: &DatabaseConnection, manager: &ConnectionManager, @@ -157,7 +180,10 @@ async fn handle_event_with_retry( broker: Option<&Arc>, ) { match handle_event(db_conn, manager, envelope, broker).await { - Ok(()) => return, + Ok(()) => { + acknowledge_turn_lifecycle(manager, envelope, true).await; + return; + } Err(e) => { tracing::warn!( "[lifecycle][WARN] handle_event failed (attempt 1, will retry) for {:?}: {e}", @@ -168,7 +194,10 @@ async fn handle_event_with_retry( for (attempt, backoff) in HANDLE_EVENT_RETRY_BACKOFFS.iter().enumerate() { tokio::time::sleep(*backoff).await; match handle_event(db_conn, manager, envelope, broker).await { - Ok(()) => return, + Ok(()) => { + acknowledge_turn_lifecycle(manager, envelope, true).await; + return; + } Err(e) => { let attempt_num = attempt + 2; let is_last = attempt + 1 == HANDLE_EVENT_RETRY_BACKOFFS.len(); @@ -196,6 +225,7 @@ async fn handle_event_with_retry( .release_turn_status(envelope.seq, TurnStatusWriter::Lifecycle); } } + acknowledge_turn_lifecycle(manager, envelope, false).await; } /// The conversation status a `TurnComplete` with `stop_reason` leaves the row @@ -428,13 +458,9 @@ pub(crate) async fn handle_event( return Ok(()); }; let continues: Vec = current.external_id.into_iter().collect(); - let preserved = conversation_service::bind_external_id( - db_conn, - cid, - transcript_id, - &continues, - ) - .await?; + let preserved = + conversation_service::bind_external_id(db_conn, cid, transcript_id, &continues) + .await?; crate::commands::conversations::emit_conversation_upsert(&emitter, db_conn, cid) .await; crate::commands::conversations::emit_preserved_conversation( @@ -1254,9 +1280,7 @@ fn skip_js_ws_and_comments(code: &str, mut i: usize) -> usize { fn is_line_terminator_start(b: &[u8], i: usize) -> bool { match b.get(i) { Some(b'\n' | b'\r') => true, - Some(0xE2) => { - b.get(i + 1) == Some(&0x80) && matches!(b.get(i + 2), Some(0xA8 | 0xA9)) - } + Some(0xE2) => b.get(i + 1) == Some(&0x80) && matches!(b.get(i + 2), Some(0xA8 | 0xA9)), _ => false, } } @@ -4354,7 +4378,10 @@ mod tests { let env = EventEnvelope { seq: 1, connection_id: "c1".to_string(), - payload: AcpEvent::ContentDelta { text: "hi".into(), parent_tool_use_id: None }, + payload: AcpEvent::ContentDelta { + text: "hi".into(), + parent_tool_use_id: None, + }, }; handle_event(&db.conn, &mgr, &env, None).await.unwrap(); diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index ce49c224589..e804ae0cae9 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -10,11 +10,11 @@ use sea_orm::{ TransactionTrait, }; +use crate::acp::agent_mentions::strip_route_separator_from_prompt; use crate::acp::connection::{ spawn_agent_connection, AgentConnection, ConnectionCommand, GoalControlAction, SetterOutcome, SteerOutcome, }; -use crate::acp::agent_mentions::strip_route_separator_from_prompt; use crate::acp::error::AcpError; use crate::acp::feedback::{ bounded_feedback_batch, FeedbackItem, FeedbackStatus, PendingFeedback, @@ -609,16 +609,11 @@ impl ConnectionManager { /// Install the chat-channel manager exactly once during bootstrap so /// live title writes can sync bound forum topics. Calling twice is a /// no-op. Tests leave this unset and skip the remote sync. - pub fn install_chat_channel( - &self, - manager: crate::chat_channel::manager::ChatChannelManager, - ) { + pub fn install_chat_channel(&self, manager: crate::chat_channel::manager::ChatChannelManager) { let _ = self.chat_channel.set(manager); } - pub(crate) fn chat_channel( - &self, - ) -> Option { + pub(crate) fn chat_channel(&self) -> Option { self.chat_channel.get().map(|c| c.clone_ref()) } @@ -1158,7 +1153,12 @@ impl ConnectionManager { } } for (state, emitter, stale) in targets { - emit_with_state(&state, &emitter, AcpEvent::SessionConfigStale { stale, kind }).await; + emit_with_state( + &state, + &emitter, + AcpEvent::SessionConfigStale { stale, kind }, + ) + .await; } stale_count } @@ -2181,6 +2181,88 @@ impl ConnectionManager { .map_err(|_| AcpError::protocol("Async task stop reply channel closed".to_string()))? } + /// Cancel and await the wire prompt response, rather than the eager UI event. + pub async fn cancel_and_wait( + &self, + db: &DatabaseConnection, + conn_id: &str, + ) -> Result<(), String> { + self.cancel(db, conn_id).await.map_err(|e| e.to_string())?; + self.wait_prompt_quiescence(conn_id).await + } + + /// Wait until the prior turn's DB/broker lifecycle work has finished. + pub async fn wait_turn_lifecycle(&self, conn_id: &str) -> Result<(), String> { + let state = self + .get_state(conn_id) + .await + .ok_or("child connection disappeared before lifecycle settlement")?; + let (target, notify) = { + let state = state.read().await; + ( + state.last_turn_complete_seq, + state.lifecycle_turn_notify.clone(), + ) + }; + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let notified = notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let state = state.read().await; + if let Some((seq, error)) = &state.lifecycle_turn_error { + if *seq >= target { + return Err(error.clone()); + } + } + if state.lifecycle_completed_turn_seq >= target { + return Ok(()); + } + } + notified.await; + } + }) + .await + .map_err(|_| "child lifecycle did not settle; continuation paused".to_string())? + } + + pub async fn wait_prompt_quiescence(&self, conn_id: &str) -> Result<(), String> { + let (reply, receiver) = tokio::sync::oneshot::channel(); + { + let connections = self.connections.lock().await; + let conn = connections + .get(conn_id) + .ok_or("connection disappeared during cancellation")?; + conn.cmd_tx + .send(ConnectionCommand::AwaitPromptQuiescence { reply }) + .await + .map_err(|_| "connection closed before cancellation settled")?; + } + receiver + .await + .map_err(|_| "connection closed before cancellation settled".to_string())? + } + + pub async fn cancel_if_turn( + &self, + db: &DatabaseConnection, + conn_id: &str, + turns_completed: u64, + ) -> Result<(), AcpError> { + let lock = self.clone_prompt_lock(conn_id).await?; + let _lock = lock.lock().await; + let Some(state) = self.get_state(conn_id).await else { + return Ok(()); + }; + let state = state.read().await; + if !state.turn_in_flight || state.turns_completed != turns_completed { + return Ok(()); + } + drop(state); + self.cancel(db, conn_id).await + } + pub async fn cancel(&self, db: &DatabaseConnection, conn_id: &str) -> Result<(), AcpError> { let (cmd_tx, state_arc, emitter) = { let connections = self.connections.lock().await; @@ -2969,13 +3051,7 @@ impl ConnectionManager { let grace_period = Duration::from_millis(500); let mut selectors_ready_at: Option = None; loop { - let ( - config_options, - modes, - available_commands, - prompt_capabilities, - selectors_ready, - ) = { + let (config_options, modes, available_commands, prompt_capabilities, selectors_ready) = { let conns = self.connections.lock().await; let conn = conns .get(conn_id) @@ -3324,9 +3400,7 @@ impl ConnectionManager { /// Block new connections from being established until the returned guard /// is dropped. The caller must enumerate live connections only AFTER /// holding this, never before. - pub async fn lock_out_new_connections( - &self, - ) -> tokio::sync::OwnedRwLockWriteGuard<()> { + pub async fn lock_out_new_connections(&self) -> tokio::sync::OwnedRwLockWriteGuard<()> { self.external_restore_lock.clone().write_owned().await } @@ -3482,6 +3556,37 @@ impl ConnectionManager { /// image attachments) to deliver on the native wire instead of the bare /// `text` — `text` then serves as the recorded note. Only the native /// channel can carry blocks; see the pull-path gate below. + /// Immediate subsession messages must never degrade into pull feedback. + pub async fn submit_feedback_native_only( + &self, + conn_id: &str, + text: String, + ) -> Result { + if text.trim().is_empty() { + return Err(AcpError::InvalidFeedback("empty note".into())); + } + if text.chars().count() > MAX_FEEDBACK_CHARS { + return Err(AcpError::InvalidFeedback(format!( + "note exceeds {MAX_FEEDBACK_CHARS} characters" + ))); + } + let (state, tx, emitter) = { + let conns = self.connections.lock().await; + let conn = conns + .get(conn_id) + .ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?; + ( + conn.state.clone(), + conn.cmd_tx.clone(), + conn.emitter.clone(), + ) + }; + if !state.read().await.native_steering_available { + return Err(AcpError::FeedbackDisabled); + } + Self::submit_feedback_native(conn_id, state, tx, emitter, text, None).await + } + pub async fn submit_feedback( &self, conn_id: &str, @@ -3545,11 +3650,8 @@ impl ConnectionManager { return Err(AcpError::NoActiveTurn); } - let item = FeedbackItem::new_pending( - uuid::Uuid::new_v4().to_string(), - text, - chrono::Utc::now(), - ); + let item = + FeedbackItem::new_pending(uuid::Uuid::new_v4().to_string(), text, chrono::Utc::now()); // Gate on `turn_in_flight` and append in ONE critical section (via the // gated emit): a `TurnComplete` (flips the flag) or `UserMessage` // (clears `feedback`) can't slip between the gate and the append+seq, so @@ -3717,9 +3819,9 @@ impl ConnectionManager { }) .await .map_err(|_| AcpError::ProcessExited)?; - let steer = reply_rx.await.map_err(|_| { - AcpError::protocol("Steer reply channel closed".to_string()) - })??; + let steer = reply_rx + .await + .map_err(|_| AcpError::protocol("Steer reply channel closed".to_string()))??; match steer { // Honored opt-in: the content was NOT consumed and is // still host-owned. Surface the frontend's existing @@ -4249,8 +4351,12 @@ impl ConnectionManager { // (disconnect removes it before this sweep), so tolerate `None`. if let Some((state, emitter)) = self.get_state_and_emitter(conn_id).await { for approval_id in drained { - emit_with_state(&state, &emitter, AcpEvent::PlanApprovalResolved { approval_id }) - .await; + emit_with_state( + &state, + &emitter, + AcpEvent::PlanApprovalResolved { approval_id }, + ) + .await; } } } @@ -4363,6 +4469,118 @@ pub struct ConnectionManagerSpawner { pub data_dir: Arc, } +impl ConnectionManagerSpawner { + pub async fn spawn_for_resume_strict( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + external_session_id: &str, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result< + crate::acp::delegation::spawner::ResumedSpawn, + crate::acp::delegation::spawner::SpawnerError, + > { + self.spawn_resume_impl( + parent_connection_id, + agent_type, + working_dir, + external_session_id, + preferred_mode_id, + preferred_config_values, + true, + ) + .await + } + #[allow(clippy::too_many_arguments)] + async fn spawn_resume_impl( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + external_session_id: &str, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + strict: bool, + ) -> Result< + crate::acp::delegation::spawner::ResumedSpawn, + crate::acp::delegation::spawner::SpawnerError, + > { + use crate::acp::delegation::spawner::{ResumedSpawn, SpawnerError}; + // Same parent inheritance as `spawn` — a resumed child whose emitter is + // wired to a different broadcaster would stream to nobody. + let (emitter, owner_window, parent_working_dir) = { + let conns = self.manager.connections.lock().await; + let parent = conns.get(parent_connection_id).ok_or_else(|| { + SpawnerError::Spawn(format!( + "parent connection {parent_connection_id} not found" + )) + })?; + let pwd = { + let s = parent.state.read().await; + s.working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }; + ( + parent.emitter.clone(), + parent.owner_window_label.clone(), + pwd, + ) + }; + let effective_working_dir = working_dir.or(parent_working_dir); + + let mut runtime_env = crate::commands::acp::build_session_runtime_env( + &self.db, + agent_type, + None, + self.data_dir.as_path(), + ) + .await + .map_err(|e| SpawnerError::Spawn(e.to_string()))?; + + if strict { + runtime_env.insert("CODEG_ACP_REQUIRE_RESUME".into(), "1".into()); + } + + // `session_id = Some(..)` is the whole difference vs `spawn`: the + // connection loads the child's prior agent session (and its context) + // instead of minting a fresh one — unless a live connection already + // represents that session (e.g. the user has the canceled child open + // in a tab), which the dedup hands back instead. The broker must know + // which, because its failure teardown may only disconnect a connection + // this call actually created; and a reused connection must not take + // the parent's CLI run tag, or the run's policy would answer the cards + // of a session a person is driving. `reused` is the dedup's own + // verdict, so a connection appearing just before the spawn cannot slip + // past it. + let outcome = self + .manager + .spawn_agent_with_outcome( + agent_type, + effective_working_dir, + Some(external_session_id.to_string()), + runtime_env, + owner_window, + emitter, + preferred_mode_id, + preferred_config_values, + ) + .await + .map_err(|e| SpawnerError::Spawn(e.to_string()))?; + if !outcome.reused { + self.manager + .inherit_cli_run_tag(parent_connection_id, &outcome.connection_id) + .await; + } + Ok(ResumedSpawn { + connection_id: outcome.connection_id, + reused: outcome.reused, + }) + } +} + #[async_trait::async_trait] impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpawner { async fn spawn( @@ -4501,75 +4719,20 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa external_session_id: &str, preferred_mode_id: Option, preferred_config_values: BTreeMap, - ) -> Result - { - use crate::acp::delegation::spawner::{ResumedSpawn, SpawnerError}; - // Same parent inheritance as `spawn` — a resumed child whose emitter is - // wired to a different broadcaster would stream to nobody. - let (emitter, owner_window, parent_working_dir) = { - let conns = self.manager.connections.lock().await; - let parent = conns.get(parent_connection_id).ok_or_else(|| { - SpawnerError::Spawn(format!( - "parent connection {parent_connection_id} not found" - )) - })?; - let pwd = { - let s = parent.state.read().await; - s.working_dir - .as_ref() - .map(|p| p.to_string_lossy().to_string()) - }; - ( - parent.emitter.clone(), - parent.owner_window_label.clone(), - pwd, - ) - }; - let effective_working_dir = working_dir.or(parent_working_dir); - - let runtime_env = crate::commands::acp::build_session_runtime_env( - &self.db, + ) -> Result< + crate::acp::delegation::spawner::ResumedSpawn, + crate::acp::delegation::spawner::SpawnerError, + > { + self.spawn_resume_impl( + parent_connection_id, agent_type, - None, - self.data_dir.as_path(), + working_dir, + external_session_id, + preferred_mode_id, + preferred_config_values, + false, ) .await - .map_err(|e| SpawnerError::Spawn(e.to_string()))?; - - // `session_id = Some(..)` is the whole difference vs `spawn`: the - // connection loads the child's prior agent session (and its context) - // instead of minting a fresh one — unless a live connection already - // represents that session (e.g. the user has the canceled child open - // in a tab), which the dedup hands back instead. The broker must know - // which, because its failure teardown may only disconnect a connection - // this call actually created; and a reused connection must not take - // the parent's CLI run tag, or the run's policy would answer the cards - // of a session a person is driving. `reused` is the dedup's own - // verdict, so a connection appearing just before the spawn cannot slip - // past it. - let outcome = self - .manager - .spawn_agent_with_outcome( - agent_type, - effective_working_dir, - Some(external_session_id.to_string()), - runtime_env, - owner_window, - emitter, - preferred_mode_id, - preferred_config_values, - ) - .await - .map_err(|e| SpawnerError::Spawn(e.to_string()))?; - if !outcome.reused { - self.manager - .inherit_cli_run_tag(parent_connection_id, &outcome.connection_id) - .await; - } - Ok(ResumedSpawn { - connection_id: outcome.connection_id, - reused: outcome.reused, - }) } async fn send_resume_prompt( @@ -4618,6 +4781,17 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Cancel(e.to_string())) } + async fn cancel_if_turn( + &self, + conn_id: &str, + turns_completed: u64, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + self.manager + .cancel_if_turn(&self.db.conn, conn_id, turns_completed) + .await + .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Spawn(e.to_string())) + } + async fn disconnect( &self, conn_id: &str, @@ -4660,10 +4834,7 @@ pub struct ConnectionManagerFeedbackLookup { #[async_trait::async_trait] impl SessionFeedbackAccess for ConnectionManagerFeedbackLookup { - async fn read_pending_feedback( - &self, - parent_connection_id: &str, - ) -> Vec { + async fn read_pending_feedback(&self, parent_connection_id: &str) -> Vec { self.manager .read_pending_feedback(parent_connection_id) .await @@ -5959,8 +6130,9 @@ mod tests { mut rx: tokio::sync::mpsc::Receiver, expected: GoalControlAction, landed: bool, - ) -> tokio::task::JoinHandle> - { + ) -> tokio::task::JoinHandle< + tokio::sync::mpsc::Receiver, + > { tokio::spawn(async move { match rx.recv().await.expect("goal control enqueued") { ConnectionCommand::GoalControl { action, reply } => { @@ -11125,7 +11297,73 @@ mod tests { .await .is_empty()); // Commit on a missing connection is a safe no-op. - mgr.commit_feedback_delivered("nope", vec!["x".into()]).await; + mgr.commit_feedback_delivered("nope", vec!["x".into()]) + .await; } +} + +#[cfg(test)] +mod subsession_ordering_tests { + use super::*; + #[tokio::test] + async fn delayed_original_cancellation_does_not_stop_a_later_turn() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let manager = ConnectionManager::new(); + let mut commands = manager + .insert_test_connection_live("child", AgentType::Codex, None, EventEmitter::Noop) + .await; + let state = manager.get_state("child").await.unwrap(); + { + let mut state = state.write().await; + state.turn_in_flight = true; + state.turns_completed = 1; + } + manager.cancel_if_turn(&db.conn, "child", 0).await.unwrap(); + assert!(matches!( + commands.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + manager.cancel_if_turn(&db.conn, "child", 1).await.unwrap(); + assert!(matches!( + commands.recv().await, + Some(ConnectionCommand::Cancel) + )); + } + + #[tokio::test] + async fn child_continuation_waits_for_lifecycle_persistence() { + let manager = Arc::new(ConnectionManager::new()); + manager + .insert_test_connection("child", AgentType::Codex, None, EventEmitter::Noop) + .await; + let state = manager.get_state("child").await.unwrap(); + let mut events = state.read().await.event_stream().subscribe(); + emit_with_state( + &state, + &EventEmitter::Noop, + AcpEvent::TurnComplete { + session_id: "original".into(), + stop_reason: "end_turn".into(), + agent_type: "codex".into(), + }, + ) + .await; + let completion = events.recv().await.unwrap(); + assert_eq!(state.read().await.last_turn_complete_seq, completion.seq); + assert!(completion.seq > 0); + let waiter = manager.clone(); + let mut waiting = tokio::spawn(async move { waiter.wait_turn_lifecycle("child").await }); + assert!( + tokio::time::timeout(Duration::from_millis(10), &mut waiting) + .await + .is_err() + ); + { + let mut state = state.write().await; + state.lifecycle_completed_turn_seq = completion.seq; + state.lifecycle_turn_notify.notify_waiters(); + } + waiting.await.unwrap().unwrap(); + } } diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 89de586e1e5..65d3c2533f7 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -58,6 +58,9 @@ pub mod service_error; pub mod session_info; pub mod session_title; pub mod session_state; +pub mod subsession_messages; +pub mod subsession_runtime; +pub mod parent_replies; pub mod stderr_tail; pub mod temp_reclaim; pub mod terminal_runtime; diff --git a/src-tauri/src/acp/parent_replies.rs b/src-tauri/src/acp/parent_replies.rs new file mode 100644 index 00000000000..137e4e7d2d1 --- /dev/null +++ b/src-tauri/src/acp/parent_replies.rs @@ -0,0 +1,331 @@ +//! A child's explicit report wakes its parent after the parent's current turn. +use crate::{ + acp::subsession_messages::{SubsessionTurn, SubsessionTurnResult}, + db::{entities::parent_reply::Model, service::parent_reply_service as inbox, AppDatabase}, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParentReplyOutcome { + pub accepted: bool, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ParentReplyOutcome { + pub fn refused(error: impl Into) -> Self { + Self { + accepted: false, + message: None, + error: Some(error.into()), + } + } +} + +#[async_trait] +pub trait ParentReplyRuntime: Send + Sync { + /// Wait for an available parent, including its prompt/lifecycle barriers. + async fn wait_ready(&self, message: &Model) -> Result<(), String>; + /// None means a user's prompt won the race: keep the report queued. + async fn start(&self, message: &Model) -> Result, String>; + async fn wait(&self, turn: SubsessionTurn) -> Result; + async fn enabled(&self) -> bool; +} + +#[derive(Clone)] +pub struct ParentReplies { + db: Arc, + runtime: Arc, + workers: Arc>>>, +} + +impl ParentReplies { + pub fn new(db: Arc, runtime: Arc) -> Self { + Self { + db, + runtime, + workers: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn recover_pending(&self) { + let service = self.clone(); + let recovery = async move { + match inbox::recover(&service.db.conn).await { + Ok(parents) => { + for parent in parents { + service.wake(parent).await; + } + } + Err(e) => tracing::warn!(%e, "parent reply recovery failed"), + } + }; + // Desktop setup is synchronous and runs outside a Tokio context. + #[cfg(feature = "tauri-runtime")] + tauri::async_runtime::spawn(recovery); + #[cfg(not(feature = "tauri-runtime"))] + tokio::spawn(recovery); + } + + pub async fn send(&self, child: i32, message: String) -> ParentReplyOutcome { + if !self.runtime.enabled().await { + return ParentReplyOutcome::refused("Delegation is disabled"); + } + match inbox::enqueue(&self.db.conn, child, message.trim()).await { + Ok(row) => { + self.wake(row.parent_conversation_id).await; + ParentReplyOutcome { + accepted: true, + message: Some(row), + error: None, + } + } + Err(e) => ParentReplyOutcome::refused(e.to_string()), + } + } + + async fn wake(&self, parent: i32) { + let mut workers = self.workers.lock().await; + if let Some(revision) = workers.get(&parent) { + revision.fetch_add(1, Ordering::AcqRel); + return; + } + let revision = Arc::new(AtomicU64::new(0)); + workers.insert(parent, revision.clone()); + drop(workers); + let service = self.clone(); + tokio::spawn(async move { + loop { + let observed = revision.load(Ordering::Acquire); + match service.run(parent).await { + Ok(()) => return, + Err(e) => { + tracing::warn!(parent, %e, "parent reply dispatcher paused"); + let mut workers = service.workers.lock().await; + if revision.load(Ordering::Acquire) != observed { + continue; + } + workers.remove(&parent); + return; + } + } + } + }); + } + + async fn run(&self, parent: i32) -> Result<(), String> { + loop { + let Some(row) = inbox::next(&self.db.conn, parent) + .await + .map_err(|e| e.to_string())? + else { + let mut workers = self.workers.lock().await; + if inbox::next(&self.db.conn, parent) + .await + .map_err(|e| e.to_string())? + .is_none() + { + workers.remove(&parent); + return Ok(()); + } + continue; + }; + if let Err(e) = self.runtime.wait_ready(&row).await { + inbox::fail_queued(&self.db.conn, &row.message_id, &e) + .await + .map_err(|e| e.to_string())?; + continue; + } + let claimed = match inbox::claim(&self.db.conn, &row).await { + Ok(claimed) => claimed, + Err(e) => { + inbox::fail_queued(&self.db.conn, &row.message_id, &e.to_string()) + .await + .map_err(|e| e.to_string())?; + continue; + } + }; + if !claimed { + // A different runtime may own a report from another child. + inbox::recover(&self.db.conn) + .await + .map_err(|e| e.to_string())?; + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + let result = match self.runtime.start(&row).await { + Ok(None) => { + inbox::finish(&self.db.conn, &row.message_id, "queued", None, None) + .await + .map_err(|e| e.to_string())?; + continue; + } + Ok(Some(turn)) => self.runtime.wait(turn).await, + Err(e) => Err(e), + } + .unwrap_or_else(|e| SubsessionTurnResult { + status: "failed".into(), + text: None, + error: Some(e), + }); + // Once the prompt ran, retry only persistence, never execution. + loop { + match inbox::finish( + &self.db.conn, + &row.message_id, + &result.status, + result.text.as_deref(), + result.error.as_deref(), + ) + .await + { + Ok(()) => break, + Err(e) => { + tracing::warn!(%e, message_id = %row.message_id, "parent reply result persistence delayed"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + db::{ + entities::conversation, + test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}, + }, + models::AgentType, + }; + use sea_orm::sea_query::Expr; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::Notify; + + struct Runtime { + busy: AtomicBool, + race_once: AtomicBool, + changed: Notify, + sent: Mutex>, + } + #[async_trait] + impl ParentReplyRuntime for Runtime { + async fn wait_ready(&self, _: &Model) -> Result<(), String> { + loop { + let wait = self.changed.notified(); + if !self.busy.load(Ordering::Acquire) { + return Ok(()); + } + wait.await; + } + } + async fn start(&self, row: &Model) -> Result, String> { + if self.race_once.swap(false, Ordering::AcqRel) { + return Ok(None); + } + self.sent.lock().await.push(row.message.clone()); + Ok(Some(SubsessionTurn { + id: row.message_id.clone(), + })) + } + async fn wait(&self, _: SubsessionTurn) -> Result { + Ok(SubsessionTurnResult { + status: "completed".into(), + text: Some("parent handled report".into()), + error: None, + }) + } + async fn enabled(&self) -> bool { + true + } + } + + #[tokio::test] + async fn child_reports_queue_while_parent_busy_then_wake_in_order_and_survive_send_race() { + let db = Arc::new(fresh_in_memory_db().await); + let folder = seed_folder(&db, "D:/parent-reply").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + let sibling = seed_conversation(&db, folder, AgentType::Codex).await; + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.is_in([child, sibling])) + .exec(&db.conn) + .await + .unwrap(); + let runtime = Arc::new(Runtime { + busy: AtomicBool::new(true), + race_once: AtomicBool::new(true), + changed: Notify::new(), + sent: Mutex::new(Vec::new()), + }); + let service = ParentReplies::new(db.clone(), runtime.clone()); + assert!(!service.send(parent, "not a child".into()).await.accepted); + let a = service + .send(child, "first report".into()) + .await + .message + .unwrap(); + let b = service + .send(sibling, "second report".into()) + .await + .message + .unwrap(); + tokio::task::yield_now().await; + assert_eq!( + inbox::get(&db.conn, &a.message_id) + .await + .unwrap() + .unwrap() + .status, + "queued" + ); + assert!(runtime.sent.lock().await.is_empty()); + runtime.busy.store(false, Ordering::Release); + runtime.changed.notify_one(); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if inbox::get(&db.conn, &b.message_id) + .await + .unwrap() + .unwrap() + .status + == "completed" + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + *runtime.sent.lock().await, + ["first report", "second report"] + ); + assert_eq!( + inbox::get(&db.conn, &a.message_id) + .await + .unwrap() + .unwrap() + .result + .as_deref(), + Some("parent handled report") + ); + } +} diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 15b3f894cac..f722c2ccab8 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -56,8 +56,12 @@ pub enum LiveContentBlock { #[serde(default, skip_serializing_if = "Option::is_none")] parent_tool_use_id: Option, }, - ToolCallRef { tool_call_id: String }, - Plan { entries: serde_json::Value }, + ToolCallRef { + tool_call_id: String, + }, + Plan { + entries: serde_json::Value, + }, } /// 工具调用的运行态。turn 完成时统一 clear。 @@ -258,6 +262,8 @@ pub struct SessionState { pub connection_id: String, pub conversation_id: Option, pub external_id: Option, + /// Messaging continuation must never replace missing agent context. + pub require_session_resume: bool, /// Wall-clock instant `external_id` last CHANGED value (SessionStarted /// for a new/loaded/forked session). The transcript watcher uses this as /// its re-arm epoch: records appended to the (forked) transcript between @@ -837,6 +843,10 @@ pub struct SessionState { /// Which writer owns the conversation-status write of a `TurnComplete`, /// keyed by that event's envelope `seq`. See [`Self::claim_turn_status`]. pub turn_status_claim: Option<(u64, TurnStatusWriter)>, + pub last_turn_complete_seq: u64, + pub lifecycle_completed_turn_seq: u64, + pub lifecycle_turn_error: Option<(u64, String)>, + pub lifecycle_turn_notify: Arc, } /// The two writers of a `TurnComplete`'s conversation status. @@ -894,6 +904,7 @@ impl SessionState { connection_id, conversation_id: None, external_id: None, + require_session_resume: false, external_id_changed_at: None, agent_type, prompt_usage_mode: crate::acp::reported_usage::PromptUsageMode::default(), @@ -972,6 +983,10 @@ impl SessionState { cli_run: None, cli_output_schema: None, turn_status_claim: None, + last_turn_complete_seq: 0, + lifecycle_completed_turn_seq: 0, + lifecycle_turn_error: None, + lifecycle_turn_notify: Arc::new(tokio::sync::Notify::new()), } } @@ -1477,6 +1492,7 @@ impl SessionState { } } AcpEvent::TurnComplete { stop_reason, .. } => { + self.last_turn_complete_seq = self.event_seq + 1; // Diagnostic only (no behavior change): pairs with the // StatusChanged log above. This is the ACTUAL point the turn // settles (`self.status` flips to `Connected` right below, @@ -3413,7 +3429,12 @@ mod tests { } } let mut s = fresh_state(); - s.apply_event(&failure("t1:error", 5, "warning", "Reconnecting, attempt 5 of 5.")); + s.apply_event(&failure( + "t1:error", + 5, + "warning", + "Reconnecting, attempt 5 of 5.", + )); // A cancelled/failed/empty exit is NOT recovery — the incident (e.g. // reconnect attempts with the network still down) must stay active @@ -4124,7 +4145,10 @@ mod tests { text: "Answer ".into(), parent_tool_use_id: None, }); - s.apply_event(&AcpEvent::Thinking { text: "hmm".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::Thinking { + text: "hmm".into(), + parent_tool_use_id: None, + }); s.apply_event(&AcpEvent::ContentDelta { text: "continues here".into(), parent_tool_use_id: None, @@ -4320,9 +4344,18 @@ mod tests { #[test] fn thinking_delta_creates_separate_block_from_text() { let mut s = fresh_state(); - s.apply_event(&AcpEvent::ContentDelta { text: "T".into(), parent_tool_use_id: None }); - s.apply_event(&AcpEvent::Thinking { text: "X".into(), parent_tool_use_id: None }); - s.apply_event(&AcpEvent::ContentDelta { text: "Y".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::ContentDelta { + text: "T".into(), + parent_tool_use_id: None, + }); + s.apply_event(&AcpEvent::Thinking { + text: "X".into(), + parent_tool_use_id: None, + }); + s.apply_event(&AcpEvent::ContentDelta { + text: "Y".into(), + parent_tool_use_id: None, + }); let live = s.live_message.as_ref().unwrap(); assert_eq!(live.content.len(), 3); match &live.content[0] { @@ -4765,7 +4798,12 @@ mod tests { for i in 0..200 { run_tool_call(&mut s, &format!("tc-{i:04}"), 16 * 1024, true); } - run_tool_call(&mut s, "tc-huge", MAX_SNAPSHOT_TOOL_PAYLOAD_BYTES + 1024, false); + run_tool_call( + &mut s, + "tc-huge", + MAX_SNAPSHOT_TOOL_PAYLOAD_BYTES + 1024, + false, + ); let snap = s.to_snapshot(); let huge = snap @@ -4940,7 +4978,10 @@ mod tests { #[test] fn turn_complete_clears_live_and_tool_calls_and_pending_permission() { let mut s = fresh_state(); - s.apply_event(&AcpEvent::ContentDelta { text: "hi".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::ContentDelta { + text: "hi".into(), + parent_tool_use_id: None, + }); s.apply_event(&AcpEvent::ToolCall { tool_call_id: "tc-1".into(), title: "x".into(), @@ -6106,7 +6147,10 @@ mod tests { fn plan_update_appends_at_end_replacing_existing() { use crate::acp::types::PlanEntryInfo; let mut s = fresh_state(); - s.apply_event(&AcpEvent::ContentDelta { text: "A".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::ContentDelta { + text: "A".into(), + parent_tool_use_id: None, + }); s.apply_event(&AcpEvent::PlanUpdate { entries: vec![PlanEntryInfo { content: "step v1".into(), @@ -6114,7 +6158,10 @@ mod tests { status: "pending".into(), }], }); - s.apply_event(&AcpEvent::ContentDelta { text: "B".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::ContentDelta { + text: "B".into(), + parent_tool_use_id: None, + }); s.apply_event(&AcpEvent::PlanUpdate { entries: vec![PlanEntryInfo { content: "step v2".into(), @@ -6176,7 +6223,10 @@ mod tests { fn turn_complete_clears_plan_and_tool_refs() { use crate::acp::types::PlanEntryInfo; let mut s = fresh_state(); - s.apply_event(&AcpEvent::ContentDelta { text: "x".into(), parent_tool_use_id: None }); + s.apply_event(&AcpEvent::ContentDelta { + text: "x".into(), + parent_tool_use_id: None, + }); s.apply_event(&tool_call_event("tc-1", "ls")); s.apply_event(&AcpEvent::PlanUpdate { entries: vec![PlanEntryInfo { @@ -6206,7 +6256,10 @@ mod tests { let env = EventEnvelope { seq: 7, connection_id: "conn-x".into(), - payload: AcpEvent::ContentDelta { text: "abc".into(), parent_tool_use_id: None }, + payload: AcpEvent::ContentDelta { + text: "abc".into(), + parent_tool_use_id: None, + }, }; let json = serde_json::to_string(&env).unwrap(); let back: EventEnvelope = serde_json::from_str(&json).unwrap(); diff --git a/src-tauri/src/acp/subsession_messages.rs b/src-tauri/src/acp/subsession_messages.rs new file mode 100644 index 00000000000..0adf6106011 --- /dev/null +++ b/src-tauri/src/acp/subsession_messages.rs @@ -0,0 +1,1292 @@ +//! Persistent messages addressed to an existing direct child conversation. + +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, Notify, RwLock}; + +use crate::db::{ + entities::subsession_message::Model, service::subsession_message_service as messages, + AppDatabase, +}; + +type ActiveTurn = ( + String, + Pin> + Send>>, +); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubsessionDelivery { + Immediate, + #[default] + Queue, +} + +impl SubsessionDelivery { + fn as_str(self) -> &'static str { + match self { + Self::Immediate => "immediate", + Self::Queue => "queue", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubsessionMessageAction { + Send, + List, + Update, + Cancel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubsessionMessageRequest { + pub child_conversation_id: i32, + pub action: SubsessionMessageAction, + pub message: Option, + pub message_id: Option, + #[serde(default)] + pub delivery: SubsessionDelivery, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubsessionMessageOutcome { + pub accepted: bool, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl SubsessionMessageOutcome { + pub fn refused(code: &str, message: &str) -> Self { + Self { + accepted: false, + messages: Vec::new(), + error_code: Some(code.into()), + message: Some(message.into()), + } + } + + fn accepted(messages: Vec) -> Self { + Self { + accepted: true, + messages, + error_code: None, + message: None, + } + } +} + +#[async_trait] +pub trait SubsessionMessageAccess: Send + Sync { + async fn send_to_parent( + &self, + _child: i32, + _message: String, + ) -> super::parent_replies::ParentReplyOutcome { + super::parent_replies::ParentReplyOutcome::refused("Parent messaging is unavailable") + } + + async fn request( + &self, + parent_connection_id: &str, + parent_conversation_id: i32, + request: SubsessionMessageRequest, + ) -> SubsessionMessageOutcome; +} + +pub struct NoSubsessionMessages; + +#[async_trait] +impl SubsessionMessageAccess for NoSubsessionMessages { + async fn request( + &self, + _: &str, + _: i32, + _: SubsessionMessageRequest, + ) -> SubsessionMessageOutcome { + SubsessionMessageOutcome::refused("unavailable", "Subsession messages are unavailable") + } +} + +#[derive(Debug, Clone)] +pub struct SubsessionTurn { + pub id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SubsessionStartError { + #[error("turn already in progress")] + Busy, + #[error("{0}")] + Failed(String), +} + +impl From for SubsessionStartError { + fn from(error: String) -> Self { + Self::Failed(error) + } +} +impl From<&str> for SubsessionStartError { + fn from(error: &str) -> Self { + Self::Failed(error.into()) + } +} + +#[derive(Debug, Clone)] +pub struct SubsessionTurnResult { + pub status: String, + pub text: Option, + pub error: Option, +} + +#[async_trait] +pub trait SubsessionRuntime: Send + Sync { + async fn prepare( + &self, + parent_connection_id: &str, + parent: i32, + child: i32, + ) -> Result<(), String>; + async fn is_busy(&self, child: i32) -> Result; + async fn wait_idle(&self, child: i32) -> Result<(), String>; + async fn start( + &self, + parent_connection_id: &str, + parent: i32, + child: i32, + message: String, + ) -> Result; + async fn steer(&self, child: i32, message: String) -> Result; + async fn interrupt(&self, child: i32) -> Result<(), String>; + async fn wait(&self, turn: SubsessionTurn) -> Result; +} + +struct Worker { + wake: Arc, + parent_connection_id: Arc>, + revision: Arc, +} + +#[derive(Clone)] +pub struct SubsessionMessages { + parent_replies: Option>, + db: Arc, + runtime: Arc, + workers: Arc>>, + pending_results: Arc>>, +} + +impl SubsessionMessages { + pub fn with_runtime(db: Arc, runtime: Arc) -> Self { + Self { + parent_replies: None, + db, + runtime, + workers: Arc::new(Mutex::new(HashMap::new())), + pending_results: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn with_parent_replies( + mut self, + replies: Arc, + ) -> Self { + self.parent_replies = Some(replies); + self + } + + async fn wake_worker(&self, owner: &str, parent: i32, child: i32) { + let mut workers = self.workers.lock().await; + if let Some(worker) = workers.get(&child) { + *worker.parent_connection_id.write().await = owner.into(); + worker.revision.fetch_add(1, Ordering::AcqRel); + worker.wake.notify_one(); + return; + } + let wake = Arc::new(Notify::new()); + let owner = Arc::new(RwLock::new(owner.to_string())); + let revision = Arc::new(AtomicU64::new(0)); + workers.insert( + child, + Worker { + wake: wake.clone(), + parent_connection_id: owner.clone(), + revision: revision.clone(), + }, + ); + let service = self.clone(); + tokio::spawn(async move { + loop { + let observed = revision.load(Ordering::Acquire); + match service.run(parent, child, &wake, &owner).await { + Ok(()) => return, + Err(error) => { + tracing::warn!(child, parent, %error, "subsession message dispatcher paused"); + let mut workers = service.workers.lock().await; + // A sender that found this worker during error cleanup + // must get a fresh attempt, rather than a lost wakeup. + if revision.load(Ordering::Acquire) != observed { + continue; + } + workers.remove(&child); + return; + } + } + } + }); + } + + async fn finish( + &self, + id: &str, + result: Result, + ) -> Result<(), String> { + let result = result.unwrap_or_else(|error| SubsessionTurnResult { + status: "failed".into(), + text: None, + error: Some(error), + }); + // Keep a completed result if SQLite is temporarily unavailable. A + // subsequent worker/request persists it without polling the turn again. + self.pending_results + .lock() + .await + .insert(id.into(), result.clone()); + self.persist_result(id, &result).await + } + + async fn persist_result(&self, id: &str, result: &SubsessionTurnResult) -> Result<(), String> { + let written = messages::finish( + &self.db.conn, + id, + &result.status, + result.text.as_deref(), + result.error.as_deref(), + ) + .await; + match written { + Ok(_) | Err(crate::db::error::DbError::NotFound(_)) => { + self.pending_results.lock().await.remove(id); + } + Err(error) => return Err(error.to_string()), + } + Ok(()) + } + + async fn flush_results(&self) -> Result<(), String> { + let pending: Vec<_> = self + .pending_results + .lock() + .await + .iter() + .map(|(id, result)| (id.clone(), result.clone())) + .collect(); + for (id, result) in pending { + self.persist_result(&id, &result).await?; + } + Ok(()) + } + + async fn start( + &self, + owner: &str, + parent: i32, + child: i32, + id: &str, + ) -> Result, String> { + if !messages::claim(&self.db.conn, id) + .await + .map_err(|e| e.to_string())? + { + return Ok(None); + } + // Read AFTER claiming: an edit that won the race before the claim must + // be the text sent to ACP, rather than our earlier queue snapshot. + let message = messages::get(&self.db.conn, parent, child, id) + .await + .map_err(|e| e.to_string()) + .and_then(|row| row.ok_or_else(|| "claimed message disappeared".to_string())); + let message = match message { + Ok(message) => message, + Err(error) => { + self.finish(id, Err(error.clone())).await?; + return Err(error); + } + }; + match self + .runtime + .start(owner, parent, child, message.message) + .await + { + Ok(turn) => { + let runtime = self.runtime.clone(); + Ok(Some(( + id.into(), + Box::pin(async move { runtime.wait(turn).await }), + ))) + } + Err(SubsessionStartError::Busy) => { + messages::release_claim(&self.db.conn, id) + .await + .map_err(|e| e.to_string())?; + Ok(None) + } + Err(SubsessionStartError::Failed(error)) => { + self.finish(id, Err(error)).await?; + Ok(None) + } + } + } + + async fn run( + &self, + parent: i32, + child: i32, + wake: &Notify, + owner: &RwLock, + ) -> Result<(), String> { + self.flush_results().await?; + let mut active: Option = None; + let mut reserved: Option = None; + let mut failure = self + .run_loop(parent, child, wake, owner, &mut active, &mut reserved) + .await + .err(); + if failure.is_some() { + if let Some((id, completion)) = active.take() { + if let Err(error) = self.finish(&id, completion.await).await { + failure = Some(error); + } + } + if let Some(id) = reserved.take() { + // Do not replace a delivered/completed result whose terminal + // write is already waiting for SQLite to become available. + let has_result = self.pending_results.lock().await.contains_key(&id); + if !has_result { + if let Err(error) = self + .finish(&id, Err(failure.clone().unwrap_or_default())) + .await + { + failure = Some(error); + } + } + } + } + failure.map_or(Ok(()), Err) + } + + async fn run_loop( + &self, + parent: i32, + child: i32, + wake: &Notify, + owner: &RwLock, + active: &mut Option, + reserved: &mut Option, + ) -> Result<(), String> { + loop { + let notified = wake.notified(); + let next = messages::next_queued(&self.db.conn, parent, child) + .await + .map_err(|e| e.to_string())?; + if next.as_ref().is_none_or(|m| m.delivery != "immediate") { + if let Some((id, result)) = active.as_mut() { + tokio::select! { + result = result.as_mut() => { + let id = id.clone(); + *active = None; + self.finish(&id, result).await?; + } + _ = notified => {} + } + continue; + } + } + if active.is_none() && next.is_none() { + // Serialize exit with registration. An enqueue before this + // recheck is observed here; one after it starts a new worker. + let mut workers = self.workers.lock().await; + if messages::next_queued(&self.db.conn, parent, child) + .await + .map_err(|e| e.to_string())? + .is_none() + { + workers.remove(&child); + return Ok(()); + } + continue; + } + let owner = owner.read().await.clone(); + if let Err(error) = self.runtime.prepare(&owner, parent, child).await { + // Disabling new work or losing the parent must not abandon the + // result of a turn we already started. + if let Some((id, result)) = active.take() { + self.finish(&id, result.await).await?; + } + return Err(error); + } + let busy = self.runtime.is_busy(child).await?; + if let Some(message) = next.as_ref().filter(|m| m.delivery == "immediate" && busy) { + if !messages::claim_delivery(&self.db.conn, &message.message_id) + .await + .map_err(|e| e.to_string())? + { + if messages::get(&self.db.conn, parent, child, &message.message_id) + .await + .map_err(|e| e.to_string())? + .is_some_and(|m| m.status == "queued") + { + return Err("child is already being dispatched by another runtime".into()); + } + continue; + } + *reserved = Some(message.message_id.clone()); + let id = message.message_id.clone(); + let message = messages::get(&self.db.conn, parent, child, &id) + .await + .map_err(|e| e.to_string()) + .and_then(|row| row.ok_or_else(|| "reserved message disappeared".to_string())); + let message = match message { + Ok(message) => message, + Err(error) => { + self.finish(&id, Err(error.clone())).await?; + return Err(error); + } + }; + match self.runtime.steer(child, message.message.clone()).await { + Ok(true) => { + // Delivered means injected into the current turn, not + // that the agent has finished following the instruction. + self.finish( + &message.message_id, + Ok(SubsessionTurnResult { + status: "delivered".into(), + text: None, + error: None, + }), + ) + .await?; + } + Ok(false) => { + if let Err(error) = self.runtime.interrupt(child).await { + self.finish(&message.message_id, Err(error)).await?; + *reserved = None; + continue; + } + if let Some((id, result)) = active.take() { + self.finish(&id, result.await).await?; + } + *active = self + .start(&owner, parent, child, &message.message_id) + .await?; + } + Err(error) => self.finish(&message.message_id, Err(error)).await?, + } + *reserved = None; + continue; + } + if let Some((id, result)) = active.as_mut() { + tokio::select! { + result = result.as_mut() => { + let id = id.clone(); + *active = None; + self.finish(&id, result).await?; + } + _ = notified => {} + } + continue; + } + if busy { + tokio::select! { + result = self.runtime.wait_idle(child) => result?, + _ = notified => {} + } + continue; + } + if let Some(message) = next { + *active = self + .start(&owner, parent, child, &message.message_id) + .await?; + if active.is_none() { + // A different runtime may already own this child's running + // message. Leave queued work intact for that owner. + let rows = messages::list(&self.db.conn, parent, child) + .await + .map_err(|e| e.to_string())?; + if rows.iter().any(|r| r.status == "running") { + return Err("child is already being dispatched by another runtime".into()); + } + } + } + } + } +} + +#[async_trait] +impl SubsessionMessageAccess for SubsessionMessages { + async fn send_to_parent( + &self, + child: i32, + message: String, + ) -> super::parent_replies::ParentReplyOutcome { + match &self.parent_replies { + Some(replies) => replies.send(child, message).await, + None => super::parent_replies::ParentReplyOutcome::refused( + "Parent messaging is unavailable", + ), + } + } + + async fn request( + &self, + owner: &str, + parent: i32, + request: SubsessionMessageRequest, + ) -> SubsessionMessageOutcome { + let child = request.child_conversation_id; + // Validate ownership before runtime access or revealing queue contents. + if child <= 0 || messages::list(&self.db.conn, parent, child).await.is_err() { + return SubsessionMessageOutcome::refused("unavailable", "Subsession is unavailable"); + } + if let Err(error) = self.flush_results().await { + return SubsessionMessageOutcome::refused("storage_error", &error); + } + let operation = async { + let message = request + .message + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let id = request.message_id.as_deref().filter(|s| !s.is_empty()); + match request.action { + SubsessionMessageAction::Send => { + let text = message + .ok_or_else(|| ("invalid_message", "Message is empty".to_string()))?; + // prepare also checks the live delegation gate and establishes + // ownership before the original one-shot broker can retire it. + self.runtime + .prepare(owner, parent, child) + .await + .map_err(|e| ("unavailable", e))?; + messages::recover_interrupted(&self.db.conn, parent, child) + .await + .map_err(|e| ("storage_error", e.to_string()))?; + let row = messages::enqueue( + &self.db.conn, + parent, + child, + text, + request.delivery.as_str(), + ) + .await + .map_err(|e| ("storage_error", e.to_string()))?; + self.wake_worker(owner, parent, child).await; + Ok(vec![row]) + } + SubsessionMessageAction::List => { + messages::recover_interrupted(&self.db.conn, parent, child) + .await + .map_err(|e| ("storage_error", e.to_string()))?; + let rows = messages::list(&self.db.conn, parent, child) + .await + .map_err(|e| ("storage_error", e.to_string()))?; + if rows.iter().any(|r| r.status == "queued") { + self.wake_worker(owner, parent, child).await; + } + Ok(rows) + } + SubsessionMessageAction::Update | SubsessionMessageAction::Cancel => { + let id = id.ok_or_else(|| { + ("invalid_message_id", "Message id is required".to_string()) + })?; + let row = if request.action == SubsessionMessageAction::Update { + let text = message + .ok_or_else(|| ("invalid_message", "Message is empty".to_string()))?; + messages::update_queued(&self.db.conn, parent, child, id, text).await + } else { + messages::cancel_queued(&self.db.conn, parent, child, id).await + } + .map_err(|e| ("storage_error", e.to_string()))?; + let Some(row) = row else { + return Err(( + "not_queued", + "Message is missing or has already started; refresh its status" + .to_string(), + )); + }; + self.wake_worker(owner, parent, child).await; + Ok(vec![row]) + } + } + } + .await; + match operation { + Ok(rows) => SubsessionMessageOutcome::accepted(rows), + Err((code, message)) => SubsessionMessageOutcome::refused(code, &message), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + db::{ + entities::conversation, + service::subsession_message_service as messages, + test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}, + }, + models::AgentType, + }; + use sea_orm::sea_query::Expr; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use tokio::sync::oneshot; + + #[derive(Default)] + struct FakeRuntime { + busy: Mutex, + native: bool, + sent: Mutex>, + steered: Mutex>, + turns: Mutex>>, + active: Mutex>>, + changed: Notify, + busy_error_once: std::sync::atomic::AtomicBool, + busy_error_seen: Notify, + prepare_calls: AtomicU64, + prepare_gate: Mutex>>>, + prepare_block_at: AtomicU64, + prepare_entered: Notify, + start_busy_once: std::sync::atomic::AtomicBool, + } + + impl FakeRuntime { + async fn complete(&self, status: &str) { + *self.busy.lock().await = false; + if let Some(tx) = self.active.lock().await.take() { + let _ = tx.send(SubsessionTurnResult { + status: status.into(), + text: Some("reply".into()), + error: None, + }); + } + self.changed.notify_waiters(); + } + } + + #[async_trait] + impl SubsessionRuntime for FakeRuntime { + async fn prepare(&self, _: &str, _: i32, _: i32) -> Result<(), String> { + let call = self.prepare_calls.fetch_add(1, Ordering::AcqRel) + 1; + if call == self.prepare_block_at.load(Ordering::Acquire) { + let gate = self.prepare_gate.lock().await.take(); + if let Some(gate) = gate { + self.prepare_entered.notify_one(); + return gate.await.map_err(|_| "prepare gate closed".to_string())?; + } + } + Ok(()) + } + async fn is_busy(&self, _: i32) -> Result { + if self.busy_error_once.swap(false, Ordering::AcqRel) { + self.busy_error_seen.notify_one(); + return Err("temporary child lookup failure".into()); + } + Ok(*self.busy.lock().await) + } + async fn wait_idle(&self, _: i32) -> Result<(), String> { + loop { + let changed = self.changed.notified(); + if !*self.busy.lock().await { + return Ok(()); + } + changed.await; + } + } + async fn start( + &self, + _: &str, + _: i32, + _: i32, + message: String, + ) -> Result { + if self.start_busy_once.swap(false, Ordering::AcqRel) { + return Err(SubsessionStartError::Busy); + } + let mut busy = self.busy.lock().await; + if *busy { + return Err(SubsessionStartError::Busy); + } + *busy = true; + let (tx, rx) = oneshot::channel(); + let id = uuid::Uuid::new_v4().to_string(); + self.turns.lock().await.insert(id.clone(), rx); + *self.active.lock().await = Some(tx); + self.sent.lock().await.push(message); + Ok(SubsessionTurn { id }) + } + async fn steer(&self, _: i32, message: String) -> Result { + if self.native { + self.steered.lock().await.push(message); + } + Ok(self.native) + } + async fn interrupt(&self, _: i32) -> Result<(), String> { + self.complete("canceled").await; + Ok(()) + } + async fn wait(&self, turn: SubsessionTurn) -> Result { + let rx = self.turns.lock().await.remove(&turn.id).unwrap(); + rx.await.map_err(|_| "closed".into()) + } + } + + async fn setup(busy: bool, native: bool) -> (SubsessionMessages, Arc, i32, i32) { + let db = Arc::new(fresh_in_memory_db().await); + let folder = seed_folder(&db, "D:/message-tests").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.eq(child)) + .exec(&db.conn) + .await + .unwrap(); + let runtime = Arc::new(FakeRuntime { + busy: Mutex::new(busy), + native, + ..Default::default() + }); + ( + SubsessionMessages::with_runtime(db, runtime.clone()), + runtime, + parent, + child, + ) + } + + fn request( + child: i32, + action: SubsessionMessageAction, + text: Option<&str>, + id: Option<&str>, + delivery: SubsessionDelivery, + ) -> SubsessionMessageRequest { + SubsessionMessageRequest { + child_conversation_id: child, + action, + message: text.map(str::to_owned), + message_id: id.map(str::to_owned), + delivery, + } + } + + async fn wait_for_status( + service: &SubsessionMessages, + parent: i32, + child: i32, + id: &str, + status: &str, + ) { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if messages::get(&service.db.conn, parent, child, id) + .await + .unwrap() + .is_some_and(|r| r.status == status) + { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("message reached expected status"); + } + + #[tokio::test] + async fn a_competing_parent_report_does_not_fail_an_unsent_child_prompt() { + let (service, runtime, parent, child) = setup(false, false).await; + runtime.start_busy_once.store(true, Ordering::Release); + let outcome = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("keep this prompt"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(outcome.accepted); + let id = &outcome.messages[0].message_id; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(*runtime.sent.lock().await, ["keep this prompt"]); + runtime.complete("completed").await; + wait_for_status(&service, parent, child, id, "completed").await; + } + + #[tokio::test] + async fn queued_messages_can_be_edited_or_canceled_until_the_child_is_idle() { + let (service, runtime, parent, child) = setup(true, false).await; + let first = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("old"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(first.accepted); + let first_id = first.messages[0].message_id.clone(); + let second = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("remove"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(second.accepted); + let second_id = second.messages[0].message_id.clone(); + assert!( + service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Update, + Some("revised"), + Some(&first_id), + SubsessionDelivery::Queue + ) + ) + .await + .accepted + ); + assert!( + service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Cancel, + None, + Some(&second_id), + SubsessionDelivery::Queue + ) + ) + .await + .accepted + ); + assert!(runtime.sent.lock().await.is_empty()); + runtime.complete("completed").await; + wait_for_status(&service, parent, child, &first_id, "running").await; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.len() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(*runtime.sent.lock().await, vec!["revised"]); + runtime.complete("completed").await; + wait_for_status(&service, parent, child, &first_id, "completed").await; + assert_eq!( + messages::get(&service.db.conn, parent, child, &first_id) + .await + .unwrap() + .unwrap() + .result + .as_deref(), + Some("reply") + ); + assert_eq!( + messages::get(&service.db.conn, parent, child, &second_id) + .await + .unwrap() + .unwrap() + .status, + "canceled" + ); + } + + #[tokio::test] + async fn immediate_messages_steer_without_canceling_the_current_turn() { + let (service, runtime, parent, child) = setup(true, true).await; + let outcome = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("redirect"), + None, + SubsessionDelivery::Immediate, + ), + ) + .await; + assert!(outcome.accepted); + wait_for_status( + &service, + parent, + child, + &outcome.messages[0].message_id, + "delivered", + ) + .await; + assert!(*runtime.busy.lock().await); + assert_eq!(*runtime.steered.lock().await, vec!["redirect"]); + assert!(runtime.sent.lock().await.is_empty()); + runtime.complete("completed").await; + } + + #[tokio::test] + async fn immediate_without_steering_interrupts_then_reuses_the_same_child() { + let (service, runtime, parent, child) = setup(false, false).await; + let first = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("first"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(first.accepted); + wait_for_status( + &service, + parent, + child, + &first.messages[0].message_id, + "running", + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let next = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("change now"), + None, + SubsessionDelivery::Immediate, + ), + ) + .await; + assert!(next.accepted); + wait_for_status( + &service, + parent, + child, + &next.messages[0].message_id, + "running", + ) + .await; + assert_eq!( + messages::get( + &service.db.conn, + parent, + child, + &first.messages[0].message_id + ) + .await + .unwrap() + .unwrap() + .status, + "canceled" + ); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.len() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(*runtime.sent.lock().await, vec!["first", "change now"]); + runtime.complete("completed").await; + wait_for_status( + &service, + parent, + child, + &next.messages[0].message_id, + "completed", + ) + .await; + } + + #[tokio::test] + async fn a_dispatch_error_still_records_the_active_turn_result() { + let (service, runtime, parent, child) = setup(false, false).await; + let first = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("first"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(first.accepted); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + runtime.busy_error_once.store(true, Ordering::Release); + let next = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("redirect"), + None, + SubsessionDelivery::Immediate, + ), + ) + .await; + assert!(next.accepted); + tokio::time::timeout( + std::time::Duration::from_secs(3), + runtime.busy_error_seen.notified(), + ) + .await + .unwrap(); + runtime.complete("completed").await; + wait_for_status( + &service, + parent, + child, + &first.messages[0].message_id, + "completed", + ) + .await; + wait_for_status( + &service, + parent, + child, + &next.messages[0].message_id, + "running", + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.len() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + runtime.complete("completed").await; + wait_for_status( + &service, + parent, + child, + &next.messages[0].message_id, + "completed", + ) + .await; + } + + #[tokio::test] + async fn a_new_message_waking_a_failing_worker_gets_a_fresh_attempt() { + let (service, runtime, parent, child) = setup(false, false).await; + let (release, gate) = oneshot::channel(); + *runtime.prepare_gate.lock().await = Some(gate); + runtime.prepare_block_at.store(2, Ordering::Release); + let first = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("first"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(first.accepted); + tokio::time::timeout( + std::time::Duration::from_secs(3), + runtime.prepare_entered.notified(), + ) + .await + .unwrap(); + let second = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::Send, + Some("second"), + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(second.accepted); + release + .send(Err("temporary prepare failure".into())) + .unwrap(); + wait_for_status( + &service, + parent, + child, + &first.messages[0].message_id, + "running", + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + runtime.complete("completed").await; + wait_for_status( + &service, + parent, + child, + &second.messages[0].message_id, + "running", + ) + .await; + tokio::time::timeout(std::time::Duration::from_secs(3), async { + while runtime.sent.lock().await.len() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(*runtime.sent.lock().await, vec!["first", "second"]); + runtime.complete("completed").await; + wait_for_status( + &service, + parent, + child, + &second.messages[0].message_id, + "completed", + ) + .await; + } + + #[tokio::test] + async fn a_completed_result_survives_a_failed_database_write_without_reexecuting() { + use sea_orm::ConnectionTrait; + let (service, runtime, parent, child) = setup(false, false).await; + let row = messages::enqueue(&service.db.conn, parent, child, "task", "queue") + .await + .unwrap(); + assert!(messages::claim(&service.db.conn, &row.message_id) + .await + .unwrap()); + service + .db + .conn + .execute_unprepared( + "ALTER TABLE subsession_message RENAME TO unavailable_subsession_message", + ) + .await + .unwrap(); + assert!(service + .finish( + &row.message_id, + Ok(SubsessionTurnResult { + status: "completed".into(), + text: Some("completed before the outage".into()), + error: None, + }) + ) + .await + .is_err()); + service + .db + .conn + .execute_unprepared( + "ALTER TABLE unavailable_subsession_message RENAME TO subsession_message", + ) + .await + .unwrap(); + let outcome = service + .request( + "parent", + parent, + request( + child, + SubsessionMessageAction::List, + None, + None, + SubsessionDelivery::Queue, + ), + ) + .await; + assert!(outcome.accepted); + assert_eq!(outcome.messages[0].status, "completed"); + assert_eq!( + outcome.messages[0].result.as_deref(), + Some("completed before the outage") + ); + assert!(runtime.sent.lock().await.is_empty()); + } +} diff --git a/src-tauri/src/acp/subsession_runtime.rs b/src-tauri/src/acp/subsession_runtime.rs new file mode 100644 index 00000000000..d8ef3bb0d8c --- /dev/null +++ b/src-tauri/src/acp/subsession_runtime.rs @@ -0,0 +1,944 @@ +//! ACP adapter for messages delivered into existing direct child conversations. +use crate::acp::{ + delegation::broker::DelegationBroker, + manager::{ConnectionManager, ConnectionManagerSpawner}, + subsession_messages::{ + SubsessionRuntime, SubsessionStartError, SubsessionTurn, SubsessionTurnResult, + }, + types::{AcpEvent, ConnectionStatus, EventEnvelope, PromptInputBlock}, +}; +use crate::db::{ + entities::{conversation, folder, subsession_message}, + AppDatabase, +}; +use crate::models::{agent::AgentType, assistant::AssistantRunSnapshot}; +use async_trait::async_trait; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use tokio::sync::{broadcast, Mutex}; + +type PendingTurn = (String, broadcast::Receiver>); + +pub struct AcpSubsessionRuntime { + manager: Arc, + broker: Arc, + db: Arc, + data_dir: Arc, + preparation_locks: Mutex>>>, + prepared_connections: Mutex>, + turns: Mutex>, +} + +impl AcpSubsessionRuntime { + pub fn new( + manager: Arc, + broker: Arc, + db: Arc, + data_dir: Arc, + ) -> Self { + Self { + manager, + broker, + db, + data_dir, + preparation_locks: Mutex::new(HashMap::new()), + prepared_connections: Mutex::new(HashMap::new()), + turns: Mutex::new(HashMap::new()), + } + } + + async fn child(&self, parent: i32, child: i32) -> Result { + let parent = conversation::Entity::find_by_id(parent) + .one(&self.db.conn) + .await + .map_err(|e| e.to_string())? + .filter(|r| r.deleted_at.is_none()) + .ok_or("parent conversation is unavailable")?; + let row = conversation::Entity::find_by_id(child) + .one(&self.db.conn) + .await + .map_err(|e| e.to_string())? + .filter(|r| { + r.deleted_at.is_none() + && r.parent_id == Some(parent.id) + && r.kind == conversation::ConversationKind::Delegate + }) + .ok_or("conversation is not a direct child of this parent")?; + Ok(row) + } + + async fn recovered_dead_owner(&self, row: &conversation::Model) -> Result { + use subsession_message::{Column, Entity}; + let claims = Entity::find() + .filter(Column::ParentConversationId.eq(row.parent_id)) + .filter(Column::ChildConversationId.eq(row.id)) + .filter(Column::Status.is_in(["running", "dispatching", "interrupted"])) + .all(&self.db.conn) + .await + .map_err(|e| e.to_string())?; + let mut recovered = false; + for claim in claims { + let Some(pid) = claim.owner_pid.and_then(|pid| u32::try_from(pid).ok()) else { + continue; + }; + let dead = crate::cli_run::discovery::pid_is_dead(pid); + if claim.status != "interrupted" && pid != std::process::id() && !dead { + return Ok(false); + } + if claim.status == "interrupted" && dead && claim.updated_at >= row.updated_at { + recovered = true; + } + } + Ok(recovered) + } + + async fn connection(&self, child: i32) -> Result { + if let Some(conn) = self.manager.find_connection_by_conversation_id(child).await { + return Ok(conn); + } + let prepared = self.prepared_connections.lock().await.get(&child).cloned(); + if let Some(conn) = prepared { + if self.manager.get_state(&conn).await.is_some() { + return Ok(conn); + } + } + Err("child session is unavailable on this runtime".into()) + } + + async fn owner(&self, requested: &str, parent: i32) -> Result { + if let Some(state) = self.manager.get_state(requested).await { + if state.read().await.conversation_id == Some(parent) { + return Ok(requested.into()); + } + } + self.manager + .find_connection_by_conversation_id(parent) + .await + .ok_or_else(|| "parent has no available runtime owner; continuation paused".into()) + } +} + +#[async_trait] +impl SubsessionRuntime for AcpSubsessionRuntime { + async fn prepare( + &self, + parent_connection_id: &str, + parent: i32, + child: i32, + ) -> Result<(), String> { + let preparation = self + .preparation_locks + .lock() + .await + .entry(child) + .or_default() + .clone(); + let _preparation = preparation.lock().await; + if !self.broker.config_snapshot().await.enabled { + return Err("delegation is disabled; child message delivery paused".into()); + } + let row = self.child(parent, child).await?; + let original_turn = if let Ok(conn) = self.connection(child).await { + if let Some(state) = self.manager.get_state(&conn).await { + let state = state.read().await; + state.turn_in_flight.then_some(state.turns_completed) + } else { + None + } + } else { + None + }; + self.broker + .retain_child_for_messaging(child, original_turn) + .await; + if self.connection(child).await.is_ok() { + return Ok(()); + } + if row.status == conversation::ConversationStatus::InProgress + && !self.recovered_dead_owner(&row).await? + { + return Err("child is marked running but has no local connection; its runtime ownership is unknown, continuation paused".into()); + } + let owner = self.owner(parent_connection_id, parent).await?; + let external = row + .external_id + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("child has no recorded agent session to resume")?; + let agent = + AgentType::from_wire(&row.agent_type).ok_or("child agent type is unavailable")?; + let folder = folder::Entity::find_by_id(row.folder_id) + .one(&self.db.conn) + .await + .map_err(|e| e.to_string())? + .ok_or("child project folder no longer exists")?; + let working_dir = row.origin_cwd.clone().unwrap_or(folder.path); + let defaults = if row.assistant_id.is_some() { + let snapshot: AssistantRunSnapshot = serde_json::from_str( + row.assistant_snapshot + .as_deref() + .ok_or("child assistant settings are missing")?, + ) + .map_err(|e| format!("child assistant settings cannot be restored: {e}"))?; + snapshot.agent_options + } else { + self.broker + .config_snapshot() + .await + .agent_defaults + .get(&agent) + .cloned() + .unwrap_or_default() + }; + let spawner = ConnectionManagerSpawner { + manager: self.manager.clone(), + db: self.db.clone(), + data_dir: self.data_dir.clone(), + }; + let spawned = spawner + .spawn_for_resume_strict( + &owner, + agent, + Some(working_dir), + external, + defaults.mode_id, + defaults.config_values, + ) + .await + .map_err(|e| e.to_string())?; + // Keep the launch ticket locally; send_prompt_linked adopts the persisted + // conversation and emits its normal lifecycle events when dispatching. + self.prepared_connections + .lock() + .await + .insert(child, spawned.connection_id); + Ok(()) + } + + async fn is_busy(&self, child: i32) -> Result { + let conn = self.connection(child).await?; + let state = self + .manager + .get_state(&conn) + .await + .ok_or("child connection disappeared")?; + let state = state.read().await; + Ok(state.turn_in_flight || state.status == ConnectionStatus::Prompting) + } + + async fn wait_idle(&self, child: i32) -> Result<(), String> { + let conn = self.connection(child).await?; + let state = self + .manager + .get_state(&conn) + .await + .ok_or("child connection disappeared")?; + let mut events = state.read().await.event_stream().subscribe(); + while self.is_busy(child).await? { + events + .recv() + .await + .map_err(|e| format!("child event stream unavailable: {e}"))?; + } + self.manager.wait_prompt_quiescence(&conn).await?; + self.manager.wait_turn_lifecycle(&conn).await + } + + async fn start( + &self, + parent_connection_id: &str, + parent: i32, + child: i32, + message: String, + ) -> Result { + self.prepare(parent_connection_id, parent, child).await?; + let row = self.child(parent, child).await?; + let conn = self.connection(child).await?; + if self.is_busy(child).await? { + return Err(SubsessionStartError::Busy); + } + if let Err(error) = self.manager.wait_prompt_quiescence(&conn).await { + return Err(if self.is_busy(child).await.unwrap_or(false) { + SubsessionStartError::Busy + } else { + SubsessionStartError::Failed(error) + }); + } + self.manager.wait_turn_lifecycle(&conn).await?; + let state = self + .manager + .get_state(&conn) + .await + .ok_or("child connection disappeared")?; + let events = state.read().await.event_stream().subscribe(); + let id = uuid::Uuid::new_v4().to_string(); + self.manager + .send_prompt_linked_with_message_id( + &self.db, + &conn, + vec![PromptInputBlock::Text { text: message }], + Some(row.folder_id), + Some(child), + None, + Some(id.clone()), + ) + .await + .map_err(|e| match e { + crate::acp::error::AcpError::TurnInProgress => SubsessionStartError::Busy, + e => SubsessionStartError::Failed(e.to_string()), + })?; + self.turns.lock().await.insert(id.clone(), (conn, events)); + Ok(SubsessionTurn { id }) + } + + async fn steer(&self, child: i32, message: String) -> Result { + let conn = self.connection(child).await?; + let state = self + .manager + .get_state(&conn) + .await + .ok_or("child connection disappeared")?; + if !state.read().await.native_steering_available { + return Ok(false); + } + match self + .manager + .submit_feedback_native_only(&conn, message) + .await + { + Ok(_) => Ok(true), + Err( + crate::acp::error::AcpError::FeedbackDisabled + | crate::acp::error::AcpError::NoActiveTurn, + ) => Ok(false), + Err(e) => Err(e.to_string()), + } + } + + async fn interrupt(&self, child: i32) -> Result<(), String> { + let conn = self.connection(child).await?; + self.manager.cancel_and_wait(&self.db.conn, &conn).await?; + self.manager.wait_turn_lifecycle(&conn).await + } + + async fn wait(&self, turn: SubsessionTurn) -> Result { + let (conn, mut events) = self + .turns + .lock() + .await + .remove(&turn.id) + .ok_or("unknown or already consumed child turn")?; + let result = collect_turn(&turn.id, &mut events).await?; + self.manager.wait_turn_lifecycle(&conn).await?; + Ok(result) + } +} + +async fn collect_turn( + turn_id: &str, + events: &mut broadcast::Receiver>, +) -> Result { + let mut started = false; + let mut text = String::new(); + let mut error = None; + loop { + let envelope = events + .recv() + .await + .map_err(|e| format!("child turn event stream unavailable: {e}"))?; + match &envelope.payload { + AcpEvent::UserMessage { message_id, .. } if message_id == turn_id => started = true, + AcpEvent::ContentDelta { + text: delta, + parent_tool_use_id: None, + } if started => text.push_str(delta), + AcpEvent::Error { message, .. } if started => error = Some(message.clone()), + AcpEvent::TurnComplete { stop_reason, .. } if started => { + let status = if stop_reason == "cancelled" { + "canceled" + } else if error.is_some() || stop_reason != "end_turn" { + "failed" + } else { + "completed" + }; + return Ok(SubsessionTurnResult { + status: status.into(), + text: (!text.is_empty()).then_some(text), + error, + }); + } + AcpEvent::StatusChanged { + status: ConnectionStatus::Disconnected | ConnectionStatus::Error, + } => { + return Ok(SubsessionTurnResult { + status: "failed".into(), + text: (!text.is_empty()).then_some(text), + error: error.or_else(|| { + Some("child connection ended before its turn completed".into()) + }), + }); + } + _ => {} + } + } +} + +/// The reverse direction uses the same ACP prompt, event collection and +/// lifecycle barriers, but never cancels or steers the parent's current turn. +pub struct AcpParentReplyRuntime { + inner: AcpSubsessionRuntime, +} + +impl AcpParentReplyRuntime { + pub fn new( + manager: Arc, + broker: Arc, + db: Arc, + data_dir: Arc, + ) -> Self { + Self { + inner: AcpSubsessionRuntime::new(manager, broker, db, data_dir), + } + } + + async fn connection_gone(&self, conn: &str) -> bool { + match self.inner.manager.get_state(conn).await { + Some(state) => matches!( + state.read().await.status, + ConnectionStatus::Disconnected | ConnectionStatus::Error + ), + None => true, + } + } + + async fn parent_connection( + &self, + message: &crate::db::entities::parent_reply::Model, + ) -> Result, String> { + let parent = message.parent_conversation_id; + self.inner + .child(parent, message.child_conversation_id) + .await?; + if let Ok(conn) = self.inner.connection(parent).await { + if let Some(state) = self.inner.manager.get_state(&conn).await { + if matches!( + state.read().await.status, + ConnectionStatus::Disconnected | ConnectionStatus::Error + ) { + return Ok(None); + } + } + // A parent can itself be a delegated child. Preserve that live + // connection before its own broker retires the original task. + let row = conversation::Entity::find_by_id(parent) + .one(&self.inner.db.conn) + .await + .map_err(|e| e.to_string())? + .ok_or("parent is unavailable")?; + if row.kind == conversation::ConversationKind::Delegate { + if let Some(state) = self.inner.manager.get_state(&conn).await { + let original_turn = { + let state = state.read().await; + state.turn_in_flight.then_some(state.turns_completed) + }; + self.inner + .broker + .retain_child_for_messaging(parent, original_turn) + .await; + } + } + return Ok(Some(conn)); + } + // An active child can restore its original parent after the UI closed + // the parent's connection. Without either live connection, leave the + // report queued until that conversation is opened again. + let Some(owner) = self + .inner + .manager + .find_connection_by_conversation_id(message.child_conversation_id) + .await + else { + return Ok(None); + }; + crate::db::service::parent_reply_service::recover(&self.inner.db.conn) + .await + .map_err(|e| e.to_string())?; + let row = conversation::Entity::find_by_id(parent) + .one(&self.inner.db.conn) + .await + .map_err(|e| e.to_string())? + .ok_or("parent is unavailable")?; + if row.status == conversation::ConversationStatus::InProgress { + // A dead report owner is not proof of ownership of the current + // parent turn: another runtime may already be handling user work. + // Keep queued until the parent is explicitly reattached locally. + return Ok(None); + } + let external = row + .external_id + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("parent has no saved agent session")?; + let agent = + AgentType::from_wire(&row.agent_type).ok_or("parent agent type is unavailable")?; + let folder = folder::Entity::find_by_id(row.folder_id) + .one(&self.inner.db.conn) + .await + .map_err(|e| e.to_string())? + .ok_or("parent project folder is unavailable")?; + let options = if row.assistant_id.is_some() { + serde_json::from_str::( + row.assistant_snapshot + .as_deref() + .ok_or("parent assistant settings are missing")?, + ) + .map_err(|e| e.to_string())? + .agent_options + } else { + self.inner + .broker + .config_snapshot() + .await + .agent_defaults + .get(&agent) + .cloned() + .unwrap_or_default() + }; + let spawner = ConnectionManagerSpawner { + manager: self.inner.manager.clone(), + db: self.inner.db.clone(), + data_dir: self.inner.data_dir.clone(), + }; + let spawned = spawner + .spawn_for_resume_strict( + &owner, + agent, + Some(row.origin_cwd.unwrap_or(folder.path)), + external, + options.mode_id, + options.config_values, + ) + .await + .map_err(|e| e.to_string())?; + self.inner + .prepared_connections + .lock() + .await + .insert(parent, spawned.connection_id.clone()); + Ok(Some(spawned.connection_id)) + } +} + +#[async_trait] +impl crate::acp::parent_replies::ParentReplyRuntime for AcpParentReplyRuntime { + async fn enabled(&self) -> bool { + self.inner.broker.config_snapshot().await.enabled + } + + async fn wait_ready( + &self, + message: &crate::db::entities::parent_reply::Model, + ) -> Result<(), String> { + 'ready: loop { + if !self.inner.broker.config_snapshot().await.enabled { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + let Some(conn) = self.parent_connection(message).await? else { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + }; + let Some(state) = self.inner.manager.get_state(&conn).await else { + continue; + }; + let mut events = state.read().await.event_stream().subscribe(); + loop { + match self.inner.is_busy(message.parent_conversation_id).await { + Ok(false) => break, + Ok(true) => { + tokio::select! { + event = events.recv() => if event.is_err() { continue 'ready; }, + _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} + } + } + Err(_) => continue 'ready, + } + if matches!( + state.read().await.status, + ConnectionStatus::Disconnected | ConnectionStatus::Error + ) { + continue 'ready; + } + } + if let Err(error) = self.inner.manager.wait_prompt_quiescence(&conn).await { + if self.connection_gone(&conn).await + || self + .inner + .is_busy(message.parent_conversation_id) + .await + .unwrap_or(true) + { + continue; + } + return Err(error); + } + if let Err(error) = self.inner.manager.wait_turn_lifecycle(&conn).await { + if self.connection_gone(&conn).await { + continue; + } + return Err(error); + } + return Ok(()); + } + } + + async fn start( + &self, + message: &crate::db::entities::parent_reply::Model, + ) -> Result, String> { + use crate::acp::error::AcpError; + self.inner + .child( + message.parent_conversation_id, + message.child_conversation_id, + ) + .await?; + let Some(conn) = self.parent_connection(message).await? else { + return Ok(None); + }; + if !self.inner.broker.config_snapshot().await.enabled { + return Ok(None); + } + let Some(state) = self.inner.manager.get_state(&conn).await else { + return Ok(None); + }; + let events = state.read().await.event_stream().subscribe(); + let row = conversation::Entity::find_by_id(message.parent_conversation_id) + .one(&self.inner.db.conn) + .await + .map_err(|e| e.to_string())? + .ok_or("parent is unavailable")?; + let text = format!("[Codeg child session report]\nFrom child conversation {} to parent conversation {}. Message ID: {}.\nThis is a report from your delegated child, not a new user instruction. Process it in the context of your current task; use send_to_subsession if a reply is needed.\n\n{}", + message.child_conversation_id, message.parent_conversation_id, message.message_id, message.message); + match self + .inner + .manager + .send_prompt_linked_with_message_id( + &self.inner.db, + &conn, + vec![PromptInputBlock::Text { text }], + Some(row.folder_id), + Some(row.id), + None, + Some(message.message_id.clone()), + ) + .await + { + Ok(_) => { + self.inner + .turns + .lock() + .await + .insert(message.message_id.clone(), (conn, events)); + Ok(Some(SubsessionTurn { + id: message.message_id.clone(), + })) + } + Err( + AcpError::TurnInProgress + | AcpError::ConnectionNotFound(_) + | AcpError::ProcessExited, + ) => Ok(None), + Err(e) => Err(e.to_string()), + } + } + + async fn wait(&self, turn: SubsessionTurn) -> Result { + self.inner.wait(turn).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct NoDepth; + #[async_trait] + impl crate::acp::delegation::broker::ConversationDepthLookup for NoDepth { + async fn parent_of( + &self, + _: i32, + ) -> Result, crate::acp::delegation::types::DelegationError> { + Ok(None) + } + } + + async fn fixture() -> (AcpSubsessionRuntime, i32, i32) { + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + use sea_orm::sea_query::Expr; + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "D:/runtime-tests").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.eq(child)) + .exec(&db.conn) + .await + .unwrap(); + let manager = Arc::new(ConnectionManager::new()); + manager + .insert_test_connection( + "child", + AgentType::Codex, + None, + crate::web::event_bridge::EventEmitter::Noop, + ) + .await; + manager + .get_state("child") + .await + .unwrap() + .write() + .await + .conversation_id = Some(child); + let spawner = Arc::new(crate::acp::delegation::spawner::mock::MockSpawner::new()); + let broker = Arc::new(DelegationBroker::new(spawner, Arc::new(NoDepth))); + ( + AcpSubsessionRuntime::new(manager, broker, Arc::new(db), Arc::new(PathBuf::new())), + parent, + child, + ) + } + + #[tokio::test] + async fn parent_report_waits_across_disconnect_and_reopens_without_failing() { + use crate::acp::{connection::ConnectionCommand, parent_replies::ParentReplyRuntime}; + use crate::web::event_bridge::EventEmitter; + let (inner, parent, child) = fixture().await; + inner + .broker + .set_config(crate::acp::delegation::broker::DelegationConfig { + enabled: true, + ..Default::default() + }) + .await; + let mut old_commands = inner + .manager + .insert_test_connection_live("old-parent", AgentType::Codex, None, EventEmitter::Noop) + .await; + let old_state = inner.manager.get_state("old-parent").await.unwrap(); + { + let mut state = old_state.write().await; + state.conversation_id = Some(parent); + state.turn_in_flight = true; + } + let message = crate::db::service::parent_reply_service::enqueue( + &inner.db.conn, + child, + "report after reconnect", + ) + .await + .unwrap(); + let runtime = Arc::new(AcpParentReplyRuntime { inner }); + let waiting = { + let runtime = runtime.clone(); + tokio::spawn(async move { runtime.wait_ready(&message).await }) + }; + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + assert!(!waiting.is_finished()); + runtime + .inner + .manager + .disconnect("old-parent") + .await + .unwrap(); + assert!(matches!( + old_commands.recv().await, + Some(ConnectionCommand::Disconnect) + )); + let mut commands = runtime + .inner + .manager + .insert_test_connection_live("new-parent", AgentType::Codex, None, EventEmitter::Noop) + .await; + runtime + .inner + .manager + .get_state("new-parent") + .await + .unwrap() + .write() + .await + .conversation_id = Some(parent); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + match commands.recv().await { + Some(ConnectionCommand::AwaitPromptQuiescence { reply }) => { + reply.send(Ok(())).unwrap(); + } + _ => panic!("expected prompt barrier on the reopened parent"), + } + waiting.await.unwrap().unwrap(); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn restart_resume_requires_recovered_dead_owner_not_just_missing_connection() { + use crate::db::service::subsession_message_service; + use sea_orm::sea_query::Expr; + let (runtime, parent, child) = fixture().await; + let row = runtime.child(parent, child).await.unwrap(); + assert!(!runtime.recovered_dead_owner(&row).await.unwrap()); + let message = + subsession_message_service::enqueue(&runtime.db.conn, parent, child, "old", "queue") + .await + .unwrap(); + subsession_message::Entity::update_many() + .col_expr( + subsession_message::Column::Status, + Expr::value("interrupted"), + ) + .col_expr( + subsession_message::Column::OwnerPid, + Expr::value(i64::from(u32::MAX)), + ) + .filter(subsession_message::Column::Id.eq(message.id)) + .exec(&runtime.db.conn) + .await + .unwrap(); + assert!(runtime.recovered_dead_owner(&row).await.unwrap()); + subsession_message::Entity::update_many() + .col_expr( + subsession_message::Column::OwnerPid, + Expr::value(i64::from(std::process::id())), + ) + .filter(subsession_message::Column::Id.eq(message.id)) + .exec(&runtime.db.conn) + .await + .unwrap(); + assert!(!runtime.recovered_dead_owner(&row).await.unwrap()); + } + + #[tokio::test] + async fn prepare_rechecks_disabled_config_even_for_live_child() { + let (runtime, parent, child) = fixture().await; + assert!(runtime + .prepare("parent", parent, child) + .await + .unwrap_err() + .contains("disabled")); + let mut config = runtime.broker.config_snapshot().await; + config.enabled = true; + runtime.broker.set_config(config.clone()).await; + runtime.prepare("parent", parent, child).await.unwrap(); + config.enabled = false; + runtime.broker.set_config(config).await; + assert!(runtime + .prepare("parent", parent, child) + .await + .unwrap_err() + .contains("disabled")); + } + + #[tokio::test] + async fn runtime_rejects_deleted_parent_and_non_delegate_child() { + use sea_orm::sea_query::Expr; + let (runtime, parent, child) = fixture().await; + assert!(runtime.child(parent, child).await.is_ok()); + conversation::Entity::update_many() + .col_expr(conversation::Column::Kind, Expr::value("regular")) + .filter(conversation::Column::Id.eq(child)) + .exec(&runtime.db.conn) + .await + .unwrap(); + assert!(runtime.child(parent, child).await.is_err()); + conversation::Entity::update_many() + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.eq(child)) + .exec(&runtime.db.conn) + .await + .unwrap(); + conversation::Entity::update_many() + .col_expr( + conversation::Column::DeletedAt, + Expr::value(chrono::Utc::now()), + ) + .filter(conversation::Column::Id.eq(parent)) + .exec(&runtime.db.conn) + .await + .unwrap(); + assert!(runtime.child(parent, child).await.is_err()); + } + + #[tokio::test] + async fn result_ignores_old_completion_and_other_turn_text() { + let (tx, mut rx) = broadcast::channel(16); + let send = |payload| { + tx.send(Arc::new(EventEnvelope { + seq: 0, + connection_id: "child".into(), + payload, + })) + .unwrap() + }; + send(AcpEvent::ContentDelta { + text: "old output".into(), + parent_tool_use_id: None, + }); + send(AcpEvent::TurnComplete { + session_id: "original".into(), + stop_reason: "cancelled".into(), + agent_type: "codex".into(), + }); + send(AcpEvent::UserMessage { + message_id: "ticket".into(), + blocks: vec![], + }); + send(AcpEvent::ContentDelta { + text: "new output".into(), + parent_tool_use_id: None, + }); + send(AcpEvent::ContentDelta { + text: "nested output".into(), + parent_tool_use_id: Some("tool".into()), + }); + send(AcpEvent::TurnComplete { + session_id: "original".into(), + stop_reason: "end_turn".into(), + agent_type: "codex".into(), + }); + let result = collect_turn("ticket", &mut rx).await.unwrap(); + assert_eq!(result.status, "completed"); + assert_eq!(result.text.as_deref(), Some("new output")); + } + + #[tokio::test] + async fn cancellation_settles_the_ticket_as_canceled() { + let (tx, mut rx) = broadcast::channel(4); + for payload in [ + AcpEvent::UserMessage { + message_id: "ticket".into(), + blocks: vec![], + }, + AcpEvent::TurnComplete { + session_id: "original".into(), + stop_reason: "cancelled".into(), + agent_type: "codex".into(), + }, + ] { + tx.send(Arc::new(EventEnvelope { + seq: 0, + connection_id: "child".into(), + payload, + })) + .unwrap(); + } + assert_eq!( + collect_turn("ticket", &mut rx).await.unwrap().status, + "canceled" + ); + } +} diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 77ab999fe21..d035a583038 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -124,6 +124,37 @@ pub fn default_chat_channel_manager() -> ChatChannelManager { ChatChannelManager::new() } +/// Shared child-message service for desktop and server listeners. +pub fn build_subsession_messages( + manager: &ConnectionManager, + broker: Arc, + conn: sea_orm::DatabaseConnection, + data_dir: PathBuf, +) -> Arc { + let db = Arc::new(AppDatabase { conn }); + let runtime = Arc::new(crate::acp::subsession_runtime::AcpSubsessionRuntime::new( + Arc::new(manager.clone_ref()), + broker.clone(), + db.clone(), + Arc::new(data_dir.clone()), + )); + let parent_runtime = Arc::new(crate::acp::subsession_runtime::AcpParentReplyRuntime::new( + Arc::new(manager.clone_ref()), + broker, + db.clone(), + Arc::new(data_dir), + )); + let replies = Arc::new(crate::acp::parent_replies::ParentReplies::new( + db.clone(), + parent_runtime, + )); + replies.recover_pending(); + Arc::new( + crate::acp::subsession_messages::SubsessionMessages::with_runtime(db, runtime) + .with_parent_replies(replies), + ) +} + /// Build the delegation broker + token registry + per-process UDS socket /// path. Shared between codeg-server bootstrap and the Tauri `setup` block /// so both modes apply identical depth limit + timeout defaults. diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 7179953157f..953894b2a0b 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -556,6 +556,12 @@ async fn async_main( Arc::new(codeg_lib::acp::result_tool::ConnectionManagerResultLookup { manager: Arc::new(state.connection_manager.clone_ref()), }), + codeg_lib::app_state::build_subsession_messages( + &state.connection_manager, + state.delegation_broker.clone(), + state.db.conn.clone(), + state.data_dir.clone(), + ), ); // Bind through the service handle rather than a bare `listener.run` // spawn: it keeps the bind error and the accept-loop handle around, so diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs index fa93ff19847..81ce7dd34a4 100644 --- a/src-tauri/src/commands/delegation.rs +++ b/src-tauri/src/commands/delegation.rs @@ -184,7 +184,9 @@ pub async fn set_delegation_enabled_core( .await .map_err(AppCommandError::from)?; let settings = load_delegation_settings(conn).await; - broker.set_config(settings.clone().into_broker_config()).await; + broker + .set_config(settings.clone().into_broker_config()) + .await; emit_event(emitter, DELEGATION_SETTINGS_CHANGED_EVENT, &settings); Ok(settings) } @@ -234,6 +236,95 @@ pub async fn set_delegation_settings_core( // -------- Tauri commands ----------------------------------------------------- +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingSessionMessage { + pub message_id: String, + pub message: String, + pub source_conversation_id: i32, + pub source_title: Option, + pub source_kind: String, + pub created_at: chrono::DateTime, +} + +/// Read-only view of messages waiting for this conversation. Dispatch remains +/// owned by the backend; these entries must never enter the composer's queue. +pub async fn list_pending_session_messages_core( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, crate::db::error::DbError> { + use crate::db::entities::{conversation, parent_reply, subsession_message}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + if conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await? + .is_none() + { + return Ok(Vec::new()); + } + let mut items = Vec::new(); + for row in parent_reply::Entity::find() + .filter(parent_reply::Column::ParentConversationId.eq(conversation_id)) + .filter(parent_reply::Column::Status.eq("queued")) + .all(conn) + .await? + { + items.push(PendingSessionMessage { + message_id: row.message_id, + message: row.message, + source_conversation_id: row.child_conversation_id, + source_title: None, + source_kind: "child".into(), + created_at: row.created_at, + }); + } + for row in subsession_message::Entity::find() + .filter(subsession_message::Column::ChildConversationId.eq(conversation_id)) + .filter(subsession_message::Column::Status.eq("queued")) + .all(conn) + .await? + { + items.push(PendingSessionMessage { + message_id: row.message_id, + message: row.message, + source_conversation_id: row.parent_conversation_id, + source_title: None, + source_kind: "parent".into(), + created_at: row.created_at, + }); + } + if items.is_empty() { + return Ok(items); + } + let sources: BTreeMap<_, _> = conversation::Entity::find() + .filter(conversation::Column::Id.is_in(items.iter().map(|r| r.source_conversation_id))) + .filter(conversation::Column::DeletedAt.is_null()) + .all(conn) + .await? + .into_iter() + .map(|r| (r.id, r.title)) + .collect(); + items.retain(|r| sources.contains_key(&r.source_conversation_id)); + for item in &mut items { + item.source_title = sources.get(&item.source_conversation_id).cloned().flatten(); + } + items.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then(a.message_id.cmp(&b.message_id)) + }); + Ok(items) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_pending_session_messages( + db: tauri::State<'_, crate::db::AppDatabase>, + conversation_id: i32, +) -> Result, AppCommandError> { + Ok(list_pending_session_messages_core(&db.conn, conversation_id).await?) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_delegation_settings( #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, @@ -278,6 +369,69 @@ mod tests { use crate::acp::delegation::types::DelegationError; use async_trait::async_trait; + #[tokio::test] + async fn pending_session_messages_are_scoped_and_disappear_when_dispatched() { + use crate::db::{ + entities::conversation, + service::{parent_reply_service as replies, subsession_message_service as messages}, + test_helpers::*, + }; + use sea_orm::sea_query::Expr; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "D:/pending-messages").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + let other = seed_conversation(&db, folder, AgentType::Codex).await; + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .col_expr(conversation::Column::Title, Expr::value("API review")) + .filter(conversation::Column::Id.eq(child)) + .exec(&db.conn) + .await + .unwrap(); + let report = replies::enqueue(&db.conn, child, "Review needed") + .await + .unwrap(); + messages::enqueue(&db.conn, parent, child, "Check types", "queue") + .await + .unwrap(); + let inbox = list_pending_session_messages_core(&db.conn, parent) + .await + .unwrap(); + assert_eq!(inbox.len(), 1); + assert_eq!(inbox[0].source_title.as_deref(), Some("API review")); + assert_eq!(inbox[0].source_kind, "child"); + let child_inbox = list_pending_session_messages_core(&db.conn, child) + .await + .unwrap(); + assert_eq!(child_inbox.len(), 1); + assert_eq!(child_inbox[0].source_kind, "parent"); + assert!(list_pending_session_messages_core(&db.conn, other) + .await + .unwrap() + .is_empty()); + assert!(replies::claim(&db.conn, &report).await.unwrap()); + assert!(list_pending_session_messages_core(&db.conn, parent) + .await + .unwrap() + .is_empty()); + conversation::Entity::update_many() + .col_expr( + conversation::Column::DeletedAt, + Expr::value(chrono::Utc::now()), + ) + .filter(conversation::Column::Id.eq(parent)) + .exec(&db.conn) + .await + .unwrap(); + assert!(list_pending_session_messages_core(&db.conn, child) + .await + .unwrap() + .is_empty()); + } + struct EmptyLookup; #[async_trait] impl ConversationDepthLookup for EmptyLookup { diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 28a67fe61fb..e83c0baa457 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -30,3 +30,6 @@ pub mod work_task_event; pub mod work_task_settings; pub mod work_task_source_key; pub mod work_task_template; +pub mod subsession_message; + +pub mod parent_reply; diff --git a/src-tauri/src/db/entities/parent_reply.rs b/src-tauri/src/db/entities/parent_reply.rs new file mode 100644 index 00000000000..afa36c6667d --- /dev/null +++ b/src-tauri/src/db/entities/parent_reply.rs @@ -0,0 +1,26 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "parent_reply")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub message_id: String, + pub parent_conversation_id: i32, + pub child_conversation_id: i32, + #[sea_orm(column_type = "Text")] + pub message: String, + pub status: String, + pub owner_pid: Option, + #[sea_orm(column_type = "Text", nullable)] + pub result: Option, + #[sea_orm(column_type = "Text", nullable)] + pub error: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/subsession_message.rs b/src-tauri/src/db/entities/subsession_message.rs new file mode 100644 index 00000000000..307bd9a19ea --- /dev/null +++ b/src-tauri/src/db/entities/subsession_message.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "subsession_message")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub message_id: String, + pub parent_conversation_id: i32, + pub child_conversation_id: i32, + #[sea_orm(column_type = "Text")] + pub message: String, + pub delivery: String, + pub status: String, + pub owner_pid: Option, + #[sea_orm(column_type = "Text", nullable)] + pub result: Option, + #[sea_orm(column_type = "Text", nullable)] + pub error: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/migration/m20261110_000001_subsession_message.rs b/src-tauri/src/db/migration/m20261110_000001_subsession_message.rs new file mode 100644 index 00000000000..00a7b4a825d --- /dev/null +++ b/src-tauri/src/db/migration/m20261110_000001_subsession_message.rs @@ -0,0 +1,126 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend, Statement}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(SubsessionMessage::Table) + .col( + ColumnDef::new(SubsessionMessage::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(SubsessionMessage::MessageId) + .string() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(SubsessionMessage::ParentConversationId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(SubsessionMessage::ChildConversationId) + .integer() + .not_null(), + ) + .col(ColumnDef::new(SubsessionMessage::Message).text().not_null()) + .col( + ColumnDef::new(SubsessionMessage::Delivery) + .string() + .not_null(), + ) + .col( + ColumnDef::new(SubsessionMessage::Status) + .string() + .not_null(), + ) + .col(ColumnDef::new(SubsessionMessage::OwnerPid).big_integer()) + .col(ColumnDef::new(SubsessionMessage::Result).text()) + .col(ColumnDef::new(SubsessionMessage::Error).text()) + .col( + ColumnDef::new(SubsessionMessage::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(SubsessionMessage::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + SubsessionMessage::Table, + SubsessionMessage::ParentConversationId, + ) + .to(Conversation::Table, Conversation::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from( + SubsessionMessage::Table, + SubsessionMessage::ChildConversationId, + ) + .to(Conversation::Table, Conversation::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .name("idx_subsession_message_queue") + .table(SubsessionMessage::Table) + .col(SubsessionMessage::ChildConversationId) + .col(SubsessionMessage::Status) + .col(SubsessionMessage::Delivery) + .col(SubsessionMessage::Id) + .to_owned(), + ) + .await?; + manager.get_connection().execute(Statement::from_string(DbBackend::Sqlite, + "CREATE UNIQUE INDEX idx_subsession_message_running ON subsession_message(child_conversation_id) WHERE status = 'running'".to_owned())).await?; + manager.get_connection().execute(Statement::from_string(DbBackend::Sqlite, + "CREATE UNIQUE INDEX idx_subsession_message_dispatching ON subsession_message(child_conversation_id) WHERE status = 'dispatching'".to_owned())).await?; + Ok(()) + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(SubsessionMessage::Table).to_owned()) + .await + } +} +#[derive(DeriveIden)] +enum SubsessionMessage { + Table, + Id, + MessageId, + ParentConversationId, + ChildConversationId, + Message, + Delivery, + Status, + OwnerPid, + Result, + Error, + CreatedAt, + UpdatedAt, +} +#[derive(DeriveIden)] +enum Conversation { + Table, + Id, +} diff --git a/src-tauri/src/db/migration/m20261111_000001_parent_reply.rs b/src-tauri/src/db/migration/m20261111_000001_parent_reply.rs new file mode 100644 index 00000000000..36e68713226 --- /dev/null +++ b/src-tauri/src/db/migration/m20261111_000001_parent_reply.rs @@ -0,0 +1,39 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Same SQLite ownership/FIFO model as subsession_message, addressed to + // the parent rather than the child. Separate indexes serialize reports + // from different children without blocking the existing child queue. + manager.get_connection().execute_unprepared( + "CREATE TABLE parent_reply ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + parent_conversation_id INTEGER NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + child_conversation_id INTEGER NOT NULL REFERENCES conversation(id) ON DELETE CASCADE, + message TEXT NOT NULL, + status TEXT NOT NULL, + owner_pid BIGINT, + result TEXT, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX idx_parent_reply_queue ON parent_reply(parent_conversation_id, status, id); + CREATE UNIQUE INDEX idx_parent_reply_running ON parent_reply(parent_conversation_id) WHERE status = 'running';" + ).await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE parent_reply") + .await?; + Ok(()) + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 51deffa3aab..cec75bc13b2 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -57,6 +57,8 @@ mod m20261020_000001_folder_command_run_local; mod m20261022_000001_tool_approval; mod m20261022_000001_work_task_delivery; mod m20261106_000001_work_task_source_key; +mod m20261110_000001_subsession_message; +mod m20261111_000001_parent_reply; pub struct Migrator; #[async_trait::async_trait] @@ -120,6 +122,8 @@ impl MigratorTrait for Migrator { Box::new(m20261022_000001_tool_approval::Migration), Box::new(m20261022_000001_work_task_delivery::Migration), Box::new(m20261106_000001_work_task_source_key::Migration), + Box::new(m20261110_000001_subsession_message::Migration), + Box::new(m20261111_000001_parent_reply::Migration), ] } } diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index d11b8a081e3..8f0f5d6203d 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -22,3 +22,6 @@ pub mod thread_binding_service; pub mod token_usage_service; pub mod tool_approval_service; pub mod work_task_service; +pub mod subsession_message_service; + +pub mod parent_reply_service; diff --git a/src-tauri/src/db/service/parent_reply_service.rs b/src-tauri/src/db/service/parent_reply_service.rs new file mode 100644 index 00000000000..0ce3b66c568 --- /dev/null +++ b/src-tauri/src/db/service/parent_reply_service.rs @@ -0,0 +1,251 @@ +//! Persistent, authenticated direct-child reports to a parent's inbox. +use crate::db::{ + entities::{ + conversation, + parent_reply::{self, Column, Entity, Model}, + }, + error::DbError, +}; +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; + +pub async fn parent_of(conn: &DatabaseConnection, child: i32) -> Result { + let row = conversation::Entity::find_by_id(child) + .one(conn) + .await? + .filter(|r| r.deleted_at.is_none() && r.kind == conversation::ConversationKind::Delegate) + .ok_or_else(|| DbError::NotFound("delegated child".into()))?; + let parent = row + .parent_id + .ok_or_else(|| DbError::NotFound("parent".into()))?; + if conversation::Entity::find_by_id(parent) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await? + .is_none() + { + return Err(DbError::NotFound("parent".into())); + } + Ok(parent) +} + +pub async fn enqueue( + conn: &DatabaseConnection, + child: i32, + message: &str, +) -> Result { + let parent = parent_of(conn, child).await?; + if message.trim().is_empty() || message.chars().count() > 32_000 { + return Err(DbError::Validation( + "message must contain 1 to 32000 characters".into(), + )); + } + let now = Utc::now(); + Ok(parent_reply::ActiveModel { + message_id: Set(uuid::Uuid::new_v4().to_string()), + parent_conversation_id: Set(parent), + child_conversation_id: Set(child), + message: Set(message.into()), + status: Set("queued".into()), + owner_pid: Set(None), + result: Set(None), + error: Set(None), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn next(conn: &DatabaseConnection, parent: i32) -> Result, DbError> { + Ok(Entity::find() + .filter(Column::ParentConversationId.eq(parent)) + .filter(Column::Status.eq("queued")) + .order_by_asc(Column::Id) + .one(conn) + .await?) +} + +pub async fn get(conn: &DatabaseConnection, message: &str) -> Result, DbError> { + Ok(Entity::find() + .filter(Column::MessageId.eq(message)) + .one(conn) + .await?) +} + +pub async fn fail_queued( + conn: &DatabaseConnection, + message: &str, + error: &str, +) -> Result<(), DbError> { + Entity::update_many() + .col_expr(Column::Status, Expr::value("failed")) + .col_expr(Column::Error, Expr::value(error)) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(message)) + .filter(Column::Status.eq("queued")) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn claim(conn: &DatabaseConnection, row: &Model) -> Result { + if parent_of(conn, row.child_conversation_id).await? != row.parent_conversation_id { + return Err(DbError::NotFound("parent".into())); + } + let result = Entity::update_many() + .col_expr(Column::Status, Expr::value("running")) + .col_expr(Column::OwnerPid, Expr::value(i64::from(std::process::id()))) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(&row.message_id)) + .filter(Column::Status.eq("queued")) + .exec(conn) + .await; + match result { + Ok(r) => Ok(r.rows_affected == 1), + Err(e) + if matches!( + e.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) => + { + Ok(false) + } + Err(e) => Err(e.into()), + } +} + +pub async fn finish( + conn: &DatabaseConnection, + message: &str, + status: &str, + text: Option<&str>, + error: Option<&str>, +) -> Result<(), DbError> { + if !matches!(status, "queued" | "completed" | "failed" | "canceled") { + return Err(DbError::Validation("invalid parent reply status".into())); + } + Entity::update_many() + .col_expr(Column::Status, Expr::value(status)) + .col_expr(Column::Result, Expr::value(text.map(str::to_owned))) + .col_expr(Column::Error, Expr::value(error.map(str::to_owned))) + .col_expr( + Column::OwnerPid, + Expr::value(if status == "queued" { + None + } else { + Some(i64::from(std::process::id())) + }), + ) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(message)) + .filter(Column::Status.eq("running")) + .filter(Column::OwnerPid.eq(i64::from(std::process::id()))) + .exec(conn) + .await?; + Ok(()) +} + +pub async fn recover(conn: &DatabaseConnection) -> Result, DbError> { + // An already dispatched prompt must never run again after a restart. + for row in Entity::find() + .filter(Column::Status.eq("running")) + .all(conn) + .await? + { + if row + .owner_pid + .and_then(|p| u32::try_from(p).ok()) + .is_some_and(crate::cli_run::discovery::pid_is_dead) + { + Entity::update_many() + .col_expr(Column::Status, Expr::value("interrupted")) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::Id.eq(row.id)) + .filter(Column::Status.eq("running")) + .filter(Column::OwnerPid.eq(row.owner_pid)) + .exec(conn) + .await?; + } + } + let mut parents: Vec = Entity::find() + .filter(Column::Status.eq("queued")) + .all(conn) + .await? + .into_iter() + .map(|r| r.parent_conversation_id) + .collect(); + parents.sort_unstable(); + parents.dedup(); + Ok(parents) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + use crate::models::AgentType; + + #[tokio::test] + async fn only_live_delegated_children_can_report_and_claims_serialize_siblings() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "D:/parent-reply").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + assert!(enqueue(&db.conn, child, "cannot spoof a parent") + .await + .is_err()); + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.eq(child)) + .exec(&db.conn) + .await + .unwrap(); + let a = enqueue(&db.conn, child, "first").await.unwrap(); + let b = enqueue(&db.conn, child, "second").await.unwrap(); + assert_eq!( + next(&db.conn, parent).await.unwrap().unwrap().message_id, + a.message_id + ); + assert!(claim(&db.conn, &a).await.unwrap()); + assert!(!claim(&db.conn, &a).await.unwrap()); + assert!(!claim(&db.conn, &b).await.unwrap()); + finish(&db.conn, &a.message_id, "completed", Some("received"), None) + .await + .unwrap(); + assert!(claim(&db.conn, &b).await.unwrap()); + finish(&db.conn, &b.message_id, "queued", None, None) + .await + .unwrap(); + assert_eq!( + get(&db.conn, &b.message_id).await.unwrap().unwrap().status, + "queued" + ); + assert_eq!(recover(&db.conn).await.unwrap(), vec![parent]); + assert!(claim(&db.conn, &b).await.unwrap()); + Entity::update_many() + .col_expr(Column::OwnerPid, Expr::value(2_000_000_000i64)) + .filter(Column::Id.eq(b.id)) + .exec(&db.conn) + .await + .unwrap(); + recover(&db.conn).await.unwrap(); + assert_eq!( + get(&db.conn, &b.message_id).await.unwrap().unwrap().status, + "interrupted" + ); + assert!(!claim(&db.conn, &b).await.unwrap()); + conversation::Entity::update_many() + .col_expr(conversation::Column::DeletedAt, Expr::value(Utc::now())) + .filter(conversation::Column::Id.eq(parent)) + .exec(&db.conn) + .await + .unwrap(); + assert!(enqueue(&db.conn, child, "deleted").await.is_err()); + } +} diff --git a/src-tauri/src/db/service/subsession_message_service.rs b/src-tauri/src/db/service/subsession_message_service.rs new file mode 100644 index 00000000000..60f28d70e1b --- /dev/null +++ b/src-tauri/src/db/service/subsession_message_service.rs @@ -0,0 +1,568 @@ +//! Durable per-child messages. User operations always verify the live direct parent. +use crate::db::{ + entities::{ + conversation, + subsession_message::{self, Column, Entity, Model}, + }, + error::DbError, +}; +use chrono::Utc; +use sea_orm::sea_query::{Alias, Expr, Query}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; + +async fn validate(conn: &DatabaseConnection, parent: i32, child: i32) -> Result<(), DbError> { + let parent_exists = conversation::Entity::find_by_id(parent) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await? + .is_some(); + let child_exists = conversation::Entity::find_by_id(child) + .filter(conversation::Column::DeletedAt.is_null()) + .filter(conversation::Column::ParentId.eq(parent)) + .filter(conversation::Column::Kind.eq(conversation::ConversationKind::Delegate)) + .one(conn) + .await? + .is_some(); + if !parent_exists || !child_exists { + return Err(DbError::NotFound("subsession".into())); + } + Ok(()) +} +fn scoped(parent: i32, child: i32) -> sea_orm::Select { + Entity::find() + .filter(Column::ParentConversationId.eq(parent)) + .filter(Column::ChildConversationId.eq(child)) +} +pub async fn enqueue( + conn: &DatabaseConnection, + parent: i32, + child: i32, + message: &str, + delivery: &str, +) -> Result { + validate(conn, parent, child).await?; + if !matches!(delivery, "queue" | "immediate") { + return Err(DbError::Validation("invalid delivery".into())); + } + if message.trim().is_empty() { + return Err(DbError::Validation("message is empty".into())); + } + let now = Utc::now(); + Ok(subsession_message::ActiveModel { + message_id: Set(uuid::Uuid::new_v4().to_string()), + parent_conversation_id: Set(parent), + child_conversation_id: Set(child), + message: Set(message.into()), + delivery: Set(delivery.into()), + status: Set("queued".into()), + owner_pid: Set(None), + result: Set(None), + error: Set(None), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} +pub async fn list( + conn: &DatabaseConnection, + parent: i32, + child: i32, +) -> Result, DbError> { + validate(conn, parent, child).await?; + Ok(scoped(parent, child) + .order_by_asc(Column::Id) + .all(conn) + .await?) +} +pub async fn get( + conn: &DatabaseConnection, + parent: i32, + child: i32, + message_id: &str, +) -> Result, DbError> { + validate(conn, parent, child).await?; + Ok(scoped(parent, child) + .filter(Column::MessageId.eq(message_id)) + .one(conn) + .await?) +} +pub async fn next_queued( + conn: &DatabaseConnection, + parent: i32, + child: i32, +) -> Result, DbError> { + validate(conn, parent, child).await?; + // Alphabetical delivery order puts immediate before queue; id is the durable FIFO. + Ok(scoped(parent, child) + .filter(Column::Status.eq("queued")) + .order_by_asc(Column::Delivery) + .order_by_asc(Column::Id) + .one(conn) + .await?) +} +pub async fn update_queued( + conn: &DatabaseConnection, + parent: i32, + child: i32, + message_id: &str, + message: &str, +) -> Result, DbError> { + validate(conn, parent, child).await?; + if message.trim().is_empty() { + return Err(DbError::Validation("message is empty".into())); + } + let changed = Entity::update_many() + .col_expr(Column::Message, Expr::value(message)) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::ParentConversationId.eq(parent)) + .filter(Column::ChildConversationId.eq(child)) + .filter(Column::MessageId.eq(message_id)) + .filter(Column::Status.eq("queued")) + .exec(conn) + .await? + .rows_affected; + if changed == 0 { + return Ok(None); + } + get(conn, parent, child, message_id).await +} +pub async fn cancel_queued( + conn: &DatabaseConnection, + parent: i32, + child: i32, + message_id: &str, +) -> Result, DbError> { + validate(conn, parent, child).await?; + let changed = Entity::update_many() + .col_expr(Column::Status, Expr::value("canceled")) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::ParentConversationId.eq(parent)) + .filter(Column::ChildConversationId.eq(child)) + .filter(Column::MessageId.eq(message_id)) + .filter(Column::Status.eq("queued")) + .exec(conn) + .await? + .rows_affected; + if changed == 0 { + return Ok(None); + } + get(conn, parent, child, message_id).await +} +async fn validate_message(conn: &DatabaseConnection, message_id: &str) -> Result { + let Some(row) = Entity::find() + .filter(Column::MessageId.eq(message_id)) + .one(conn) + .await? + else { + return Ok(false); + }; + validate(conn, row.parent_conversation_id, row.child_conversation_id).await?; + Ok(true) +} +async fn claim_as( + conn: &DatabaseConnection, + message_id: &str, + status: &str, +) -> Result { + if !validate_message(conn, message_id).await? { + return Ok(false); + } + let mut eligible = sea_orm::Condition::any().add(Column::Status.eq("queued")); + if status == "running" { + eligible = eligible.add( + sea_orm::Condition::all() + .add(Column::Status.eq("dispatching")) + .add(Column::OwnerPid.eq(i64::from(std::process::id()))), + ); + } + // Correlated NOT EXISTS executes in the same write as the claim, so a + // different process cannot reserve steering between an ownership read and write. + let other = Alias::new("other_message"); + let foreign_active = Query::select() + .expr(Expr::value(1)) + .from_as(Entity, other.clone()) + .and_where( + Expr::col((other.clone(), Column::ChildConversationId)) + .equals((Entity, Column::ChildConversationId)), + ) + .and_where(Expr::col((other.clone(), Column::Status)).is_in(["running", "dispatching"])) + .cond_where( + sea_orm::Condition::any() + .add(Expr::col((other.clone(), Column::OwnerPid)).ne(i64::from(std::process::id()))) + .add(Expr::col((other, Column::OwnerPid)).is_null()), + ) + .to_owned(); + let result = Entity::update_many() + .col_expr(Column::Status, Expr::value(status)) + .col_expr(Column::OwnerPid, Expr::value(i64::from(std::process::id()))) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(message_id)) + .filter(eligible) + .filter( + sea_orm::Condition::all() + .not() + .add(Expr::exists(foreign_active)), + ) + .exec(conn) + .await; + match result { + Ok(result) => Ok(result.rows_affected == 1), + Err(e) + if matches!( + e.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) + ) => + { + Ok(false) + } + Err(e) => Err(e.into()), + } +} +/// Internal runtime claim; the unique index prevents two runtimes executing one child. +pub async fn claim(conn: &DatabaseConnection, message_id: &str) -> Result { + claim_as(conn, message_id, "running").await +} + +/// The ACP send gate rejected the prompt before dispatch; let the next idle +/// turn claim it again instead of treating a competing prompt as a failure. +pub async fn release_claim(conn: &DatabaseConnection, message_id: &str) -> Result<(), DbError> { + Entity::update_many() + .col_expr(Column::Status, Expr::value("queued")) + .col_expr(Column::OwnerPid, Expr::value(Option::::None)) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(message_id)) + .filter(Column::Status.eq("running")) + .filter(Column::OwnerPid.eq(i64::from(std::process::id()))) + .exec(conn).await?; + Ok(()) +} +/// Reserve an immediate steering note while the child's existing turn keeps running. +pub async fn claim_delivery(conn: &DatabaseConnection, message_id: &str) -> Result { + claim_as(conn, message_id, "dispatching").await +} +pub async fn finish( + conn: &DatabaseConnection, + message_id: &str, + status: &str, + result: Option<&str>, + error: Option<&str>, +) -> Result { + if !validate_message(conn, message_id).await? { + return Ok(false); + } + if !matches!( + status, + "delivered" | "completed" | "failed" | "canceled" | "interrupted" + ) { + return Err(DbError::Validation("invalid terminal status".into())); + } + Ok(Entity::update_many() + .col_expr(Column::Status, Expr::value(status)) + .col_expr(Column::Result, Expr::value(result.map(str::to_owned))) + .col_expr(Column::Error, Expr::value(error.map(str::to_owned))) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::MessageId.eq(message_id)) + .filter(Column::OwnerPid.eq(i64::from(std::process::id()))) + .filter(Column::Status.is_in(["running", "dispatching"])) + .exec(conn) + .await? + .rows_affected + == 1) +} +/// Recover only rows whose claiming process is demonstrably dead. +pub async fn recover_interrupted( + conn: &DatabaseConnection, + parent: i32, + child: i32, +) -> Result { + validate(conn, parent, child).await?; + let rows = scoped(parent, child) + .filter(Column::Status.is_in(["running", "dispatching"])) + .all(conn) + .await?; + let mut count = 0; + for row in rows { + let Some(pid) = row.owner_pid.and_then(|pid| u32::try_from(pid).ok()) else { + continue; + }; + if !crate::cli_run::discovery::pid_is_dead(pid) { + continue; + } + count += Entity::update_many() + .col_expr(Column::Status, Expr::value("interrupted")) + .col_expr(Column::UpdatedAt, Expr::value(Utc::now())) + .filter(Column::Id.eq(row.id)) + .filter(Column::OwnerPid.eq(i64::from(pid))) + .filter(Column::Status.is_in(["running", "dispatching"])) + .exec(conn) + .await? + .rows_affected; + } + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{ + test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}, + AppDatabase, + }; + use crate::models::agent::AgentType; + async fn setup() -> (AppDatabase, i32, i32, i32) { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "D:/subsession-test").await; + let parent = seed_conversation(&db, folder, AgentType::Codex).await; + let child = seed_conversation(&db, folder, AgentType::Codex).await; + let foreign = seed_conversation(&db, folder, AgentType::Codex).await; + conversation::Entity::update_many() + .col_expr(conversation::Column::ParentId, Expr::value(parent)) + .col_expr(conversation::Column::Kind, Expr::value("delegate")) + .filter(conversation::Column::Id.eq(child)) + .exec(&db.conn) + .await + .unwrap(); + (db, parent, child, foreign) + } + #[tokio::test] + async fn ownership_and_deleted_parents_are_rejected() { + let (db, parent, child, foreign) = setup().await; + let row = enqueue(&db.conn, parent, child, "hello", "queue") + .await + .unwrap(); + assert!(list(&db.conn, foreign, child).await.is_err()); + assert!(enqueue(&db.conn, parent, foreign, "hello", "queue") + .await + .is_err()); + assert!(get(&db.conn, foreign, child, &row.message_id) + .await + .is_err()); + assert!( + update_queued(&db.conn, foreign, child, &row.message_id, "bad") + .await + .is_err() + ); + assert!(cancel_queued(&db.conn, foreign, child, &row.message_id) + .await + .is_err()); + assert!(next_queued(&db.conn, foreign, child).await.is_err()); + assert!(recover_interrupted(&db.conn, foreign, child).await.is_err()); + conversation::Entity::update_many() + .col_expr(conversation::Column::DeletedAt, Expr::value(Utc::now())) + .filter(conversation::Column::Id.eq(parent)) + .exec(&db.conn) + .await + .unwrap(); + assert!(list(&db.conn, parent, child).await.is_err()); + assert!(claim(&db.conn, &row.message_id).await.is_err()); + } + #[tokio::test] + async fn priority_fifo_and_terminal_results_are_durable() { + let (db, p, c, _) = setup().await; + let first = enqueue(&db.conn, p, c, "first", "queue").await.unwrap(); + let second = enqueue(&db.conn, p, c, "second", "queue").await.unwrap(); + let immediate = enqueue(&db.conn, p, c, "now", "immediate").await.unwrap(); + assert_eq!( + next_queued(&db.conn, p, c) + .await + .unwrap() + .unwrap() + .message_id, + immediate.message_id + ); + assert!(claim_delivery(&db.conn, &immediate.message_id) + .await + .unwrap()); + assert!(finish( + &db.conn, + &immediate.message_id, + "delivered", + Some("steered"), + None + ) + .await + .unwrap()); + assert_eq!( + next_queued(&db.conn, p, c) + .await + .unwrap() + .unwrap() + .message_id, + first.message_id + ); + assert!(claim(&db.conn, &first.message_id).await.unwrap()); + assert!(finish( + &db.conn, + &first.message_id, + "completed", + Some("answer"), + None + ) + .await + .unwrap()); + assert!( + !finish(&db.conn, &first.message_id, "failed", None, Some("late")) + .await + .unwrap() + ); + assert_eq!( + get(&db.conn, p, c, &first.message_id) + .await + .unwrap() + .unwrap() + .result + .as_deref(), + Some("answer") + ); + assert_eq!( + next_queued(&db.conn, p, c) + .await + .unwrap() + .unwrap() + .message_id, + second.message_id + ); + } + #[tokio::test] + async fn dispatching_reservation_promotes_only_for_its_owner_and_live_owners_survive_recovery() + { + let (db, p, c, _) = setup().await; + let row = enqueue(&db.conn, p, c, "steer", "immediate").await.unwrap(); + assert!(claim_delivery(&db.conn, &row.message_id).await.unwrap()); + assert!(!claim_delivery(&db.conn, &row.message_id).await.unwrap()); + assert!(cancel_queued(&db.conn, p, c, &row.message_id) + .await + .unwrap() + .is_none()); + assert_eq!(recover_interrupted(&db.conn, p, c).await.unwrap(), 0); + Entity::update_many() + .col_expr(Column::OwnerPid, Expr::value(1i64)) + .filter(Column::MessageId.eq(&row.message_id)) + .exec(&db.conn) + .await + .unwrap(); + assert!(!claim(&db.conn, &row.message_id).await.unwrap()); + Entity::update_many() + .col_expr(Column::OwnerPid, Expr::value(i64::from(std::process::id()))) + .filter(Column::MessageId.eq(&row.message_id)) + .exec(&db.conn) + .await + .unwrap(); + assert!(claim(&db.conn, &row.message_id).await.unwrap()); + assert_eq!(recover_interrupted(&db.conn, p, c).await.unwrap(), 0); + } + + #[tokio::test] + async fn foreign_runtime_cannot_finish_or_reserve_the_child() { + let (db, p, c, _) = setup().await; + let a = enqueue(&db.conn, p, c, "active", "queue").await.unwrap(); + let b = enqueue(&db.conn, p, c, "steer", "immediate").await.unwrap(); + assert!(claim(&db.conn, &a.message_id).await.unwrap()); + Entity::update_many() + .col_expr(Column::OwnerPid, Expr::value(1i64)) + .filter(Column::MessageId.eq(&a.message_id)) + .exec(&db.conn) + .await + .unwrap(); + assert!( + !finish(&db.conn, &a.message_id, "completed", Some("foreign"), None) + .await + .unwrap() + ); + assert!(!claim_delivery(&db.conn, &b.message_id).await.unwrap()); + assert!(!claim(&db.conn, &b.message_id).await.unwrap()); + assert!(!finish(&db.conn, &b.message_id, "failed", None, None) + .await + .unwrap()); + Entity::update_many() + .col_expr(Column::Status, Expr::value("dispatching")) + .filter(Column::MessageId.eq(&a.message_id)) + .exec(&db.conn) + .await + .unwrap(); + assert!(!claim(&db.conn, &b.message_id).await.unwrap()); + assert!(!claim_delivery(&db.conn, &b.message_id).await.unwrap()); + assert!(!finish(&db.conn, &a.message_id, "delivered", None, None) + .await + .unwrap()); + assert_eq!( + get(&db.conn, p, c, &a.message_id) + .await + .unwrap() + .unwrap() + .status, + "dispatching" + ); + } + + #[tokio::test] + async fn dispatching_claims_compete_but_allow_same_owner_running() { + let (db, p, c, _) = setup().await; + let active = enqueue(&db.conn, p, c, "active", "queue").await.unwrap(); + let a = enqueue(&db.conn, p, c, "a", "immediate").await.unwrap(); + let b = enqueue(&db.conn, p, c, "b", "immediate").await.unwrap(); + assert!(claim(&db.conn, &active.message_id).await.unwrap()); + let (left, right) = tokio::join!( + claim_delivery(&db.conn, &a.message_id), + claim_delivery(&db.conn, &b.message_id) + ); + assert_eq!(usize::from(left.unwrap()) + usize::from(right.unwrap()), 1); + assert_eq!( + get(&db.conn, p, c, &active.message_id) + .await + .unwrap() + .unwrap() + .status, + "running" + ); + } + + #[tokio::test] + async fn claims_compete_and_queue_edits_cannot_touch_claimed_rows() { + let (db, p, c, _) = setup().await; + let a = enqueue(&db.conn, p, c, "a", "queue").await.unwrap(); + let b = enqueue(&db.conn, p, c, "b", "queue").await.unwrap(); + let (left, right) = tokio::join!( + claim(&db.conn, &a.message_id), + claim(&db.conn, &a.message_id) + ); + assert_eq!(usize::from(left.unwrap()) + usize::from(right.unwrap()), 1); + assert!(!claim(&db.conn, &b.message_id).await.unwrap()); + assert!(update_queued(&db.conn, p, c, &a.message_id, "edit") + .await + .unwrap() + .is_none()); + assert!(cancel_queued(&db.conn, p, c, &a.message_id) + .await + .unwrap() + .is_none()); + assert_eq!( + update_queued(&db.conn, p, c, &b.message_id, "edited") + .await + .unwrap() + .unwrap() + .message, + "edited" + ); + assert!(cancel_queued(&db.conn, p, c, &b.message_id) + .await + .unwrap() + .is_some()); + assert!(!claim(&db.conn, &b.message_id).await.unwrap()); + assert!(finish(&db.conn, &a.message_id, "interrupted", None, None) + .await + .unwrap()); + assert_eq!( + get(&db.conn, p, c, &a.message_id) + .await + .unwrap() + .unwrap() + .status, + "interrupted" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5900e5aef0..dae1b9788f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1340,6 +1340,12 @@ mod tauri_app { manager: std::sync::Arc::new(cm_state.clone_ref()), }, ), + crate::app_state::build_subsession_messages( + &cm_state, + broker.clone(), + db_conn.clone(), + effective_data_dir.clone(), + ), ); // Bind through the service handle rather than a bare // `listener.run` spawn: it keeps the bind error and the @@ -2090,6 +2096,7 @@ mod tauri_app { logging_commands::list_log_files, logging_commands::open_logs_dir, delegation_commands::get_delegation_settings, + delegation_commands::list_pending_session_messages, delegation_commands::set_delegation_settings, crate::commands::mcp_service::get_codeg_mcp_service_status, crate::commands::mcp_service::start_codeg_mcp_service, diff --git a/src-tauri/src/web/handlers/delegation.rs b/src-tauri/src/web/handlers/delegation.rs index ae5547afa35..366741ee9d3 100644 --- a/src-tauri/src/web/handlers/delegation.rs +++ b/src-tauri/src/web/handlers/delegation.rs @@ -22,6 +22,25 @@ pub async fn get_delegation_settings( Ok(Json(load_delegation_settings(&state.db.conn).await)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingSessionMessagesParams { + pub conversation_id: i32, +} + +pub async fn list_pending_session_messages( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + crate::commands::delegation::list_pending_session_messages_core( + &state.db.conn, + params.conversation_id, + ) + .await?, + )) +} + #[derive(Deserialize)] pub struct SetDelegationSettingsParams { pub settings: DelegationSettings, @@ -31,13 +50,12 @@ pub async fn set_delegation_settings( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let saved = - set_delegation_settings_core( + let saved = set_delegation_settings_core( &state.db.conn, &state.delegation_broker, &state.emitter, params.settings, ) - .await?; + .await?; Ok(Json(saved)) } diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index e2008192d32..38a1e95b999 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -76,6 +76,10 @@ pub fn build_router_with_proxy_auth( "/list_child_conversations", post(handlers::conversations::list_child_conversations), ) + .route( + "/list_pending_session_messages", + post(handlers::delegation::list_pending_session_messages), + ) .route( "/get_delegation_settings", post(handlers::delegation::get_delegation_settings), diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index 8763822031e..f7e02c4685c 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -230,6 +230,7 @@ async fn end_to_end_uds_happy_path() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); // Freshly-named directory per test — no clashes across test bins. @@ -352,6 +353,7 @@ async fn end_to_end_uds_batch_status() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let dir = socket_dir(); @@ -445,6 +447,7 @@ async fn end_to_end_uds_invalid_token_rejected() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let dir = socket_dir(); @@ -517,6 +520,7 @@ async fn end_to_end_uds_ask_question_round_trip() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let dir = socket_dir(); @@ -663,6 +667,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let dir = socket_dir(); @@ -814,6 +819,7 @@ async fn end_to_end_uds_assistant_delegation() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let dir = socket_dir(); let socket = dir.path().join("codeg-e2e-assistant.sock"); diff --git a/src-tauri/tests/delegation_e2e_windows.rs b/src-tauri/tests/delegation_e2e_windows.rs index 22a78c5fa84..9f19dee4190 100644 --- a/src-tauri/tests/delegation_e2e_windows.rs +++ b/src-tauri/tests/delegation_e2e_windows.rs @@ -220,6 +220,7 @@ async fn end_to_end_named_pipe_happy_path() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let pipe = unique_pipe("happy"); @@ -328,6 +329,7 @@ async fn end_to_end_named_pipe_back_to_back_requests() { Arc::new(codeg_lib::acp::computer_tools::NoComputerDesktop) as Arc, Arc::new(codeg_lib::acp::result_tool::NoResultTool), + Arc::new(codeg_lib::acp::subsession_messages::NoSubsessionMessages), ); let pipe = unique_pipe("repeat"); diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 4458a603a30..2a92915118f 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -14,6 +14,7 @@ import type { AvailableCommandInfo, } from "@/lib/types" import type { QueuedMessage } from "@/hooks/use-message-queue" +import type { PendingSessionMessage } from "@/lib/api" import { MessageInput, type ComposerInjectContent, @@ -52,6 +53,7 @@ interface ChatInputProps { * tab when tiled across multiple sessions; passed through to MessageInput. */ showActiveFlow?: boolean queue?: QueuedMessage[] + backendQueue?: PendingSessionMessage[] onEnqueue?: (draft: PromptDraft, modeId: string | null) => void onQueueReorder?: (items: QueuedMessage[]) => void onQueueEdit?: (id: string) => void @@ -128,6 +130,7 @@ export const ChatInput = memo(function ChatInput({ isActive, showActiveFlow, queue, + backendQueue, onEnqueue, onQueueReorder, onQueueEdit, @@ -165,6 +168,9 @@ export const ChatInput = memo(function ChatInput({ // and is bounded: `selectors_ready` fires on every establishment path whether // or not the agent has any commands, so this can never hang on a spinner. const commandsLoading = isConnecting || selectorsLoading + const hasLocalQueue = Boolean( + queue?.length && onQueueReorder && onQueueEdit && onQueueDelete + ) // Active/historical conversations dock the composer at the very bottom of the // message list. The attached folder/branch selector row now sits at the @@ -188,21 +194,18 @@ export const ChatInput = memo(function ChatInput({ if (event.pointerType !== "mouse") event.stopPropagation() }} > - {queue && - queue.length > 0 && - onQueueReorder && - onQueueEdit && - onQueueDelete && ( - - )} + {(hasLocalQueue || Boolean(backendQueue?.length)) && ( + {})} + onEdit={onQueueEdit ?? (() => {})} + onDelete={onQueueDelete ?? (() => {})} + editingItemId={editingItemId ?? null} + onSteerItem={onQueueSteer} + steerChannel={steerChannel} + /> + )} void onQueueReorder?: (items: QueuedMessage[]) => void onQueueEdit?: (id: string) => void @@ -215,6 +217,7 @@ export function ConversationShell({ isActive, showActiveFlow, queue, + backendQueue, onEnqueue, onQueueReorder, onQueueEdit, @@ -347,6 +350,7 @@ export function ConversationShell({ isActive={isActive} showActiveFlow={showActiveFlow} queue={queue} + backendQueue={backendQueue} onEnqueue={onEnqueue} onQueueReorder={onQueueReorder} onQueueEdit={onQueueEdit} diff --git a/src/components/chat/message-queue-display.test.tsx b/src/components/chat/message-queue-display.test.tsx index 91a8047290b..56d86c96068 100644 --- a/src/components/chat/message-queue-display.test.tsx +++ b/src/components/chat/message-queue-display.test.tsx @@ -66,6 +66,30 @@ function renderDisplay( afterEach(cleanup) describe("MessageQueueDisplay click-to-insert", () => { + it("shows server-owned reports even without local drafts and removes dispatched reports", () => { + const props = { + queue: [], + backendQueue: [ + { + message_id: "report-1", + message: "Please review the interface", + source_conversation_id: 6, + source_title: "API review", + source_kind: "child" as const, + created_at: "2026-11-11T05:43:41Z", + }, + ], + } + const view = renderDisplay(props) + expect(screen.getByText("Please review the interface")).toBeInTheDocument() + expect(screen.getByText(/API review/)).toBeInTheDocument() + expect(screen.queryByTitle(TQ.editItem)).toBeNull() + expect(screen.queryByTitle(TQ.deleteItem)).toBeNull() + view.unmount() + renderDisplay({ queue: [], backendQueue: [] }) + expect(screen.queryByText("Please review the interface")).toBeNull() + }) + it("offers no insert button without a steering handler", () => { renderDisplay() expect(screen.queryByTitle(TQ.steerItemNow)).toBeNull() diff --git a/src/components/chat/message-queue-display.tsx b/src/components/chat/message-queue-display.tsx index 571f73a1194..89a2e1c5345 100644 --- a/src/components/chat/message-queue-display.tsx +++ b/src/components/chat/message-queue-display.tsx @@ -7,9 +7,11 @@ import { useTranslations } from "next-intl" import { cn } from "@/lib/utils" import { draftRidesBlocks } from "@/lib/prompt-draft" import type { QueuedMessage } from "@/hooks/use-message-queue" +import type { PendingSessionMessage } from "@/lib/api" interface MessageQueueDisplayProps { queue: QueuedMessage[] + backendQueue?: PendingSessionMessage[] onReorder: (items: QueuedMessage[]) => void onEdit: (id: string) => void onDelete: (id: string) => void @@ -144,6 +146,7 @@ function QueueItem({ export function MessageQueueDisplay({ queue, + backendQueue = [], onReorder, onEdit, onDelete, @@ -151,6 +154,7 @@ export function MessageQueueDisplay({ onSteerItem, steerChannel = "pull", }: MessageQueueDisplayProps) { + const t = useTranslations("Folder.chat.messageQueue") // The id whose insert is in flight. A per-row `steering` would let a // concurrent click on another row race the same channel; one shared id // both disables the clicked row and (via `steeringId !== null`) the others @@ -177,10 +181,28 @@ export function MessageQueueDisplay({ [onSteerItem] ) - if (queue.length === 0) return null + if (queue.length === 0 && backendQueue.length === 0) return null return (
+ {backendQueue.map((item) => ( +
+ + + {t(item.source_kind === "child" ? "fromChild" : "fromParent", { + name: item.source_title || `#${item.source_conversation_id}`, + })} + + + {item.message} + + {t("waiting")} +
+ ))} ({ listPendingSessionMessages: vi.fn() })) +const list = vi.mocked(listPendingSessionMessages) +const report: PendingSessionMessage = { + message_id: "r1", + message: "Review needed", + source_conversation_id: 6, + source_title: "API review", + source_kind: "child", + created_at: "2026-11-11T05:43:41Z", +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.resetAllMocks() +}) + +it("loads persisted messages on reopen and removes them after backend dispatch", async () => { + vi.useFakeTimers() + list.mockResolvedValueOnce([report]).mockResolvedValue([]) + const { result, unmount } = renderHook(() => + usePendingSessionMessages(5, true) + ) + await act(async () => {}) + expect(result.current).toEqual([report]) + await act(() => vi.advanceTimersByTimeAsync(1000)) + expect(result.current).toEqual([]) + unmount() + list.mockResolvedValue([report]) + const reopened = renderHook(() => usePendingSessionMessages(5, true)) + await act(async () => {}) + expect(reopened.result.current).toEqual([report]) +}) + +it("ignores an old conversation's delayed response and stops polling when inactive", async () => { + vi.useFakeTimers() + let resolveOld!: (items: PendingSessionMessage[]) => void + list + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve + }) + ) + .mockResolvedValue([]) + const { result, rerender } = renderHook( + ({ id, active }) => usePendingSessionMessages(id, active), + { initialProps: { id: 5, active: true } } + ) + rerender({ id: 7, active: true }) + await act(async () => { + resolveOld([report]) + }) + expect(result.current).toEqual([]) + rerender({ id: 7, active: false }) + const calls = list.mock.calls.length + await act(() => vi.advanceTimersByTimeAsync(3000)) + expect(list).toHaveBeenCalledTimes(calls) +}) diff --git a/src/hooks/use-pending-session-messages.ts b/src/hooks/use-pending-session-messages.ts new file mode 100644 index 00000000000..a447ba24608 --- /dev/null +++ b/src/hooks/use-pending-session-messages.ts @@ -0,0 +1,48 @@ +"use client" + +import { useEffect, useState } from "react" +import { + listPendingSessionMessages, + type PendingSessionMessage, +} from "@/lib/api" + +/** Observe the persisted queue; never dispatch these messages from the UI. */ +export function usePendingSessionMessages( + conversationId: number | null, + active: boolean +): PendingSessionMessage[] { + const [snapshot, setSnapshot] = useState<{ + conversationId: number + items: PendingSessionMessage[] + } | null>(null) + + useEffect(() => { + if (conversationId == null || !active) return + let disposed = false + let timer: ReturnType + const refresh = async () => { + try { + const items = await listPendingSessionMessages(conversationId) + if (!disposed) { + setSnapshot((previous) => + previous?.conversationId === conversationId && + JSON.stringify(previous.items) === JSON.stringify(items) + ? previous + : { conversationId, items } + ) + } + } catch (error) { + if (!disposed) console.error("Failed to read session queue", error) + } finally { + if (!disposed) timer = setTimeout(refresh, 1000) + } + } + void refresh() + return () => { + disposed = true + clearTimeout(timer) + } + }, [conversationId, active]) + + return snapshot?.conversationId === conversationId ? snapshot.items : [] +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 092624ad9cc..37fb5dea021 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "المساعدون" }, "messageQueue": { + "fromChild": "من الجلسة الفرعية: {name}", + "fromParent": "من الجلسة الرئيسية: {name}", + "waiting": "في الانتظار", "addToQueue": "إضافة للقائمة", "saveEdit": "حفظ", "cancelEdit": "إلغاء التعديل", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 717bf1ec451..4df3285070c 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "Assistenten" }, "messageQueue": { + "fromChild": "Von Untersitzung: {name}", + "fromParent": "Von übergeordneter Sitzung: {name}", + "waiting": "In Warteschlange", "addToQueue": "Zur Warteschlange", "saveEdit": "Speichern", "cancelEdit": "Bearbeitung abbrechen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 95c2505440a..d656dfb8c1b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "Assistants" }, "messageQueue": { + "fromChild": "From child: {name}", + "fromParent": "From parent: {name}", + "waiting": "Queued", "addToQueue": "Queue message", "saveEdit": "Save", "cancelEdit": "Cancel edit", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 8410c0165b1..2e974ad8bd2 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "Asistentes" }, "messageQueue": { + "fromChild": "De sesión hija: {name}", + "fromParent": "De sesión padre: {name}", + "waiting": "En cola", "addToQueue": "Agregar a la cola", "saveEdit": "Guardar", "cancelEdit": "Cancelar edición", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1e03d8719c5..3e4024f655d 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "Assistants" }, "messageQueue": { + "fromChild": "Session enfant : {name}", + "fromParent": "Session parente : {name}", + "waiting": "En attente", "addToQueue": "Mettre en file", "saveEdit": "Enregistrer", "cancelEdit": "Annuler la modification", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c4695f5207a..f9de7849ea3 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "アシスタント" }, "messageQueue": { + "fromChild": "子セッション:{name}", + "fromParent": "親セッション:{name}", + "waiting": "待機中", "addToQueue": "キューに追加", "saveEdit": "保存", "cancelEdit": "編集をキャンセル", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 412f820533b..e98057e5959 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "어시스턴트" }, "messageQueue": { + "fromChild": "하위 세션: {name}", + "fromParent": "상위 세션: {name}", + "waiting": "대기 중", "addToQueue": "대기열에 추가", "saveEdit": "저장", "cancelEdit": "편집 취소", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 503cba05049..9ed3ab0690c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "Assistentes" }, "messageQueue": { + "fromChild": "Da sessão filha: {name}", + "fromParent": "Da sessão pai: {name}", + "waiting": "Na fila", "addToQueue": "Adicionar à fila", "saveEdit": "Salvar", "cancelEdit": "Cancelar edição", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b3bfccb921b..dbc54d3018e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "助手" }, "messageQueue": { + "fromChild": "来自子会话:{name}", + "fromParent": "来自父会话:{name}", + "waiting": "排队中", "addToQueue": "加入队列", "saveEdit": "保存", "cancelEdit": "取消编辑", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5bc94c985c0..57996d3ddc3 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3518,6 +3518,9 @@ "mentionGroupAssistant": "助手" }, "messageQueue": { + "fromChild": "來自子會話:{name}", + "fromParent": "來自父會話:{name}", + "waiting": "排隊中", "addToQueue": "加入佇列", "saveEdit": "儲存", "cancelEdit": "取消編輯", diff --git a/src/lib/api.ts b/src/lib/api.ts index c7250ff793d..e448ac4c66e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -5631,6 +5631,23 @@ export async function getDelegationSettings(): Promise { return getTransport().call("get_delegation_settings") } +export interface PendingSessionMessage { + message_id: string + message: string + source_conversation_id: number + source_title: string | null + source_kind: "parent" | "child" + created_at: string +} + +export async function listPendingSessionMessages( + conversationId: number +): Promise { + return getTransport().call("list_pending_session_messages", { + conversationId, + }) +} + export async function setDelegationSettings( settings: DelegationSettings ): Promise { diff --git a/src/lib/codeg-mcp-tool.test.ts b/src/lib/codeg-mcp-tool.test.ts index f5b7be4cfe0..d0c063dafdd 100644 --- a/src/lib/codeg-mcp-tool.test.ts +++ b/src/lib/codeg-mcp-tool.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest" -import { parseCodegMcpToolCall } from "./codeg-mcp-tool" +import { + isCodegMcpSubsessionTool, + parseCodegMcpToolCall, +} from "./codeg-mcp-tool" const progress = (output: string | null, extra = {}) => parseCodegMcpToolCall({ @@ -142,3 +145,12 @@ describe("parseCodegMcpToolCall: work-task report acks", () => { expect(model.resultText).toBe('{"recorded":true}') }) }) + +describe("child message tool names", () => { + it("recognizes both directions without treating delegation tools as message tools", () => { + expect(isCodegMcpSubsessionTool("send_to_parent")).toBe(true) + expect(isCodegMcpSubsessionTool("send_to_subsession")).toBe(true) + expect(isCodegMcpSubsessionTool(" MANAGE_SUBSESSION_MESSAGES ")).toBe(true) + expect(isCodegMcpSubsessionTool("delegate_to_agent")).toBe(false) + }) +}) diff --git a/src/lib/codeg-mcp-tool.ts b/src/lib/codeg-mcp-tool.ts index 9061ec40670..e58fe5b90d0 100644 --- a/src/lib/codeg-mcp-tool.ts +++ b/src/lib/codeg-mcp-tool.ts @@ -50,6 +50,23 @@ export const CODEG_MCP_WORKBENCH_TOOLS = [ "resume_delegation", ] as const +/** Child-message tools keep the generic tool renderer and their full JSON result. */ +export const CODEG_MCP_SUBSESSION_TOOLS = [ + "send_to_parent", + "send_to_subsession", + "manage_subsession_messages", +] as const + +export type CodegMcpSubsessionTool = (typeof CODEG_MCP_SUBSESSION_TOOLS)[number] + +export function isCodegMcpSubsessionTool( + name: string +): name is CodegMcpSubsessionTool { + return CODEG_MCP_SUBSESSION_TOOLS.some( + (tool) => tool === name.toLowerCase().trim() + ) +} + export type CodegMcpWorkbenchTool = (typeof CODEG_MCP_WORKBENCH_TOOLS)[number] const TOOL_SET: ReadonlySet = new Set(CODEG_MCP_WORKBENCH_TOOLS)