From f2a024b5264a42b72a6ca29771c1e72521752c65 Mon Sep 17 00:00:00 2001 From: 7 <7@localhost> Date: Sun, 26 Jul 2026 20:31:29 +0800 Subject: [PATCH 1/3] feat(delegation): continuable sub-agent sessions with user entry point Replace the v1 one-shot delegation lifecycle (child torn down at first terminal state, broker.rs "v1 one-shot" comment) with continuable sessions shared between the parent LLM and the user. Backend: - Session/Turn/Operation three-layer split inside the broker; the completed cache now only caches result text (evicting it can no longer change domain behavior) - continue_with_session / close_session MCP tools (wire-compatible with upstream PR #375; close is release semantics, session_released with session_closed accepted as a historical alias) - Keep-alive with kept_alive_cap (global + per-parent FIFO) on top of idle-sweep reclamation; resume via external_id with triple binding checks (agent_type/folder/uniqueness) and pre-side-effect resume_unavailable instead of silent session/new downgrade - Startup rebuild protocol (rebuilding retryable state, per-row failure isolation, ledger not reused across restarts) - Continuation registered cancellable before the follow-up prompt is sent (closes the register-window cancel race), close-vs-continue serialization hardened at the settle_session choke point - update_external_id resume-safe/skip-delegate guards at all four call sites so a degraded session/new can never overwrite the resume credential of a delegate row - session-scoped delegation_session_update event (task_id, turn_id, turn_version, origin); no duplicate completion against an already-terminal parent_tool_use_id User entry point (not present in upstream PR #375): - continue/close/availability HTTP + Tauri commands keyed by conversation id, continuation_id idempotency ledger across all three entries (MCP arm included, listener never mints ids) - Sub-agent dialog composer gated by five-state continuation availability; errors surfaced with stable codes - Sidebar discoverability: Sub badge, sub-session filter toggle (default off), delegate rows excluded from the root list by DB markers only (immune to worktree indentation) - Multi-turn timeline: prefix cut anchored to the in-flight user turn so earlier completed turns stay visible while a new turn streams Verification: rust --lib 1805 passed / clippy -D warnings clean / frontend vitest 2835 passed / tsc clean. Independent read-only review (heterogeneous model) returned 0 critical; both important findings (close-vs-continue compound-failure leak, unguarded external_id call sites) fixed with red-green tests in this change. Spec: docs/specs/delegation-continue-session/{requirements,design,tasks}.md (3-round codex cross-review converged; introspection archived) --- src-tauri/src/acp/connection.rs | 578 ++- src-tauri/src/acp/delegation/broker.rs | 4269 +++++++++++++++-- src-tauri/src/acp/delegation/companion.rs | 271 +- src-tauri/src/acp/delegation/event_emitter.rs | 98 + src-tauri/src/acp/delegation/listener.rs | 230 +- src-tauri/src/acp/delegation/mod.rs | 15 +- src-tauri/src/acp/delegation/spawner.rs | 281 ++ src-tauri/src/acp/delegation/tool_schema.json | 46 +- src-tauri/src/acp/delegation/transport.rs | 128 + src-tauri/src/acp/delegation/types.rs | 156 + src-tauri/src/acp/lifecycle.rs | 12 +- src-tauri/src/acp/manager.rs | 243 +- src-tauri/src/acp/session_state.rs | 40 +- src-tauri/src/acp/types.rs | 25 +- src-tauri/src/bin/codeg_server.rs | 17 +- .../chat_channel/session_event_subscriber.rs | 92 +- src-tauri/src/commands/conversations.rs | 440 +- src-tauri/src/commands/delegation.rs | 247 +- .../src/db/service/conversation_service.rs | 404 +- src-tauri/src/lib.rs | 27 +- src-tauri/src/web/handlers/conversations.rs | 7 + src-tauri/src/web/handlers/delegation.rs | 384 ++ src-tauri/src/web/router.rs | 22 +- .../sidebar-conversation-card.tsx | 54 +- .../sidebar-conversation-list.tsx | 77 +- src/components/layout/sidebar.tsx | 25 +- .../message/content-parts-renderer.tsx | 24 + .../message/delegation-status-card.test.tsx | 38 + .../message/delegation-status-card.tsx | 31 +- .../message/delegation-status-row.tsx | 27 +- .../message/sub-agent-session-dialog.test.tsx | 383 +- .../message/sub-agent-session-dialog.tsx | 293 +- src/i18n/messages/ar.json | 20 +- src/i18n/messages/de.json | 20 +- src/i18n/messages/en.json | 20 +- src/i18n/messages/es.json | 20 +- src/i18n/messages/fr.json | 20 +- src/i18n/messages/ja.json | 20 +- src/i18n/messages/ko.json | 20 +- src/i18n/messages/pt.json | 20 +- src/i18n/messages/zh-CN.json | 20 +- src/i18n/messages/zh-TW.json | 20 +- src/lib/adapters/tool-kind-classifier.test.ts | 18 + src/lib/adapters/tool-kind-classifier.ts | 6 + src/lib/api.ts | 74 + src/lib/conversation-sidebar.test.ts | 133 + src/lib/conversation-sidebar.ts | 45 + src/lib/delegation-status.ts | 10 +- src/lib/sidebar-view-mode-storage.ts | 26 + src/lib/tool-call-normalization.test.ts | 56 + src/lib/tool-call-normalization.ts | 13 + src/lib/types.ts | 23 + src/stores/app-workspace-store.ts | 14 +- src/stores/conversation-runtime-store.ts | 51 +- .../delegation-multi-turn-timeline.test.ts | 209 + 55 files changed, 8927 insertions(+), 935 deletions(-) create mode 100644 src/lib/conversation-sidebar.test.ts create mode 100644 src/lib/conversation-sidebar.ts create mode 100644 src/stores/delegation-multi-turn-timeline.test.ts diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 104d3a4c4..e5a002714 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -4,17 +4,17 @@ use std::sync::Arc; use sacp::schema::{ BlobResourceContents, CancelNotification, ClientCapabilities, ContentBlock, ContentChunk, - CreateTerminalRequest, CreateTerminalResponse, - ElicitationCapabilities, ElicitationFormCapabilities, EmbeddedResource, EmbeddedResourceResource, + CreateTerminalRequest, CreateTerminalResponse, ElicitationCapabilities, + ElicitationFormCapabilities, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, ImageContent, InitializeRequest, KillTerminalRequest, KillTerminalResponse, LoadSessionRequest, NewSessionRequest, NewSessionResponse, PermissionOptionKind, Plan, PlanEntryPriority, PlanEntryStatus, PromptRequest, ProtocolVersion, ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest, ReleaseTerminalResponse, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, ResourceLink, ResumeSessionRequest, ResumeSessionResponse, SelectedPermissionOutcome, SessionConfigKind, - SessionConfigOption, SessionConfigOptionCategory, - SessionConfigSelectGroup, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, - SessionModeState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectGroup, + SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, SessionModeState, + SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, StopReason, TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, TextContent, TextResourceContents, ToolCallContent, WaitForTerminalExitRequest, WaitForTerminalExitResponse, WriteTextFileRequest, @@ -91,14 +91,15 @@ fn merge_agent_env( /// 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; } for key in ["CURSOR_API_KEY", "CURSOR_API_BASE_URL"] { - let already_set = merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); + let already_set = merged.iter().any(|(k, v)| k == key && !v.trim().is_empty()); if !already_set { merged.retain(|(k, _)| k != key); merged.push((key.to_string(), String::new())); @@ -115,14 +116,15 @@ fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTr /// sacp-tokio) 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; } let key = "XAI_API_KEY"; - let already_set = merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); + let already_set = merged.iter().any(|(k, v)| k == key && !v.trim().is_empty()); if !already_set { merged.retain(|(k, _)| k != key); merged.push((key.to_string(), String::new())); @@ -539,10 +541,7 @@ async fn build_agent( let binary_path = match cached { Some((path, cached_version)) => { if cached_version == registry_version { - tracing::info!( - "[ACP][{}] Using cached binary {cached_version}", - meta.name - ); + tracing::info!("[ACP][{}] Using cached binary {cached_version}", meta.name); } else { tracing::info!( "[ACP][{}] Using cached binary {cached_version} (registry recommends {registry_version})", @@ -720,7 +719,8 @@ async fn build_agent( // than provisioned through uvx. tracing::warn!( "[ACP][{}] uvx unavailable; falling back to system command {:?}", - meta.name, sys_path + meta.name, + sys_path ); // `system_cmd` is a complete launch recipe for the PATH binary; // the uvx entry-script `args` don't necessarily apply to it @@ -887,8 +887,7 @@ pub async fn spawn_agent_connection( // Derived from the same `runtime_env` we hand the agent (minus per-launch // volatile keys) plus the agent's native config file content, so a later // settings save can be compared against it to detect a stale running session. - let config_fingerprint = - crate::commands::acp::fingerprint_config(agent_type, &runtime_env); + let config_fingerprint = crate::commands::acp::fingerprint_config(agent_type, &runtime_env); // Insert the entry BEFORE spawning the background task so that a // fast-failing `run_connection` can never remove it before it was @@ -933,88 +932,88 @@ pub async fn spawn_agent_connection( .spawn(move || { let _cleanup = cleanup_guard; connection_rt.block_on(async move { - let delegation_for_cleanup = delegation_injection.clone(); - let result = run_connection( - agent, - conn_id.clone(), - agent_type, - working_dir, - session_id, - cmd_rx, - emitter_clone.clone(), - Arc::clone(&state_clone), - terminal_base_env, - preferred_mode_id, - preferred_config_values, - delegation_injection, - fs_policy, - ) - .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) + let delegation_for_cleanup = delegation_injection.clone(); + let result = run_connection( + agent, + conn_id.clone(), + agent_type, + working_dir, + session_id, + cmd_rx, + emitter_clone.clone(), + Arc::clone(&state_clone), + terminal_base_env, + preferred_mode_id, + preferred_config_values, + delegation_injection, + fs_policy, + ) .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, - // 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; - } + // 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; + } - emit_with_state( - &state_clone, - &emitter_clone, - AcpEvent::StatusChanged { - status: ConnectionStatus::Disconnected, - }, - ) - .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, + // 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; // 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. @@ -2027,18 +2026,16 @@ async fn send_resume_session( cx: &ConnectionTo, req: ResumeSessionRequest, ) -> Result<(ResumeSessionResponse, Option), sacp::Error> { - let untyped_req = UntypedMessage::new("session/resume", req).map_err(|e| { - sacp::util::internal_error(format!("Failed to build resume request: {e}")) - })?; + let untyped_req = UntypedMessage::new("session/resume", req) + .map_err(|e| sacp::util::internal_error(format!("Failed to build resume request: {e}")))?; let raw_response = cx.send_request_to(Agent, untyped_req).block_task().await?; // Capture the raw top-level `models` (per-model reasoning-effort data) BEFORE // deserializing into the typed response, which drops it (Grok only — the // field survives serde as an ignored unknown for other agents). let models = raw_response.get("models").cloned(); - let resp = serde_json::from_value(raw_response).map_err(|e| { - sacp::util::internal_error(format!("Failed to parse resume response: {e}")) - })?; + let resp = serde_json::from_value(raw_response) + .map_err(|e| sacp::util::internal_error(format!("Failed to parse resume response: {e}")))?; Ok((resp, models)) } @@ -2816,6 +2813,16 @@ async fn run_connection( "[ACP] Agent capabilities: load_session={}, fork={}, resume={}", init_resp.agent_capabilities.load_session, supports_fork, supports_resume ); + // Persist the self-reported continuation capabilities onto the + // session state (design D3.1) — the broker / availability query + // reads them via `manager.get_state` to classify a delegated + // child as Resumable vs LiveOnly. Previously log-only. + { + let mut s = state.write().await; + s.agent_supports_load_session = init_resp.agent_capabilities.load_session; + s.agent_supports_resume = supports_resume; + s.agent_supports_fork = supports_fork; + } // Whether this agent accepts MCP server entries over the ACP wire // (`session/new`'s `mcpServers`). Almost all do; OpenClaw rejects @@ -3570,8 +3577,7 @@ async fn handle_grok_exit_plan_mode( .map(|o| o.keys().map(String::as_str).collect::>()) ); let Some(access) = access else { - let _ = - responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + let _ = responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); return; }; let (plan_markdown, tool_call_id) = @@ -3593,8 +3599,7 @@ async fn handle_grok_exit_plan_mode( .await else { // Connection gone, or an approval is already pending on this connection. - let _ = - responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); + let _ = responder.respond(crate::acp::plan_approval::grok_exit_plan_disconnect_response()); return; }; // The user answers out-of-band (the HTTP `answer_plan_approval` endpoint @@ -3764,11 +3769,11 @@ async fn handle_elicitation_request( let reaper_conn = connection_id.to_string(); let reaper_qid = registered.question_id.clone(); tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis( - ms.saturating_add(2_000), - )) - .await; - reaper_access.cancel_question(&reaper_conn, &reaper_qid).await; + tokio::time::sleep(std::time::Duration::from_millis(ms.saturating_add(2_000))) + .await; + reaper_access + .cancel_question(&reaper_conn, &reaper_qid) + .await; }); } // The user answers out-of-band (the `answer_question` endpoint @@ -4048,7 +4053,9 @@ async fn apply_preferred_session_options( .unwrap_or(false); if needs_apply { if let Err(e) = set_session_mode(session, state, emitter, pref_mode.to_string()).await { - tracing::error!("[ACP] failed to apply preferred mode '{pref_mode}' on connect: {e}"); + tracing::error!( + "[ACP] failed to apply preferred mode '{pref_mode}' on connect: {e}" + ); } } } @@ -4583,7 +4590,8 @@ async fn poll_tracked_terminal_tool_calls( Err(err) => { tracing::error!( "[ACP] Failed to poll terminal output for tool call {}: {:?}", - tool_call_id, err + tool_call_id, + err ); continue; } @@ -4766,7 +4774,8 @@ async fn handle_fork_or_exit( tracing::info!( "[ACP] Fork transition: attaching to forked session {} (original: {})", - new_sid, fork_info.original_session_id + new_sid, + fork_info.original_session_id ); // Reply protocol-level result to manager.fork_session, which will combine @@ -4900,8 +4909,7 @@ fn classify_session_load_failure( // "The Claude Agent process exited unexpectedly…" // - "session has ended" → SESSION_ENDED_MESSAGE // - "Session not found" → a plain Error rethrown as an Internal error - const UNRECOVERABLE: &[&str] = - &["process exited", "session has ended", "Session not found"]; + const UNRECOVERABLE: &[&str] = &["process exited", "session has ended", "Session not found"]; if UNRECOVERABLE.iter().any(|s| message.contains(s)) { return Some("session_unavailable"); } @@ -5725,7 +5733,8 @@ async fn run_conversation_loop<'a>( let sid = session.session_id().clone(); tracing::info!( "[ACP] Sending session/fork for session_id={} cwd={}", - sid.0, cwd + sid.0, + cwd ); let result = crate::acp::fork::fork_session(&cx, &sid, cwd).await; match result { @@ -5764,10 +5773,7 @@ async fn run_conversation_loop<'a>( /// (doubling the event) and the hunkless full-file `--- /+++` blob stays in the /// tool `output`, where `extractEditLineChangeStats` mis-counts it as full-file /// +/- totals in the card header even though the body shows the compact diff. -fn serialize_tool_call_content( - content: &[ToolCallContent], - include_diffs: bool, -) -> Option { +fn serialize_tool_call_content(content: &[ToolCallContent], include_diffs: bool) -> Option { let mut parts: Vec = Vec::new(); for item in content { match item { @@ -5895,10 +5901,7 @@ fn build_new_file_diff(path: &str, new_text: &str) -> String { // it keeps the trailing empty segment from a final newline, so the `+N` // count and the trailing `+` addition line match exactly. let lines: Vec<&str> = new_text.split('\n').collect(); - let mut out = format!( - "--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{} @@", - lines.len() - ); + let mut out = format!("--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{} @@", lines.len()); for line in lines { out.push('\n'); out.push('+'); @@ -6146,7 +6149,10 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static let is_report_item = |t: &serde_json::Value| { t.get("task_id").and_then(|x| x.as_str()).is_some() && t.get("status").and_then(|x| x.as_str()).is_some_and(|s| { - matches!(s, "running" | "completed" | "failed" | "canceled" | "unknown") + matches!( + s, + "running" | "completed" | "failed" | "canceled" | "unknown" + ) }) }; if !tasks.is_empty() && tasks.iter().all(is_report_item) { @@ -6198,7 +6204,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; } @@ -6266,7 +6275,11 @@ fn codebuddy_meta_marks_subagent( if meta.get("codebuddy.ai/toolName").and_then(|v| v.as_str()) == Some("Agent") { return true; } - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + if meta + .get("codebuddy.ai/isSubagent") + .and_then(|v| v.as_bool()) + == Some(true) + { return true; } meta.get("codebuddy.ai/subagentType") @@ -6385,7 +6398,11 @@ fn codebuddy_chunk_marks_subagent( let Some(meta) = meta else { return false; }; - if meta.get("codebuddy.ai/isSubagent").and_then(|v| v.as_bool()) == Some(true) { + if meta + .get("codebuddy.ai/isSubagent") + .and_then(|v| v.as_bool()) + == Some(true) + { return true; } meta.get("codebuddy.ai/parentToolCallId") @@ -6645,8 +6662,7 @@ fn map_claude_sdk_ext_notification(notification: &UntypedMessage) -> Option { json_value_to_text(&Some(inner.clone())).filter(|t| !t.trim().is_empty()) } - None => { - json_value_to_text(&tcu.fields.raw_input).filter(|t| !t.trim().is_empty()) - } + None => json_value_to_text(&tcu.fields.raw_input).filter(|t| !t.trim().is_empty()), }; let synthesized_edit = if own_raw_input.is_none() { tcu.fields @@ -7084,7 +7098,8 @@ async fn emit_conversation_update( .filter(|l| !l.is_empty()) .and_then(|l| serde_json::to_value(l).ok()); let meta_marks_subagent = codebuddy_meta_marks_subagent(agent_type, tcu.meta.as_ref()); - let meta_marks_background = codebuddy_meta_marks_background(agent_type, tcu.meta.as_ref()); + let meta_marks_background = + codebuddy_meta_marks_background(agent_type, tcu.meta.as_ref()); let meta = tcu.meta.clone().map(serde_json::Value::Object); let status = tcu.fields.status.map(|s| format!("{:?}", s).to_lowercase()); raw_output_cache.remove_if_final(&tool_call_id, status.as_deref()); @@ -7308,7 +7323,9 @@ mod tests { assert!(!GrokAskUserQuestionRequest::matches_method( "x.ai/ask_user_question" )); - assert!(!GrokAskUserQuestionRequest::matches_method("session/prompt")); + assert!(!GrokAskUserQuestionRequest::matches_method( + "session/prompt" + )); // The exact params grok sends (captured from a real 0.2.101 run): the // transparent newtype must deserialize them and the raw object must parse @@ -7358,7 +7375,10 @@ mod tests { })); assert!(is_codex_subagent_activity(AgentType::Codex, Some(&sub))); // Only Codex is gated — the same meta never suppresses another agent. - assert!(!is_codex_subagent_activity(AgentType::ClaudeCode, Some(&sub))); + assert!(!is_codex_subagent_activity( + AgentType::ClaudeCode, + Some(&sub) + )); // Absent meta and sibling codex meta keys (goal / collaboration) are not // subagent activity and must render normally. assert!(!is_codex_subagent_activity(AgentType::Codex, None)); @@ -7383,7 +7403,10 @@ mod tests { "presentation": "state" } })); - assert!(is_config_option_state_command(AgentType::Codex, Some(&plan))); + assert!(is_config_option_state_command( + AgentType::Codex, + Some(&plan) + )); // Gated on Codex — the same meta never suppresses another agent's command. assert!(!is_config_option_state_command( AgentType::ClaudeCode, @@ -7394,7 +7417,10 @@ mod tests { let goal = meta_map(serde_json::json!({ "commandAction": { "kind": "prefixPrompt", "presentation": "state" } })); - assert!(!is_config_option_state_command(AgentType::Codex, Some(&goal))); + assert!(!is_config_option_state_command( + AgentType::Codex, + Some(&goal) + )); // Ordinary commands (no `commandAction`) and absent meta are kept. assert!(!is_config_option_state_command(AgentType::Codex, None)); let plain = meta_map(serde_json::json!({ "somethingElse": true })); @@ -7566,7 +7592,9 @@ mod tests { // No configured creds → both injected empty (⇒ spawn strips inherited). let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; apply_cursor_env_policy(&mut merged, &sub); - assert!(merged.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); + assert!(merged + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); assert!(merged .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); @@ -7574,7 +7602,9 @@ mod tests { // A configured key is preserved; only the absent base URL is cleared. let mut with_key = vec![("CURSOR_API_KEY".to_string(), "sk-x".to_string())]; apply_cursor_env_policy(&mut with_key, &sub); - assert!(with_key.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v == "sk-x")); + assert!(with_key + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v == "sk-x")); assert!(with_key .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); @@ -7600,7 +7630,9 @@ mod tests { // inherited XAI_API_KEY so `grok login` is used). let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; apply_grok_env_policy(&mut merged, &sub); - assert!(merged.iter().any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); + assert!(merged + .iter() + .any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); // A configured key is preserved even in subscription mode (explicit wins). let mut with_key = vec![("XAI_API_KEY".to_string(), "xai-abc".to_string())]; @@ -7797,8 +7829,10 @@ mod tests { true, ); // Exactly one PATH-ish key, the original casing preserved, value prepended. - let path_keys: Vec<&String> = - env.keys().filter(|k| k.eq_ignore_ascii_case("PATH")).collect(); + let path_keys: Vec<&String> = env + .keys() + .filter(|k| k.eq_ignore_ascii_case("PATH")) + .collect(); assert_eq!(path_keys.len(), 1, "{env:?}"); assert_eq!( env.get("Path").unwrap(), @@ -7809,9 +7843,17 @@ 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"); + assert_eq!( + env.get("Path").unwrap(), + r"C:\OfficeCLI;C:\Windows;C:\Windows\System32" + ); } #[test] @@ -7824,9 +7866,15 @@ mod tests { env.insert("PATH".to_string(), r"C:\a".to_string()); env.insert("Path".to_string(), r"C:\b".to_string()); prepend_dir_to_path_env(&mut env, r"C:\OfficeCLI", "ignored-fallback", true); - let path_keys: Vec<&String> = - env.keys().filter(|k| k.eq_ignore_ascii_case("PATH")).collect(); - assert_eq!(path_keys.len(), 1, "exactly one PATH-ish key must remain: {env:?}"); + let path_keys: Vec<&String> = env + .keys() + .filter(|k| k.eq_ignore_ascii_case("PATH")) + .collect(); + assert_eq!( + path_keys.len(), + 1, + "exactly one PATH-ish key must remain: {env:?}" + ); assert_eq!(env.get("Path").unwrap(), r"C:\OfficeCLI;C:\b"); } @@ -7938,9 +7986,18 @@ mod tests { assert_eq!(tool_call_id, "019f9475-c67f-7390-9ee5-a09d29986a6c-4"); assert_eq!(status, "completed"); let meta = meta.expect("compaction card needs meta"); - assert_eq!(meta.get("contextCompaction").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(meta.get("tokensBefore").and_then(|v| v.as_u64()), Some(45389)); - assert_eq!(meta.get("tokensAfter").and_then(|v| v.as_u64()), Some(16486)); + assert_eq!( + meta.get("contextCompaction").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + meta.get("tokensBefore").and_then(|v| v.as_u64()), + Some(45389) + ); + assert_eq!( + meta.get("tokensAfter").and_then(|v| v.as_u64()), + Some(16486) + ); } other => panic!("expected ToolCall, got {other:?}"), } @@ -7975,8 +8032,13 @@ mod tests { ) .unwrap(); match map_grok_ext_notification(&raw, AgentType::Grok) { - Some(AcpEvent::Error { message, terminal, .. }) => { - assert!(message.contains("503"), "error should carry the reason; got: {message}"); + Some(AcpEvent::Error { + message, terminal, .. + }) => { + assert!( + message.contains("503"), + "error should carry the reason; got: {message}" + ); assert!(!terminal, "compaction failure must not kill the connection"); } other => panic!("expected non-terminal Error, got {other:?}"), @@ -8030,11 +8092,20 @@ mod tests { ) }; // Both compaction outcomes are visible turn output. - assert!(grok_ext_notification_is_turn_output(¬if("auto_compact_completed"), AgentType::Grok)); - assert!(grok_ext_notification_is_turn_output(¬if("auto_compact_failed"), AgentType::Grok)); + assert!(grok_ext_notification_is_turn_output( + ¬if("auto_compact_completed"), + AgentType::Grok + )); + assert!(grok_ext_notification_is_turn_output( + ¬if("auto_compact_failed"), + AgentType::Grok + )); // turn_completed is deliberately left to the prompt-response path — it is // NOT counted here (otherwise a genuinely empty turn would be masked). - assert!(!grok_ext_notification_is_turn_output(¬if("turn_completed"), AgentType::Grok)); + assert!(!grok_ext_notification_is_turn_output( + ¬if("turn_completed"), + AgentType::Grok + )); // Never fires for a non-grok agent. assert!(!grok_ext_notification_is_turn_output( ¬if("auto_compact_completed"), @@ -8241,10 +8312,12 @@ mod tests { // A different data.code, or no data at all, must NOT be swallowed — // those fall through to the generic error path. - let other = sacp::Error::new(-32603, "boom") - .data(serde_json::json!({ "code": "SOMETHING_ELSE" })); + let other = + sacp::Error::new(-32603, "boom").data(serde_json::json!({ "code": "SOMETHING_ELSE" })); assert!(!is_grok_incompatible_agent_switch(&other)); - assert!(!is_grok_incompatible_agent_switch(&sacp::Error::internal_error())); + assert!(!is_grok_incompatible_agent_switch( + &sacp::Error::internal_error() + )); } #[test] @@ -8267,8 +8340,8 @@ mod tests { // Empty specs → the effort selector comes from the flat `x.ai/sessionConfig` // "mode" list (the no-`models` fallback path). - let opts = - synthesize_grok_config_options(Some(&meta), &HashMap::new()).expect("should synthesize"); + let opts = synthesize_grok_config_options(Some(&meta), &HashMap::new()) + .expect("should synthesize"); assert_eq!(opts.len(), 2, "model + effort selectors"); let model = &opts[0]; @@ -8278,15 +8351,24 @@ mod tests { // Both models appear (agent-type filtering is deliberately NOT applied — // cross-type switches are handled gracefully at set time instead). assert_eq!(model_sel.options.len(), 2); - assert_eq!(model_sel.current_value, "grok-4.5", "the `selected` model is current"); - assert!(model_sel.options.iter().any(|o| o.value == "grok-composer-2.5-fast")); + assert_eq!( + model_sel.current_value, "grok-4.5", + "the `selected` model is current" + ); + assert!(model_sel + .options + .iter() + .any(|o| o.value == "grok-composer-2.5-fast")); let effort = &opts[1]; assert_eq!(effort.id, GROK_EFFORT_OPTION_ID); assert_eq!(effort.category.as_deref(), Some("mode")); let SessionConfigKindInfo::Select(effort_sel) = &effort.kind; assert_eq!(effort_sel.options.len(), 2); - assert_eq!(effort_sel.current_value, "high", "the `selected` effort is current"); + assert_eq!( + effort_sel.current_value, "high", + "the `selected` effort is current" + ); assert!(effort_sel.options.iter().any(|o| o.value == "low")); } @@ -8306,8 +8388,8 @@ mod tests { .unwrap(); // Empty specs → the effort selector comes from the flat `x.ai/sessionConfig` // "mode" list (the no-`models` fallback path). - let opts = - synthesize_grok_config_options(Some(&meta), &HashMap::new()).expect("should synthesize"); + let opts = synthesize_grok_config_options(Some(&meta), &HashMap::new()) + .expect("should synthesize"); assert_eq!(opts.len(), 1); assert_eq!(opts[0].id, GROK_MODEL_OPTION_ID); } @@ -8410,10 +8492,9 @@ mod tests { ); assert!(sel.options.iter().all(|o| o.description.is_some())); // Grok's own per-tier text is preserved for the switchable tiers. - assert!(sel - .options - .iter() - .any(|o| o.value == "high" && o.name == "High" && o.description.as_deref() == Some("Highest quality"))); + assert!(sel.options.iter().any(|o| o.value == "high" + && o.name == "High" + && o.description.as_deref() == Some("Highest quality"))); // Unsupported model → no selector; unknown model → None. assert!(build_grok_effort_option("grok-composer-2.5-fast", &specs).is_none()); assert!(build_grok_effort_option("nope", &specs).is_none()); @@ -8442,7 +8523,10 @@ mod tests { .expect("effort selector"); let SessionConfigKindInfo::Select(sel) = &effort.kind; assert_eq!(sel.current_value, "xhigh", "grok-4.5's real default"); - assert!(sel.options.iter().any(|o| o.value == "xhigh" && o.name == "Max")); + assert!(sel + .options + .iter() + .any(|o| o.value == "xhigh" && o.name == "Max")); } #[test] @@ -8568,9 +8652,7 @@ mod tests { let errors: Vec<(Option, bool)> = events .iter() .filter_map(|e| match &e.payload { - AcpEvent::Error { - code, terminal, .. - } => Some((code.clone(), *terminal)), + AcpEvent::Error { code, terminal, .. } => Some((code.clone(), *terminal)), _ => None, }) .collect(); @@ -8781,10 +8863,10 @@ mod tests { // Missing tool_input. assert!(unwrap_grok_use_tool(Some(&serde_json::json!({"tool_name": "x"}))).is_none()); // Empty tool_name. - assert!( - unwrap_grok_use_tool(Some(&serde_json::json!({"tool_name": "", "tool_input": {}}))) - .is_none() - ); + assert!(unwrap_grok_use_tool(Some( + &serde_json::json!({"tool_name": "", "tool_input": {}}) + )) + .is_none()); // Absent / non-object. assert!(unwrap_grok_use_tool(None).is_none()); assert!(unwrap_grok_use_tool(Some(&serde_json::json!("s"))).is_none()); @@ -8848,7 +8930,8 @@ mod tests { Some("codeg-mcp__get_delegation_status") ); // Mixed batch with a running item still resolves. - let mixed = r#"{"tasks":[{"task_id":"a","status":"running"},{"task_id":"b","status":"unknown"}]}"#; + let mixed = + r#"{"tasks":[{"task_id":"a","status":"running"},{"task_id":"b","status":"unknown"}]}"#; assert_eq!( cursor_companion_title_from_content(Some(mixed)), Some("codeg-mcp__get_delegation_status") @@ -8874,9 +8957,7 @@ mod tests { assert_eq!(cursor_companion_title_from_content(None), None); // Ack prefix must match from the start, not mid-string. assert_eq!( - cursor_companion_title_from_content(Some( - "Note: Delegation successful. task_id=x." - )), + cursor_companion_title_from_content(Some("Note: Delegation successful. task_id=x.")), None ); } @@ -9417,10 +9498,7 @@ mod tests { // returns false. Regression guard against any future "optimisation" // that conflates the substring check with the field check. let input = Some(r#"{"description":"use subagent_type=foo"}"#.to_string()); - assert!(!is_subagent_invocation( - AgentType::OpenCode, - &input - )); + assert!(!is_subagent_invocation(AgentType::OpenCode, &input)); } #[test] @@ -9467,7 +9545,8 @@ mod tests { "not json", ] { assert!( - codebuddy_deferred_tool_name(AgentType::CodeBuddy, &Some(raw.to_string())).is_none(), + codebuddy_deferred_tool_name(AgentType::CodeBuddy, &Some(raw.to_string())) + .is_none(), "expected None for raw_input={raw}" ); } @@ -9527,28 +9606,56 @@ mod tests { ); // Initial event carrying the subagent marker → "agent", recorded. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &subagent, "tc1", false, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &subagent, + "tc1", + false, + false, + &mut overrides + ) + .as_deref(), Some("agent") ); // The bug: a later status-only update lost the marker (raw_input None). // The override must be RE-ASSERTED, not downgraded to the event's title. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent"), "a status-only update must not downgrade the Agent card mid-stream" ); // Even an update whose raw_input looks like a different tool keeps it. let bash = Some(r#"{"command":"ls"}"#.to_string()); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &bash, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &bash, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent") ); // A never-classified tool call returns None → caller uses its own title. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc2", true, false, &mut overrides), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc2", + true, + false, + &mut overrides + ), None ); // Deferred MCP tool: inner name recorded, then re-asserted on a bare update. @@ -9557,18 +9664,39 @@ mod tests { .to_string(), ); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &deferred, "tc3", false, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &deferred, + "tc3", + false, + false, + &mut overrides + ) + .as_deref(), Some("mcp__codeg-mcp__delegate_to_agent") ); assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc3", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc3", + true, + false, + &mut overrides + ) + .as_deref(), Some("mcp__codeg-mcp__delegate_to_agent") ); // Non-CodeBuddy agent with no prior classification: never rewritten. assert_eq!( - resolve_rewritten_title(AgentType::OpenCode, &None, "tc9", true, false, &mut overrides), + resolve_rewritten_title( + AgentType::OpenCode, + &None, + "tc9", + true, + false, + &mut overrides + ), None ); } @@ -9612,15 +9740,29 @@ mod tests { // Frame 1: `raw_input` has NO `subagent_type` yet, but `_meta` already // marks it (the early, reliable signal). Title must already be "agent". assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", false, true, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + false, + true, + &mut overrides + ) + .as_deref(), Some("agent") ); // Later sparse frames carry NEITHER signal — the override is re-asserted, // so the pill never flickers back to a generic tool mid-stream. assert_eq!( - resolve_rewritten_title(AgentType::CodeBuddy, &None, "tc1", true, false, &mut overrides) - .as_deref(), + resolve_rewritten_title( + AgentType::CodeBuddy, + &None, + "tc1", + true, + false, + &mut overrides + ) + .as_deref(), Some("agent"), "meta-classified Agent pill must stay 'agent' across signal-less frames" ); @@ -9650,7 +9792,7 @@ mod tests { let mut open: HashSet = HashSet::new(); let mut closed: HashSet = HashSet::new(); let fg = false; // foreground (not background) - // A non-final foreground agent frame opens the window. + // A non-final foreground agent frame opens the window. track_subagent_window( AgentType::CodeBuddy, true, @@ -9751,7 +9893,11 @@ mod tests { // the parent model is suspended — so every chunk in the window is the // sub-agent's, never main-agent output (background sub-agents, which could // interleave main output, are excluded from the window upstream). - assert!(should_suppress_subagent_chunk(AgentType::CodeBuddy, true, None)); + assert!(should_suppress_subagent_chunk( + AgentType::CodeBuddy, + true, + None + )); // Window closed and no chunk meta → emit (e.g. main-agent text before the // sub-agent opens or after it closes). assert!(!should_suppress_subagent_chunk( @@ -9771,7 +9917,11 @@ mod tests { )); } // Other agents never suppress, even inside a (spurious) open window. - assert!(!should_suppress_subagent_chunk(AgentType::OpenCode, true, None)); + assert!(!should_suppress_subagent_chunk( + AgentType::OpenCode, + true, + None + )); } #[test] diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index bae005df1..d7f38ff0a 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -78,6 +78,27 @@ use crate::models::AgentType; /// eviction entirely. See `PendingInner::completed_cap_bytes`. const DEFAULT_COMPLETED_CACHE_CAP_BYTES: usize = 512 * 1024 * 1024; +/// Default cap on kept-alive settled child connections (D3). Each kept-alive +/// child is a resident agent CLI process, so the count — not bytes — is the +/// resource that must be bounded (`completed_cache_cap_bytes` only budgets +/// result TEXT and can be bypassed by many short results). Applied at global +/// scope and per-parent-conversation scope; FIFO-evicts the oldest settled +/// connection. `0` disables the cap. +const DEFAULT_KEPT_ALIVE_CAP: usize = 8; + +// The continuation error codes (`session_still_running` / `session_released` +// / `not_continuable` / `resume_unavailable` / `continuation_conflict` / +// `rebuilding`) are typed `DelegationError` variants in `types.rs` (T4.1), +// projected onto wire codes by `DelegationOutcome::from_err`; the facade +// accepts upstream PR #375's `session_closed` as a historical alias +// (`types::canonical_continuation_code`). Release semantics (R2-B4): the +// wire code is `session_released` — child process freed + no further +// continuation IN THIS PROCESS, not a permanent close. +/// Fixed payload identity for `close_delegation_session` ledger entries — +/// distinguishes a replayed close from a continuation reusing the same +/// `continuation_id` (which is a conflict). +const CLOSE_OP_PAYLOAD: &str = ""; + /// Per-result cap on cached completed text. The full child output always lives /// in the child's own session (viewable via the frontend's child-session /// sheet); this only bounds the broker's in-memory copy of a SINGLE result. @@ -118,6 +139,14 @@ pub struct ChildStatusRecord { /// the DB fallback to the calling parent so one parent can't read another's /// task by guessing a UUID. pub parent_id: Option, + /// Folder the child conversation lives in — required for Branch A row + /// adoption when a continuation sends a follow-up prompt. + pub folder_id: i32, + /// Agent-side session id (`conversation.external_id`): the resume + /// credential handed to `spawn_for_resume` once the process is gone. + pub external_id: Option, + /// Workspace path for the resume spawn (folder path when available). + pub working_dir: Option, } /// DB fallback for `get_delegation_status` / `cancel_delegation` once a task's @@ -127,6 +156,41 @@ pub struct ChildStatusRecord { #[async_trait] pub trait ChildStatusLookup: Send + Sync { async fn find_by_call_id(&self, call_id: &str) -> Option; + + /// How many live (non-deleted) conversation rows reference this + /// `external_id`. A count above 1 means the credential is ambiguous and + /// must not be handed to an agent for resume (Requirement 7.7). Defaults + /// to 0 so in-memory test lookups (and the Noop) stay unambiguous. + async fn external_id_ref_count(&self, _external_id: &str) -> usize { + 0 + } + + /// Every child conversation row eligible for the startup session rebuild + /// (Requirement 7.1): `kind = Delegate`, not deleted, carrying an + /// `external_id` and a `delegation_call_id`. Defaults to empty so lookups + /// that don't model persistence rebuild nothing. + async fn list_rebuildable(&self) -> Vec { + Vec::new() + } +} + +/// One child conversation row as seen by the startup rebuild protocol +/// (Requirement 7.1). `parent_alive` is resolved by the lookup (parent row +/// exists and is not soft-deleted) so the broker's ownership validation stays +/// storage-agnostic. +#[derive(Debug, Clone)] +pub struct RebuildCandidate { + /// The persisted `delegation_call_id` — the stable `task_id`. + pub task_id: String, + pub child_conversation_id: i32, + pub parent_conversation_id: Option, + pub parent_alive: bool, + pub parent_tool_use_id: Option, + pub agent_type: AgentType, + pub status: TaskStatus, + pub folder_id: i32, + pub external_id: Option, + pub working_dir: Option, } /// Default lookup — always "unknown". Used by `DelegationBroker::new` / @@ -142,6 +206,29 @@ impl ChildStatusLookup for NoopChildStatusLookup { } } +/// User-facing continuability verdict for one child session (design §D4 five +/// tiers). Serialized snake_case onto the user-side query wire. The design +/// table's `Closed` label predates the R2-B4 release rename — the wire and +/// UI expose it as `released` (the child process is freed; a restart expires +/// the lease, this is not a permanent close). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContinuationAvailability { + /// A turn is in flight — input disabled, "sub-agent is working". + Running, + /// A kept-alive connection is up: a continuation sends directly. + ContinuableLive, + /// The connection is gone but a resume credential exists: continuable, + /// first round will be slower (resume spawn). + ContinuableResume, + /// Released by `close_session` / parent-conversation deletion. + Released, + /// No resume credential, the agent's capability rules out a resume, or + /// the id is unknown to the broker (one verdict — existence is never + /// disclosed). + NotContinuable, +} + #[derive(Debug, Clone)] pub struct DelegationConfig { pub enabled: bool, @@ -159,6 +246,12 @@ pub struct DelegationConfig { /// converted to bytes in `into_broker_config`. Pushed into the pending-calls /// bucket by `set_config` so `insert_completed` reads it lock-free. pub completed_cache_cap_bytes: usize, + /// Max settled child connections kept alive for `continue_with_session`, + /// enforced at global scope and per-parent-conversation scope (D3 / + /// Requirements 5.4-5.6). `0` disables the cap. FIFO-evicts the oldest + /// settled connection; the evicted task's result text and resume credential + /// survive, so a later continuation goes through the resume path. + pub kept_alive_cap: usize, } impl Default for DelegationConfig { @@ -168,6 +261,7 @@ impl Default for DelegationConfig { depth_limit: 1, agent_defaults: BTreeMap::new(), completed_cache_cap_bytes: DEFAULT_COMPLETED_CACHE_CAP_BYTES, + kept_alive_cap: DEFAULT_KEPT_ALIVE_CAP, } } } @@ -203,6 +297,19 @@ struct RunningTask { /// When the child started running (after `send_prompt` succeeded). Used to /// compute a real `duration_ms` at terminal time. started_at: Instant, + /// The session's dispatch counter for THIS turn (1 = the original + /// delegation). Gates completion-event emission: a continued turn + /// (`> 1`) must NOT re-emit `DelegationCompleted` against a + /// `parent_tool_use_id` whose tool call already went terminal + /// (Requirement 2.8a; the session-scoped update replaces it — T4.3). + turn_version: u64, + /// Broker-internal id of THIS turn's [`TurnRecord`], carried onto the + /// session-scoped update at settle time (Requirement 8.2). `None` for the + /// original turn (version 1 — its terminal event is the tool-scoped + /// `DelegationCompleted`, which needs no turn id). + turn_id: Option, + /// Who dispatched this turn (Requirement 8.2). + origin: TurnOrigin, } /// A terminal delegation result retained so `get_delegation_status` / @@ -224,6 +331,122 @@ struct CompletedTask { duration_ms: u64, } +/// Who dispatched a turn (Requirement 8.2 origin): the MCP companion tool +/// (`ParentAgent`) or the user-side entry point (`User`). Recorded on each +/// [`TurnRecord`]; never on the wire REPORT — but it does ride the +/// session-scoped `AcpEvent::DelegationSessionUpdate` event (T4.3), hence the +/// serde derives (snake_case: `parent_agent` / `user`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnOrigin { + ParentAgent, + User, +} + +/// One dispatched execution of a child session — the middle layer of the D2 +/// split. `turn_id` is broker-internal (event correlation / audit, never on +/// the wire); `turn_version` is the session's monotonic dispatch counter +/// (Requirement 8.1). +struct TurnRecord { + /// Consumed by the session-scoped update events (T4.3: payload + /// `(task_id, turn_id, turn_version, origin)`); until that lands the only + /// readers live in tests, so the non-test build sees no read. + #[allow(dead_code)] + turn_id: String, + #[allow(dead_code)] + turn_version: u64, + #[allow(dead_code)] + origin: TurnOrigin, +} + +/// Bound on the per-session turn history (D2 "bounded queue") — audit depth, +/// not correctness: `turn_version` keeps counting past it. +const TURNS_CAP: usize = 32; + +/// One continuation operation tracked for idempotency — the bottom layer of +/// the D2 split. Keyed by the caller-generated `continuation_id` in +/// `PendingInner::operations`, independent of `completed` / `running`; lives +/// until the session is released or the process restarts (Requirement 7.4). +struct OperationRecord { + task_id: String, + /// Payload identity: the same `continuation_id` with a different message + /// is a caller bug (`continuation_conflict`), never silently resolved. + message: String, + /// First accepted report. `None` while the dispatch is still in flight — + /// a duplicate arriving then gets an equivalent Running ack. + report: Option, +} + +/// Session-level domain state — the top layer of the D2 three-layer split +/// (Session / Turn / Operation). One entry per delegation `task_id`, created at +/// dispatch and retained for the whole session lifetime (running, settled, +/// released). Owns identity, ownership, and the kept-alive child connection. +/// +/// This is deliberately SEPARATE from [`CompletedTask`]: the completed-cache +/// only caches result TEXT (byte-capped, evictable), while this entry carries +/// the domain facts that must never be lost to a cache eviction. Emptying the +/// completed-cache may only make the system slower (DB fallback for text), +/// never wrong. +struct SessionEntry { + /// Persistent ownership key (D5): the parent CONVERSATION row id, not the + /// volatile parent connection id — so a parent reconnect can't orphan the + /// session. + parent_conversation_id: i32, + /// Latest run lease — the parent connection that dispatched the most + /// recent turn. Transient; refreshed on every dispatch. + parent_connection_id: String, + /// Parent `delegate_to_agent` tool_use_id — reused verbatim by follow-up + /// meta writes / started events (NEVER re-claimed; see `44415f56`). + parent_tool_use_id: String, + agent_type: AgentType, + child_conversation_id: i32, + /// Latest known child ACP connection. `Some` while running or kept alive + /// after a completed/failed settle; `None` once torn down (cancel) — a + /// later continuation must then go through the resume path. + child_connection_id: Option, + /// Session status = the latest turn's result (`Running` while a turn is in + /// flight). Mirrors what `get_delegation_status` reports. + status: TaskStatus, + /// Release semantics (R2-B4): the child process is freed and this session + /// takes no further continuation IN THIS PROCESS. Not persisted — a + /// restart naturally expires the lease (that is correct behavior, not a + /// gap). Permanent disposal = deleting the conversation row. + released: bool, + /// Set when a release-time disconnect FAILED: the child process may still + /// be alive (observable for diagnosis; a background retry + the + /// `--parent-pid` watchdog are the recovery paths). + orphan_suspect: bool, + /// Monotonic dispatch counter — +1 per dispatched turn (Requirement 8.1). + turn_version: u64, + /// Bounded turn history, newest at the back (D2 middle layer). + turns: VecDeque, + /// Resume metadata, cached lazily from the child conversation row on the + /// first continuation (folder → Branch A row adoption; `external_id` → + /// resume credential; `working_dir` → resume spawn cwd). + folder_id: Option, + external_id: Option, + working_dir: Option, +} + +impl SessionEntry { + /// Register a newly dispatched turn: bump the monotonic version and append + /// a bounded [`TurnRecord`]. Returns the new turn's `(turn_id, version)` + /// for event payloads. + fn push_turn(&mut self, origin: TurnOrigin) -> (String, u64) { + self.turn_version += 1; + let turn_id = uuid::Uuid::new_v4().to_string(); + self.turns.push_back(TurnRecord { + turn_id: turn_id.clone(), + turn_version: self.turn_version, + origin, + }); + while self.turns.len() > TURNS_CAP { + self.turns.pop_front(); + } + (turn_id, self.turn_version) + } +} + #[derive(Default)] struct PendingCalls { inner: Mutex, @@ -261,6 +484,27 @@ struct PendingCalls { /// `handle_request` drains both buffers at park. #[derive(Default)] struct PendingInner { + /// Session registry — the domain-state layer (see [`SessionEntry`]). Keyed + /// by `task_id`. Entries are created at dispatch and OUTLIVE both `running` + /// (turn scheduling) and `completed` (result-text cache): cancel paths + /// settle them in place, and parent-connection teardown does NOT remove + /// them (ownership is the parent conversation, not the connection — D5). + sessions: HashMap, + /// Kept-alive FIFO — task ids whose session currently retains a settled + /// child connection, oldest-settled first. Drives the `kept_alive_cap` + /// eviction (global + per-parent scope). Invariant: `task ∈ + /// kept_alive_order` ⇔ `sessions[task].child_connection_id.is_some()` and + /// the session is settled (maintained by `settle_session`). + kept_alive_order: VecDeque, + /// Global cap on kept-alive settled connections. `0` = unlimited. Seeded + /// by `set_config` from `DelegationConfig::kept_alive_cap` (same scheme as + /// `completed_cap_bytes`). + kept_alive_cap_global: usize, + /// Per-parent-conversation cap. Seeded to the SAME configured value as the + /// global cap today — with equal values the global check fires first, so + /// this layer is a structural safeguard that becomes load-bearing the day + /// the two values diverge (Requirement 5.5 mandates both scopes). + kept_alive_cap_per_parent: usize, /// Tasks running in the background after their `Running` ack, keyed by /// broker `call_id` (= `task_id`). A terminal event migrates an entry from /// here into `completed` under THIS lock (atomic `running` → `completed` @@ -309,6 +553,15 @@ struct PendingInner { /// `handle_request` rebuilds the full outcome at park with the real /// `child_conversation_id` (which the resolver, finding no entry, lacked). early_cancels: HashMap, + /// Continuation idempotency ledger (D2 bottom layer), keyed by the + /// caller-generated `continuation_id`. Deliberately independent of + /// `completed` / `running` so dedup covers the whole + /// dispatching → running → settled lifetime (R2-B5). + operations: HashMap, + /// True while the startup rebuild scan is in flight (Requirement 7.2): + /// a session-miss then answers the retryable `rebuilding` code instead of + /// `Unknown`. Set/cleared by `rebuild_sessions_from_db`. + rebuilding: bool, /// In-flight `handle_request` setups, keyed by a unique per-call id and /// registered at entry (BEFORE the claim poll, so the whole claim→park /// window is covered). This is the parent-cancel counterpart to `setups`: @@ -337,6 +590,12 @@ struct InflightSetup { /// so a cancel can't be lost between `handle_request`'s checkpoints, and its /// stamp lets the park order it against a racing child terminal. canceled_at: Option, + /// The task a CONTINUATION dispatch is acting on (`None` for the original + /// `start_delegation` setup, whose task id isn't minted yet at + /// registration). Lets `close_delegation_session` cancel exactly the + /// in-dispatch continuation of ITS task (Requirement 2.12) without + /// flagging the parent's unrelated setups. + task_id: Option, } impl PendingInner { @@ -439,11 +698,40 @@ impl PendingInner { InflightSetup { parent_connection_id: parent_connection_id.to_string(), canceled_at: None, + task_id: None, + }, + ); + id + } + + /// Like [`Self::register_inflight`], but for a CONTINUATION dispatch — + /// records the task id so a `close_delegation_session` racing the dispatch + /// can flag exactly this setup (Requirement 2.12). + fn register_inflight_for_task(&mut self, parent_connection_id: &str, task_id: &str) -> u64 { + let id = self.tick(); + self.inflight.insert( + id, + InflightSetup { + parent_connection_id: parent_connection_id.to_string(), + canceled_at: None, + task_id: Some(task_id.to_string()), }, ); id } + /// Flag the in-flight continuation dispatch(es) of ONE task as canceled — + /// the close-vs-continue serialization point (Requirement 2.12). Same + /// first-write-wins stamping as `mark_inflight_canceled_for_parent`. + fn mark_inflight_canceled_for_task(&mut self, task_id: &str) { + let stamp = self.tick(); + for setup in self.inflight.values_mut() { + if setup.task_id.as_deref() == Some(task_id) && setup.canceled_at.is_none() { + setup.canceled_at = Some(stamp); + } + } + } + /// Drop an in-flight setup record (idempotent). fn deregister_inflight(&mut self, id: u64) { self.inflight.remove(&id); @@ -551,6 +839,142 @@ impl PendingInner { } } + /// Settle the session entry for `task_id`: record the terminal status and + /// either retain the child connection (keep-alive, completed/failed) or + /// drop it (cancel-coded outcomes — the process is being torn down). + /// No-op for an unknown task (e.g. pure-unit tests that drive + /// `insert_completed` directly without a dispatch). + /// + /// Returns the connection ids EVICTED by the `kept_alive_cap` to make room + /// for a newly retained connection (Property 1 conservation: every id + /// cleared from a session is handed back for a disconnect call). The + /// caller MUST disconnect them after releasing the pending lock. + #[must_use] + fn settle_session( + &mut self, + task_id: &str, + status: TaskStatus, + keep_conn: Option, + ) -> Vec { + let parent_conversation_id = match self.sessions.get_mut(task_id) { + Some(s) => { + // Release is terminal (R2-B4): a close that raced this settle + // has already recorded the session's final status and taken + // its connection pointer — for an in-dispatch continuation it + // deferred the teardown to the dispatch's compensation paths + // (S3 checkpoint / `abort_continuation`), which settle through + // HERE. Restoring `keep_conn` onto the released session would + // leave `released = true` plus a connection nobody will ever + // disconnect (leaked child process), and overwriting `status` + // would corrupt the close's recorded terminal. Freeze the + // session and hand the connection back to the caller for a + // disconnect instead (Property 1 conservation: every id not + // retained by a session is returned for teardown). + if s.released { + self.kept_alive_order.retain(|t| t != task_id); + return keep_conn.into_iter().collect(); + } + s.status = status; + s.child_connection_id = keep_conn.clone(); + s.parent_conversation_id + } + None => return Vec::new(), + }; + // Maintain the kept-alive FIFO invariant: drop any stale slot for this + // task (re-settle after a continuation), then re-register when a + // connection is retained. + self.kept_alive_order.retain(|t| t != task_id); + if keep_conn.is_none() { + return Vec::new(); + } + self.kept_alive_order.push_back(task_id.to_string()); + self.enforce_kept_alive_caps(parent_conversation_id) + } + + /// Enforce the kept-alive caps after a new retention: global scope first + /// (FIFO over ALL kept connections), then the per-parent scope for the + /// parent that just retained one. Clears each victim's session connection + /// pointer and returns the ids for the caller to disconnect out of lock. + /// The just-retained newest entry sits at the back of the FIFO, so it is + /// only evicted when the cap is smaller than 1 slot of headroom. + fn enforce_kept_alive_caps(&mut self, parent_conversation_id: i32) -> Vec { + let mut evicted = Vec::new(); + if self.kept_alive_cap_global > 0 { + while self.kept_alive_order.len() > self.kept_alive_cap_global { + let Some(victim) = self.kept_alive_order.pop_front() else { + break; + }; + if let Some(cid) = self + .sessions + .get_mut(&victim) + .and_then(|s| s.child_connection_id.take()) + { + evicted.push(cid); + } + } + } + if self.kept_alive_cap_per_parent > 0 { + loop { + let parent_tasks: Vec = self + .kept_alive_order + .iter() + .filter(|t| { + self.sessions + .get(t.as_str()) + .is_some_and(|s| s.parent_conversation_id == parent_conversation_id) + }) + .cloned() + .collect(); + if parent_tasks.len() <= self.kept_alive_cap_per_parent { + break; + } + let victim = parent_tasks[0].clone(); + self.kept_alive_order.retain(|t| t != &victim); + if let Some(cid) = self + .sessions + .get_mut(&victim) + .and_then(|s| s.child_connection_id.take()) + { + evicted.push(cid); + } + } + } + evicted + } + + /// Re-apply the kept-alive caps to EVERY parent — called by `set_config` + /// when the cap may have been LOWERED at runtime, mirroring + /// `enforce_completed_cap_all_parents`. Returns the evicted connection ids + /// for the caller to disconnect after releasing the lock. + fn enforce_kept_alive_caps_all(&mut self) -> Vec { + let parents: Vec = self + .kept_alive_order + .iter() + .filter_map(|t| self.sessions.get(t).map(|s| s.parent_conversation_id)) + .collect(); + let mut evicted = Vec::new(); + for parent in parents { + evicted.extend(self.enforce_kept_alive_caps(parent)); + } + evicted + } + + /// Remove ONE completed entry, keeping the byte counter and per-parent + /// order index consistent. Used when a continuation moves a settled task + /// back to `running` (the old result text is no longer the current answer) + /// and by the dispatch-window cancel compensation. + fn remove_completed_entry(&mut self, task_id: &str) { + if let Some(c) = self.completed.remove(task_id) { + let freed = c.text.as_ref().map_or(0, |t| t.len()); + if let Some(slot) = self.completed_bytes.get_mut(&c.parent_connection_id) { + *slot = slot.saturating_sub(freed); + } + if let Some(order) = self.completed_order.get_mut(&c.parent_connection_id) { + order.retain(|t| t != task_id); + } + } + } + /// Forget every completed result for a parent. Called on connection /// teardown (the parent is gone — nothing left to query). A turn cancel /// deliberately does NOT call this: the connection stays alive and the LLM @@ -683,6 +1107,12 @@ fn drain_and_record_canceled( &outcome, ), ); + // Cancel tears the child process down — clear the session's connection + // so a later continuation must go through the resume path instead of + // talking to a dead connection. `keep_conn: None` never evicts, so the + // returned list is structurally empty here. + let evicted = inner.settle_session(&k, TaskStatus::Canceled, None); + debug_assert!(evicted.is_empty()); out.push((task, duration_ms)); } out @@ -731,6 +1161,21 @@ fn report_from_outcome( } } +/// Build a `Failed` report carrying a known task id and a typed continuation +/// error — the continue / close rejection shape (the task exists, the +/// operation on it was refused). The wire `error_code` / `message` are +/// projected by `DelegationOutcome::from_err`, keeping `types.rs` the single +/// source of the continuation error contract (T4.1). +fn continuation_err_report( + task_id: &str, + err: DelegationError, + child_conversation_id: Option, + agent_type: Option, +) -> DelegationTaskReport { + let outcome = DelegationOutcome::from_err(err, child_conversation_id); + report_from_outcome(Some(task_id.to_string()), agent_type, &outcome, None) +} + /// Build a `Failed`/`Canceled` report for a setup error (no task id — setup /// failed before/around registration, so the LLM has no task to track). fn report_err( @@ -821,6 +1266,39 @@ fn completed_report(task_id: &str, c: &CompletedTask) -> DelegationTaskReport { } } +/// Last-known-status report for a released session: the cached completed +/// result when the text cache still holds it, else a status-only shell from +/// the session's domain facts. The message states the release explicitly. +fn last_known_report_locked( + inner: &PendingInner, + task_id: &str, + status: TaskStatus, + child_conversation_id: i32, + agent_type: AgentType, +) -> DelegationTaskReport { + let mut report = if let Some(c) = inner.completed.get(task_id) { + completed_report(task_id, c) + } else { + DelegationTaskReport { + task_id: Some(task_id.to_string()), + status, + child_conversation_id: Some(child_conversation_id), + agent_type: Some(agent_type), + text: None, + error_code: None, + message: None, + duration_ms: None, + } + }; + report.message = Some( + "Session released. The child process is freed and this session cannot \ + be continued in this process. Start a new delegate_to_agent for \ + further work." + .to_string(), + ); + report +} + /// Status report when a task id isn't known to the caller (never existed, /// owned by a different parent, or evicted with no DB record). fn unknown_report(task_id: &str) -> DelegationTaskReport { @@ -1964,17 +2442,32 @@ impl DelegationBroker { pub async fn set_config(&self, cfg: DelegationConfig) { let cap_bytes = cfg.completed_cache_cap_bytes; + let kept_cap = cfg.kept_alive_cap; *self.config.lock().await = cfg; - // Seed the byte cap into the pending-calls bucket so `insert_completed` - // reads it lock-free (it already holds the pending lock). Acquired AFTER + // Seed the caps into the pending-calls bucket so the settle paths read + // them lock-free (they already hold the pending lock). Acquired AFTER // the config guard above is dropped — sequential, never nested — so no // path locks `config` under `pending` or vice-versa (deadlock-free). - // Then prune existing per-parent caches: a LOWERED cap must free memory - // now, not lazily on each parent's next completion (which may never + // Then prune existing retentions: a LOWERED cap must free memory / + // processes now, not lazily on the next completion (which may never // arrive for an idle parent). - let mut inner = self.pending.inner.lock().await; - inner.completed_cap_bytes = cap_bytes; - inner.enforce_completed_cap_all_parents(); + let evicted = { + let mut inner = self.pending.inner.lock().await; + inner.completed_cap_bytes = cap_bytes; + // TODO(delegation-settings): one `kept_alive_cap` setting feeds + // BOTH scopes, so the per-parent layer is same-value redundant + // with the global FIFO until the settings surface lets the two + // caps diverge. Keep the two-scope enforcement (the eviction + // semantics differ) but don't read meaning into the equal values. + inner.kept_alive_cap_global = kept_cap; + inner.kept_alive_cap_per_parent = kept_cap; + inner.enforce_completed_cap_all_parents(); + inner.enforce_kept_alive_caps_all() + }; + // Disconnect displaced kept-alive children out of lock (Property 1). + for cid in evicted { + let _ = self.spawner.disconnect(&cid).await; + } } pub async fn config_snapshot(&self) -> DelegationConfig { @@ -2396,6 +2889,28 @@ impl DelegationBroker { let setup_duration_ms = started_at.elapsed().as_millis() as u64; let disposition = { let mut inner = self.pending.inner.lock().await; + // Create the session entry (domain layer) for this dispatch. Done + // unconditionally under the disposition lock: the terminal arms + // below settle it in place, and the Running arm leaves it live. + let mut session_entry = SessionEntry { + parent_conversation_id: req.parent_conversation_id, + parent_connection_id: req.parent_connection_id.clone(), + parent_tool_use_id: req.parent_tool_use_id.clone(), + agent_type: req.agent_type, + child_conversation_id, + child_connection_id: Some(child_connection_id.clone()), + status: TaskStatus::Running, + released: false, + orphan_suspect: false, + turn_version: 0, + turns: VecDeque::new(), + folder_id: None, + external_id: None, + working_dir: None, + }; + // Turn 1 = the original delegation dispatch. + let _ = session_entry.push_turn(TurnOrigin::ParentAgent); + inner.sessions.insert(call_id.clone(), session_entry); // Each buffered child terminal carries (arrival_stamp, outcome). let child_terminal: Option<(u64, DelegationOutcome)> = if let Some((stamp, outcome)) = inner.take_early_complete(&call_id) { @@ -2431,36 +2946,45 @@ impl DelegationBroker { outcome, ), ); + // Settle the session created above: cancel-coded outcomes drop + // the connection (about to be torn down); a child that finished + // during setup keeps it alive for continue_with_session. + let is_canceled = matches!( + outcome, + DelegationOutcome::Err { code, .. } if code == "canceled" + ); + let keep_conn = (!is_canceled).then(|| child_connection_id.clone()); + inner.settle_session(&call_id, terminal_fields(outcome).0, keep_conn) }; match (child_terminal, parent_canceled_at) { // Both raced in the setup window: the earlier arrival stamp wins. (Some((child_stamp, outcome)), Some(cancel_stamp)) => { inner.deregister_inflight(inflight_id); if child_stamp < cancel_stamp { - record(&mut inner, &outcome); - Disposition::ChildTerminal(outcome) + let evicted = record(&mut inner, &outcome); + (Disposition::ChildTerminal(outcome), evicted) } else { - record( + let evicted = record( &mut inner, &canceled_outcome(child_conversation_id, "parent canceled"), ); - Disposition::ParentCanceled + (Disposition::ParentCanceled, evicted) } } // Only a child terminal fired. (Some((_, outcome)), None) => { inner.deregister_inflight(inflight_id); - record(&mut inner, &outcome); - Disposition::ChildTerminal(outcome) + let evicted = record(&mut inner, &outcome); + (Disposition::ChildTerminal(outcome), evicted) } // Only a parent cancel fired. (None, Some(_)) => { inner.deregister_inflight(inflight_id); - record( + let evicted = record( &mut inner, &canceled_outcome(child_conversation_id, "parent canceled"), ); - Disposition::ParentCanceled + (Disposition::ParentCanceled, evicted) } // Nothing beat us — register the running task for a future // resolver, deregistering the in-flight record adjacent to the @@ -2479,13 +3003,22 @@ impl DelegationBroker { task_id: call_id.clone(), external_handle: req.external_handle.clone(), started_at, + turn_version: 1, + turn_id: None, + origin: TurnOrigin::ParentAgent, }, ); inner.deregister_inflight(inflight_id); - Disposition::Running + (Disposition::Running, Vec::new()) } } }; + let (disposition, cap_evicted) = disposition; + // Kept-alive cap evictions decided under the disposition lock: release + // the displaced connections now that the lock is gone (Property 1). + for cid in cap_evicted { + let _ = self.spawner.disconnect(&cid).await; + } match disposition { // A child terminal beat registration. Finalize (terminal meta + @@ -2503,6 +3036,8 @@ impl DelegationBroker { &outcome, &task_preview, &call_id, + // Setup-window terminal is always the FIRST turn. + true, ) .await; self.result_notify.notify_waiters(); @@ -2586,6 +3121,8 @@ impl DelegationBroker { &outcome, ), ); + let evicted = inner.settle_session(&call_id, TaskStatus::Canceled, None); + debug_assert!(evicted.is_empty()); Some(duration_ms) } else { None @@ -2653,6 +3190,10 @@ impl DelegationBroker { /// the `call_id` is no longer reserved the call was already resolved by /// another terminal path, so the buffer is skipped (silent no-op). pub async fn complete_call(&self, call_id: &str, outcome: DelegationOutcome) { + let is_canceled = matches!( + &outcome, + DelegationOutcome::Err { code, .. } if code == "canceled" + ); let task = { let mut inner = self.pending.inner.lock().await; match inner.running.remove(call_id) { @@ -2670,7 +3211,12 @@ impl DelegationBroker { &outcome, ), ); - Some((task, duration_ms)) + // Keep the child process for continue_with_session unless + // this terminal is cancel-coded (Property 3). + let keep_conn = (!is_canceled).then(|| task.child_connection_id.clone()); + let evicted = + inner.settle_session(call_id, terminal_fields(&outcome).0, keep_conn); + Some((task, duration_ms, evicted)) } None => { // Buffer for the racing `start_delegation` to drain iff still @@ -2681,7 +3227,12 @@ impl DelegationBroker { } } }; - if let Some((task, duration_ms)) = task { + if let Some((task, duration_ms, evicted)) = task { + // Kept-alive cap evictions decided under the settle lock: release + // the displaced connections now that the lock is gone (Property 1). + for cid in evicted { + let _ = self.spawner.disconnect(&cid).await; + } self.finalize_delegation( &task.parent_connection_id, &task.parent_tool_use_id, @@ -2692,8 +3243,16 @@ impl DelegationBroker { &outcome, &task.task_preview, &task.task_id, + // Requirement 2.8a: a continued turn (version > 1) must not + // re-emit completion against the already-terminal tool call. + task.turn_version <= 1, ) .await; + // T4.3 (Requirements 2.8 / 8.2): a CONTINUED turn's terminal is + // announced by the session-scoped update carrying + // (task_id, turn_id, turn_version, origin) — the replacement for + // the suppressed completion above. + self.emit_session_update_for_settled_turn(&task).await; self.result_notify.notify_waiters(); } } @@ -2721,6 +3280,7 @@ impl DelegationBroker { outcome: &DelegationOutcome, task_preview: &str, task_id: &str, + emit_completion: bool, ) { let meta = match outcome { DelegationOutcome::Ok(ok) => build_delegation_meta( @@ -2746,17 +3306,28 @@ impl DelegationBroker { }; self.write_meta_if_real(parent_connection_id, parent_tool_use_id, meta) .await; - self.emit_completed_if_real( - parent_connection_id, - parent_tool_use_id, - child_connection_id, - child_conversation_id, - agent_type, - outcome_to_summary(outcome, duration_ms), - ) - .await; - // v1 one-shot: always tear down the child. - let _ = self.spawner.disconnect(child_connection_id).await; + if emit_completion { + self.emit_completed_if_real( + parent_connection_id, + parent_tool_use_id, + child_connection_id, + child_conversation_id, + agent_type, + outcome_to_summary(outcome, duration_ms), + ) + .await; + } + // Terminal-outcome routing (Property 3): completed / failed children + // stay alive so continue_with_session can follow up with the child's + // context intact. Only cancel-coded outcomes tear the child down here + // (the cancel_by_* paths run their own teardown). + let should_disconnect = matches!( + outcome, + DelegationOutcome::Err { code, .. } if code == "canceled" + ); + if should_disconnect { + let _ = self.spawner.disconnect(child_connection_id).await; + } } /// Internal helper — apply the meta write iff the parent's @@ -2812,6 +3383,40 @@ impl DelegationBroker { .await; } + /// Internal helper — emit the session-scoped + /// `AcpEvent::DelegationSessionUpdate` for a settled CONTINUED turn + /// (Requirements 2.8 / 8.2). No-op for the original turn (version 1 — its + /// terminal is the tool-scoped `DelegationCompleted`) and for the + /// defensive case of a continued turn without a recorded turn id. Unlike + /// the completed/started emits this is NOT gated on a real tool_use_id: + /// the event is session-addressed (task_id), not tool-call-addressed. + async fn emit_session_update_for_settled_turn(&self, task: &RunningTask) { + if task.turn_version <= 1 { + return; + } + let Some(turn_id) = task.turn_id.as_deref() else { + debug_assert!(false, "continued turns always carry a turn_id"); + return; + }; + // USER-originated continuations carry the synthetic + // `USER_ENTRY_CONNECTION_ID` as `parent_connection_id`, which the + // emitter's `get_state_and_emitter` fan-out lookup never resolves — + // for those turns this emit is an INTENTIONAL silent no-op, not a + // missed wire-up: the parent AI collects user-origin turn results by + // POLLING `get_delegation_status` (design §2.8b), never via a push + // on a parent connection stream. + self.event_emitter + .emit_session_update( + &task.parent_connection_id, + task.child_conversation_id, + &task.task_id, + turn_id, + task.turn_version, + task.origin, + ) + .await; + } + /// Internal helper — emit `AcpEvent::DelegationCompleted` on the parent's /// stream iff the `parent_tool_use_id` refers to a real ACP tool_call. /// Synthetic ids (the `"delegation-"` UUID fallback) map to no @@ -2920,7 +3525,8 @@ impl DelegationBroker { for (task, duration_ms) in drained { // The child already disconnected/errored — disconnect-only teardown // (no spawner `cancel`, there's no live turn to interrupt). - self.teardown_canceled_child(&task, duration_ms, false).await; + self.teardown_canceled_child(&task, duration_ms, false) + .await; } self.result_notify.notify_waiters(); } @@ -3018,6 +3624,13 @@ impl DelegationBroker { .map(|k| { let task = inner.running.remove(&k).expect("key just observed"); let duration_ms = task.started_at.elapsed().as_millis() as u64; + // The in-flight turn is canceled and its child torn + // down; settle the session accordingly. The session + // entry itself SURVIVES the teardown (ownership is the + // parent conversation, not the connection — D5). + // `keep_conn: None` never evicts. + let evicted = inner.settle_session(&k, TaskStatus::Canceled, None); + debug_assert!(evicted.is_empty()); (task, duration_ms) }) .collect(); @@ -3075,17 +3688,25 @@ impl DelegationBroker { ), ) .await; - self.emit_completed_if_real( - &task.parent_connection_id, - &task.parent_tool_use_id, - &task.child_connection_id, - task.child_conversation_id, - task.agent_type, - DelegationResultSummary::Err { - error_code: "canceled".to_string(), - }, - ) - .await; + // Requirement 2.8a: a continued turn (version > 1) must not re-emit a + // completion event against the already-terminal tool call — the meta + // snapshot above still updates the card state. Its terminal is instead + // announced by the session-scoped update (T4.3). + if task.turn_version <= 1 { + self.emit_completed_if_real( + &task.parent_connection_id, + &task.parent_tool_use_id, + &task.child_connection_id, + task.child_conversation_id, + task.agent_type, + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + } else { + self.emit_session_update_for_settled_turn(task).await; + } if cancel_turn { let _ = self.spawner.cancel(&task.child_connection_id).await; } @@ -3317,328 +3938,3285 @@ impl DelegationBroker { } } - /// DB status fallback for a task evicted from / never in the in-memory maps. - /// Scopes to the caller's conversation: a child whose `parent_id` doesn't - /// match (or when the caller has no active conversation) reports `Unknown`. - async fn status_from_db( + /// Backs `continue_with_session` (MCP) and the user-side continuation + /// entry: send a follow-up into an existing child session under the SAME + /// `task_id` (Property 4), adopting the existing child conversation row + /// (Property 2 — never a second row). + /// + /// Five-stage orchestration with per-stage compensation (design §阶段化补偿矩阵): + /// S1 validate + ledger + register cancellable (R3: BEFORE any I/O) → + /// S2 pick live connection or resume-spawn → S3 pre-send cancel check → + /// S4 send follow-up → S5 commit to `running` (with a final cancel check). + /// + /// Ownership is the parent CONVERSATION id (D5): a reconnected parent with + /// a new connection id can still continue its tasks. `continuation_id` is + /// the caller-generated idempotency key (required; Requirement 2.13). + pub async fn continue_delegation( &self, + parent_connection_id: &str, parent_conversation_id: Option, task_id: &str, + message: String, + continuation_id: &str, + origin: TurnOrigin, ) -> DelegationTaskReport { - match self.status_lookup.find_by_call_id(task_id).await { - Some(rec) - if parent_conversation_id.is_some() && rec.parent_id == parent_conversation_id => - { - db_report(task_id, &rec) - } - _ => unknown_report(task_id), + let message = message.trim().to_string(); + if message.is_empty() { + return continuation_err_report( + task_id, + DelegationError::NotContinuable("message is empty".into()), + None, + None, + ); + } + if continuation_id.trim().is_empty() { + return continuation_err_report( + task_id, + DelegationError::NotContinuable("continuation_id is required".into()), + None, + None, + ); } - } - /// Test-only shim preserving the old blocking `handle_request` contract over - /// the async path: start the delegation, then block until it reaches a - /// terminal state (driven by the test's `complete_call` / cancel), mapping - /// the terminal report back to a `DelegationOutcome`. Keeps the broker's - /// extensive setup-window race tests exercising the same lifecycle without - /// each rewriting to the start/poll/collect shape. - #[cfg(any(test, feature = "test-utils"))] - pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome { - let parent_connection_id = req.parent_connection_id.clone(); - let parent_conversation_id = Some(req.parent_conversation_id); - let ack = self.start_delegation(req).await; - let task_id = match ack.task_id.clone() { - Some(id) => id, - // Setup failed before a task existed — the ack itself is terminal. - None => return report_to_outcome(&ack), - }; - if ack.status != TaskStatus::Running { - return report_to_outcome(&ack); + // --- S1: dedupe + ownership + state gate, all under one lock -------- + struct DispatchPlan { + inflight_id: u64, + prior_status: TaskStatus, + prior_conn: Option, + child_conversation_id: i32, + agent_type: AgentType, + parent_tool_use_id: String, + folder_id: Option, + external_id: Option, + working_dir: Option, } - // Block until terminal via the long-poll path (re-issued so an - // indefinitely-pending task in a test simply parks here, mirroring the - // old unbounded `rx.await`). - loop { - let report = self - .get_task_status( - &parent_connection_id, - parent_conversation_id, - &task_id, - StatusWait::Bounded(3_600_000), - ) - .await; - if report.status != TaskStatus::Running { - return report_to_outcome(&report); + let plan = { + let mut inner = self.pending.inner.lock().await; + // Operation ledger first: a replay must return the FIRST report + // (never `session_still_running`), and a same-id different-payload + // request is a conflict (R2-B5). + if let Some(op) = inner.operations.get(continuation_id) { + if op.task_id != task_id || op.message != message { + return continuation_err_report( + task_id, + DelegationError::ContinuationConflict, + None, + None, + ); + } + if let Some(report) = &op.report { + return report.clone(); + } + // Dispatch still in flight: an equivalent Running ack. + let (conv, at) = inner + .sessions + .get(task_id) + .map(|s| (Some(s.child_conversation_id), Some(s.agent_type))) + .unwrap_or((None, None)); + return DelegationTaskReport { + task_id: Some(task_id.to_string()), + status: TaskStatus::Running, + child_conversation_id: conv, + agent_type: at, + text: None, + error_code: None, + message: Some("Continuation already dispatching.".to_string()), + duration_ms: None, + }; } - } - } - - #[cfg(any(test, feature = "test-utils"))] - pub async fn peek_first_pending_call_id(&self) -> Option { - self.pending - .inner - .lock() - .await - .running - .keys() - .next() - .cloned() - } + let Some(s) = inner.sessions.get_mut(task_id) else { + if inner.rebuilding { + return continuation_err_report( + task_id, + DelegationError::Rebuilding, + None, + None, + ); + } + return unknown_report(task_id); + }; + // Ownership (D5): prefer the persistent parent conversation id; + // fall back to the connection id only when the caller has no + // conversation context. A mismatch never leaks existence. + let owned = match parent_conversation_id { + Some(pc) => s.parent_conversation_id == pc, + None => s.parent_connection_id == parent_connection_id, + }; + if !owned { + return unknown_report(task_id); + } + if s.released { + return continuation_err_report( + task_id, + DelegationError::SessionReleased, + Some(s.child_conversation_id), + Some(s.agent_type), + ); + } + if s.status == TaskStatus::Running { + return continuation_err_report( + task_id, + DelegationError::SessionStillRunning, + Some(s.child_conversation_id), + Some(s.agent_type), + ); + } + let prior_status = s.status; + let prior_conn = s.child_connection_id.clone(); + let plan = DispatchPlan { + inflight_id: 0, // filled below (needs &mut inner after s drops) + prior_status, + prior_conn, + child_conversation_id: s.child_conversation_id, + agent_type: s.agent_type, + parent_tool_use_id: s.parent_tool_use_id.clone(), + folder_id: s.folder_id, + external_id: s.external_id.clone(), + working_dir: s.working_dir.clone(), + }; + // In-dispatch marker: a concurrent continuation (different + // continuation_id) now sees Running and is rejected; the + // compensation paths restore `prior_status` on failure. + s.status = TaskStatus::Running; + // Refresh the run lease to the dispatching parent connection. + s.parent_connection_id = parent_connection_id.to_string(); + // The connection (if any) is about to be actively used — take it + // out of the kept-alive FIFO so a racing cap eviction can't kill + // it mid-dispatch. + inner.kept_alive_order.retain(|t| t != task_id); + // R3 fix: register as cancellable BEFORE any I/O, so a parent + // cancel landing anywhere in the dispatch window reaches this turn. + // Task-scoped so a racing close can flag exactly this dispatch. + let inflight_id = inner.register_inflight_for_task(parent_connection_id, task_id); + inner.operations.insert( + continuation_id.to_string(), + OperationRecord { + task_id: task_id.to_string(), + message: message.clone(), + report: None, + }, + ); + DispatchPlan { + inflight_id, + ..plan + } + }; + // --- resolve resume/folder metadata (cache → DB fallback) ----------- + let (folder_id, external_id, working_dir) = if plan.folder_id.is_some() { + ( + plan.folder_id, + plan.external_id.clone(), + plan.working_dir.clone(), + ) + } else { + match self.status_lookup.find_by_call_id(task_id).await { + Some(rec) => (Some(rec.folder_id), rec.external_id, rec.working_dir), + None => (None, plan.external_id.clone(), plan.working_dir.clone()), + } + }; + let Some(folder_id) = folder_id else { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + return continuation_err_report( + task_id, + DelegationError::NotContinuable("missing folder for child conversation".into()), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + }; + + // --- S2: pick a live kept connection, or resume the agent session --- + let mut newly_spawned = false; + let conn_id = match plan.prior_conn.clone() { + Some(cid) if self.spawner.is_alive(&cid).await => cid, + _ => { + // Resume validation is grounded in the DB row (Requirement + // 7.6): re-read it even when the session carries cached + // metadata — the row is what the credential belongs to. + let Some(rec) = self.status_lookup.find_by_call_id(task_id).await else { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + return continuation_err_report( + task_id, + DelegationError::NotContinuable( + "child conversation row not found; cannot validate the resume".into(), + ), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + }; + // Triple binding check (7.6): agent + child conversation + + // folder must all match the resume target. Refuse BEFORE + // attempting the resume — never hand a foreign credential to + // an agent. + let folder_bound = plan.folder_id; + let mismatch = rec.agent_type != plan.agent_type + || rec.child_conversation_id != plan.child_conversation_id + || folder_bound.is_some_and(|f| rec.folder_id != f); + if mismatch { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + return continuation_err_report( + task_id, + DelegationError::NotContinuable( + "stored agent/folder binding does not match the resume target".into(), + ), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + } + // Requirement 3.2: no resume credential + dead process = not + // continuable. Rejected BEFORE any prompt side effect. The DB + // row's credential wins over the session cache (3.3a: the + // cache is never OVERWRITTEN with a lesser value — see S5). + let Some(session_id) = rec.external_id.clone().or_else(|| external_id.clone()) + else { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + return continuation_err_report( + task_id, + DelegationError::NotContinuable( + "child process is gone and no resume credential (external_id) exists" + .into(), + ), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + }; + // Ambiguity check (7.7): a credential referenced by multiple + // child rows cannot be resolved to ONE session — refuse rather + // than guess. + if self.status_lookup.external_id_ref_count(&session_id).await > 1 { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + return continuation_err_report( + task_id, + DelegationError::ResumeUnavailable( + "resume credential is ambiguous (external_id referenced by \ + multiple child sessions); refusing to guess" + .into(), + ), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + } + let cfg = self.config_snapshot().await; + let (preferred_mode_id, preferred_config_values) = cfg + .agent_defaults + .get(&plan.agent_type) + .map(|d: &AgentDelegationDefaults| (d.mode_id.clone(), d.config_values.clone())) + .unwrap_or((None, BTreeMap::new())); + match self + .spawner + .spawn_for_resume( + parent_connection_id, + plan.agent_type, + working_dir.clone(), + Some(session_id), + preferred_mode_id, + preferred_config_values, + ) + .await + { + Ok(id) => { + newly_spawned = true; + id + } + Err(e) => { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + // A failed resume spawn = the resume chain cannot + // restore the context (3.3). No prompt side effect has + // occurred and the credential is untouched (3.3a). + return continuation_err_report( + task_id, + DelegationError::ResumeUnavailable(format!( + "resume failed before dispatch: {e}" + )), + Some(plan.child_conversation_id), + Some(plan.agent_type), + ); + } + } + } + }; + + // --- S3: pre-send cancel checkpoint --------------------------------- + // A cancel observed BEFORE the prompt went out: nothing was dispatched, + // so restore the settled state. Only a NEWLY spawned connection is torn + // down (design S3 compensation); a kept-alive one survives — settled + // children are not victims of a parent turn cancel (R2-B6 / 3.10). + if self.take_inflight_cancel(plan.inflight_id).await { + let evicted = { + let mut inner = self.pending.inner.lock().await; + inner.operations.remove(continuation_id); + inner.settle_session(task_id, plan.prior_status, plan.prior_conn.clone()) + }; + // Re-registering the restored conn can displace another kept + // connection under a tight cap — release it out of lock. + for cid in evicted { + let _ = self.spawner.disconnect(&cid).await; + } + if newly_spawned { + let _ = self.spawner.disconnect(&conn_id).await; + } + return report_from_outcome( + Some(task_id.to_string()), + Some(plan.agent_type), + &canceled_outcome(plan.child_conversation_id, "parent canceled"), + None, + ); + } + + // --- S4: send the follow-up prompt (Branch A row adoption) ---------- + if let Err(e) = self + .spawner + .send_followup_prompt( + &conn_id, + message.clone(), + plan.child_conversation_id, + folder_id, + ) + .await + { + self.abort_continuation( + task_id, + continuation_id, + plan.inflight_id, + plan.prior_status, + plan.prior_conn.clone(), + ) + .await; + // S4 compensation: a newly spawned connection must not be leaked; + // a kept-alive one is left in place (usable next time). + if newly_spawned { + let _ = self.spawner.disconnect(&conn_id).await; + } + let mut r = report_err( + plan.agent_type, + DelegationError::SubagentRuntimeError(e.to_string()), + Some(plan.child_conversation_id), + ); + r.task_id = Some(task_id.to_string()); + return r; + } + + // --- S5: commit into `running` (final cancel check under the lock) -- + let started_at = Instant::now(); + let committed = { + let mut inner = self.pending.inner.lock().await; + if inner.inflight_canceled(plan.inflight_id) { + // The prompt already reached the child: this turn is live and + // must be cancelled + torn down (leaving it would recreate the + // unmanaged-child gap R3 exists to close). + inner.deregister_inflight(plan.inflight_id); + inner.operations.remove(continuation_id); + inner.remove_completed_entry(task_id); + inner.insert_completed( + task_id, + build_completed( + parent_connection_id, + plan.child_conversation_id, + plan.agent_type, + started_at.elapsed().as_millis() as u64, + &canceled_outcome(plan.child_conversation_id, "parent canceled"), + ), + ); + let evicted = inner.settle_session(task_id, TaskStatus::Canceled, None); + debug_assert!(evicted.is_empty()); + None + } else { + inner.deregister_inflight(plan.inflight_id); + let (turn_id, turn_version) = { + let s = inner + .sessions + .get_mut(task_id) + .expect("session registry entries are never removed in-process"); + let (turn_id, version) = s.push_turn(origin); + s.status = TaskStatus::Running; + s.child_connection_id = Some(conn_id.clone()); + // Cache resume metadata for later continuations. Never + // overwrite an existing credential with None (3.3a). + s.folder_id = Some(folder_id); + if s.external_id.is_none() { + s.external_id = external_id.clone(); + } + if s.working_dir.is_none() { + s.working_dir = working_dir.clone(); + } + (turn_id, version) + }; + // The previous turn's cached text is no longer the current + // answer — drop it so a status poll reports Running. + inner.remove_completed_entry(task_id); + inner.running.insert( + task_id.to_string(), + RunningTask { + child_connection_id: conn_id.clone(), + child_conversation_id: plan.child_conversation_id, + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: plan.parent_tool_use_id.clone(), + agent_type: plan.agent_type, + task_preview: truncate_on_char_boundary(&message, TASK_PREVIEW_CAP), + task_id: task_id.to_string(), + external_handle: None, + started_at, + turn_version, + turn_id: Some(turn_id), + origin, + }, + ); + let mut ack = running_ack( + task_id.to_string(), + plan.child_conversation_id, + plan.agent_type, + ); + ack.message = Some(format!( + "Continue successful. task_id={task_id}. Call get_delegation_status \ + with this id in the task_ids array (optionally wait_ms) to collect \ + the new turn's result, or cancel_delegation / close_session when done." + )); + if let Some(op) = inner.operations.get_mut(continuation_id) { + op.report = Some(ack.clone()); + } + Some(ack) + } + }; + let Some(ack) = committed else { + // Post-send cancel: tear the dispatched turn down. + let _ = self.spawner.cancel(&conn_id).await; + let _ = self.spawner.disconnect(&conn_id).await; + self.result_notify.notify_waiters(); + return report_from_outcome( + Some(task_id.to_string()), + Some(plan.agent_type), + &canceled_outcome(plan.child_conversation_id, "parent canceled"), + None, + ); + }; + + // Re-announce on the ORIGINAL tool_use_id — direct addressing, never + // the claim path (44415f56). The frontend re-attaches / cancels its + // detach timer on this started event. + let task_preview = truncate_on_char_boundary(&message, TASK_PREVIEW_CAP); + self.write_meta_if_real( + parent_connection_id, + &plan.parent_tool_use_id, + build_delegation_meta( + "running", + Some(&conn_id), + Some(plan.child_conversation_id), + None, + None, + None, + Some(&task_preview), + Some(task_id), + ), + ) + .await; + self.emit_started_if_real( + parent_connection_id, + &plan.parent_tool_use_id, + &conn_id, + plan.child_conversation_id, + plan.agent_type, + &task_preview, + task_id, + ) + .await; + ack + } + + /// Compensation for a continuation that failed BEFORE dispatch committed: + /// drop the in-flight record and the operation-ledger entry, and restore + /// the session to its pre-dispatch settled state (status + connection, + /// re-registering the kept-alive slot). Any connections displaced by that + /// re-registration are disconnected here. If a concurrent close RELEASED + /// the session mid-dispatch, `settle_session` refuses the restore and + /// returns `prior_conn` itself — the disconnect below then performs the + /// teardown the close deferred to this compensation path (2.12). + async fn abort_continuation( + &self, + task_id: &str, + continuation_id: &str, + inflight_id: u64, + prior_status: TaskStatus, + prior_conn: Option, + ) { + let evicted = { + let mut inner = self.pending.inner.lock().await; + inner.deregister_inflight(inflight_id); + inner.operations.remove(continuation_id); + inner.settle_session(task_id, prior_status, prior_conn) + }; + for cid in evicted { + let _ = self.spawner.disconnect(&cid).await; + } + } + + /// Backs `close_session`: RELEASE a delegated child — free its process and + /// refuse further continuation in this process (R2-B4: release semantics, + /// not a permanent close; permanent disposal = deleting the conversation + /// row). A running turn is canceled first (Requirement 2.9); repeat closes + /// are side-effect-free and return the last known status (2.10); a task + /// with no live connection still releases (2.11); a close racing an + /// in-dispatch continuation serializes under the pending lock and cancels + /// it (2.12). + pub async fn close_delegation_session( + &self, + parent_connection_id: &str, + parent_conversation_id: Option, + task_id: &str, + continuation_id: &str, + ) -> DelegationTaskReport { + let (report, conn_to_disconnect, teardown) = { + let mut inner = self.pending.inner.lock().await; + // Ledger replay: a re-sent close returns the first report. + if let Some(op) = inner.operations.get(continuation_id) { + if op.task_id != task_id || op.message != CLOSE_OP_PAYLOAD { + return continuation_err_report( + task_id, + DelegationError::ContinuationConflict, + None, + None, + ); + } + if let Some(report) = &op.report { + return report.clone(); + } + // Close ops are always recorded settled; defensively fall + // through to normal handling if not. + } + let Some(snap) = inner.sessions.get(task_id).map(|s| { + ( + s.parent_conversation_id, + s.parent_connection_id.clone(), + s.released, + s.status, + s.child_conversation_id, + s.agent_type, + ) + }) else { + if inner.rebuilding { + return continuation_err_report( + task_id, + DelegationError::Rebuilding, + None, + None, + ); + } + return unknown_report(task_id); + }; + let (s_parent_conv, s_parent_conn, s_released, s_status, s_child_conv, s_agent) = snap; + let owned = match parent_conversation_id { + Some(pc) => s_parent_conv == pc, + None => s_parent_conn == parent_connection_id, + }; + if !owned { + return unknown_report(task_id); + } + if s_released { + // Idempotent repeat (2.10): last known status, zero side + // effects — not even a ledger write. + return last_known_report_locked(&inner, task_id, s_status, s_child_conv, s_agent); + } + // A running turn is canceled first (2.9). Two shapes: a committed + // turn sits in `running` (drain it); an in-dispatch continuation + // only has its in-flight record (flag it — the dispatch's S3/S5 + // checkpoints observe the flag and cancel themselves, 2.12). + let teardown = if inner.running.contains_key(task_id) { + drain_and_record_canceled(&mut inner, vec![task_id.to_string()], "session released") + .pop() + } else { + if s_status == TaskStatus::Running { + inner.mark_inflight_canceled_for_task(task_id); + } + None + }; + let was_running = s_status == TaskStatus::Running; + // Release: mark, take the kept connection, leave the FIFO. The + // session entry itself survives (ownership facts stay queryable). + let conn = { + let s = inner + .sessions + .get_mut(task_id) + .expect("session registry entries are never removed in-process"); + s.released = true; + if was_running { + s.status = TaskStatus::Canceled; + } + s.child_connection_id.take() + }; + inner.kept_alive_order.retain(|t| t != task_id); + // The operation ledger follows the session's lease (Requirement + // 7.4 posture): release invalidates this task's continuations. + inner.operations.retain(|_, op| op.task_id != task_id); + let report = if was_running { + report_from_outcome( + Some(task_id.to_string()), + Some(s_agent), + &canceled_outcome(s_child_conv, "session released"), + None, + ) + } else { + last_known_report_locked(&inner, task_id, s_status, s_child_conv, s_agent) + }; + inner.operations.insert( + continuation_id.to_string(), + OperationRecord { + task_id: task_id.to_string(), + message: CLOSE_OP_PAYLOAD.to_string(), + report: Some(report.clone()), + }, + ); + // An in-dispatch continuation owns its connection's fate (its + // cancel checkpoints tear it down) — don't double-disconnect. + let conn_to_disconnect = if was_running && teardown.is_none() { + None + } else { + conn + }; + (report, conn_to_disconnect, teardown) + }; + // Slow I/O out of lock. The canceled-turn teardown is backgrounded + // (same posture as `cancel_by_parent_turn`) so close stays responsive + // even against a slow agent — the release is already committed. + if let Some((task, duration_ms)) = teardown { + let broker = self.clone(); + tokio::spawn(async move { + broker + .teardown_canceled_child(&task, duration_ms, true) + .await; + }); + } + if let Some(cid) = conn_to_disconnect { + if let Err(e) = self.spawner.disconnect(&cid).await { + // The child process may still be alive: surface it (observable + // `orphan_suspect`) and retry in the background. The + // `--parent-pid` watchdog is the final backstop. + tracing::warn!( + "[delegation] release disconnect failed for {cid}: {e}; \ + marking orphan_suspect and scheduling a retry" + ); + { + let mut inner = self.pending.inner.lock().await; + if let Some(s) = inner.sessions.get_mut(task_id) { + s.orphan_suspect = true; + } + } + let spawner = self.spawner.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = spawner.disconnect(&cid).await; + }); + } + } + self.result_notify.notify_waiters(); + report + } + + /// Backs the user-side `get_continuation_availability` query (design §D4): + /// project one child session onto the five-tier verdict the Sub Agent + /// Session Dialog uses to enable/disable its input. Located by the child + /// CONVERSATION id (D5 — the only stable handle the user side holds). + /// + /// The verdict is advisory: the actual `continue_delegation` re-validates + /// everything under the pending lock, so a stale answer here can only cost + /// the user one rejected attempt, never a wrong dispatch. An id with no + /// session entry (never delegated, startup rebuild filtered it out, or + /// rebuild still in flight) folds into `NotContinuable` — one verdict for + /// "unknown" and "cannot continue", so existence is never disclosed. + pub async fn get_continuation_availability( + &self, + child_conversation_id: i32, + ) -> ContinuationAvailability { + // Snapshot under the lock; the (slow) spawner probes run after release. + let snap = { + let inner = self.pending.inner.lock().await; + inner + .sessions + .iter() + .find(|(_, s)| s.child_conversation_id == child_conversation_id) + .map(|(task_id, s)| { + ( + task_id.clone(), + s.released, + s.status, + s.child_connection_id.clone(), + s.external_id.clone(), + ) + }) + }; + let Some((task_id, released, status, conn_id, cached_external)) = snap else { + return ContinuationAvailability::NotContinuable; + }; + if released { + return ContinuationAvailability::Released; + } + if status == TaskStatus::Running { + return ContinuationAvailability::Running; + } + if let Some(cid) = conn_id.as_deref() { + if self.spawner.is_alive(cid).await { + return ContinuationAvailability::ContinuableLive; + } + } + // Connection dead/absent: a resume needs a credential. The session + // cache is lazily filled, so fall back to the child conversation row + // (the credential's true home) before concluding "none". + let has_credential = cached_external.is_some() + || self + .status_lookup + .find_by_call_id(&task_id) + .await + .and_then(|rec| rec.external_id) + .is_some(); + if !has_credential { + return ContinuationAvailability::NotContinuable; + } + // D3.1: the agent self-reported its continuation capability on + // `initialize`. If the dead connection's state is still around and + // says neither `session/load` nor resume is supported, the process + // death lost the context for good (LiveOnly agent). Unknown state + // (already removed) stays optimistic — the resume attempt itself + // reports `resume_unavailable` when the capability was truly absent. + if let Some(cid) = conn_id.as_deref() { + if let Some(cap) = self.spawner.continuation_capability(cid).await { + if !cap.supports_resume && !cap.supports_load_session { + return ContinuationAvailability::NotContinuable; + } + } + } + ContinuationAvailability::ContinuableResume + } + + /// Requirement 1.4a: the parent CONVERSATION was deleted — the owner is + /// gone for good, so every child session of that conversation is released: + /// running turns are canceled + torn down, kept-alive connections are + /// disconnected, and further continuation is refused. This is the ONLY + /// parent-side event that frees kept-alive children; a mere parent + /// CONNECTION teardown does not (Requirement 1.4 / R2-B6). + pub async fn release_children_of_parent_conversation(&self, parent_conversation_id: i32) { + let (kept_conns, drained) = { + let mut inner = self.pending.inner.lock().await; + let task_ids: Vec = inner + .sessions + .iter() + .filter(|(_, s)| s.parent_conversation_id == parent_conversation_id) + .map(|(k, _)| k.clone()) + .collect(); + // Cancel running turns first (atomic running → completed). + let running_keys: Vec = task_ids + .iter() + .filter(|t| inner.running.contains_key(t.as_str())) + .cloned() + .collect(); + let drained = + drain_and_record_canceled(&mut inner, running_keys, "parent conversation deleted"); + // Release every session: mark, take kept connections, clear the + // FIFO slots and this conversation's operation-ledger entries. + let mut kept_conns = Vec::new(); + for t in &task_ids { + // In-dispatch continuations get their in-flight record flagged + // so their own checkpoints cancel them (same seam as close). + inner.mark_inflight_canceled_for_task(t); + if let Some(s) = inner.sessions.get_mut(t.as_str()) { + s.released = true; + if s.status == TaskStatus::Running { + s.status = TaskStatus::Canceled; + } + if let Some(cid) = s.child_connection_id.take() { + kept_conns.push(cid); + } + } + inner.kept_alive_order.retain(|k| k != t); + inner.operations.retain(|_, op| &op.task_id != t); + } + // Drained running tasks already had their connection taken by + // `settle_session(.., None)` inside the drain — their teardown + // below handles the disconnect; don't double-free. + let drained_conns: Vec = drained + .iter() + .map(|(t, _)| t.child_connection_id.clone()) + .collect(); + kept_conns.retain(|c| !drained_conns.contains(c)); + (kept_conns, drained) + }; + for (task, duration_ms) in drained { + self.teardown_canceled_child(&task, duration_ms, true).await; + } + for cid in kept_conns { + let _ = self.spawner.disconnect(&cid).await; + } + self.result_notify.notify_waiters(); + } + + /// Startup rebuild protocol (Requirement 7.1-7.3): reconstruct the + /// continuable session index from persisted child conversation rows. + /// Rules: + /// * a row whose parent conversation is gone / dangling never enters the + /// index (ownership validation; observable via the warn) — and never + /// aborts the rest of the scan (single-row isolation); + /// * turn history and the operation ledger do NOT cross restarts — a + /// rebuilt session starts at `turn_version 0` with an empty ledger; + /// * a LIVE in-memory entry always wins over the DB reconstruction (a + /// second call must not clobber kept-alive state); + /// * while the scan runs, session-misses answer the retryable + /// `rebuilding` code (Requirement 7.2). + pub async fn rebuild_sessions_from_db(&self) { + { + let mut inner = self.pending.inner.lock().await; + inner.rebuilding = true; + } + let candidates = self.status_lookup.list_rebuildable().await; + let mut inner = self.pending.inner.lock().await; + for c in candidates { + let Some(parent_conversation_id) = c.parent_conversation_id else { + tracing::warn!( + "[delegation] rebuild: child conversation {} has no parent; not continuable", + c.child_conversation_id + ); + continue; + }; + if !c.parent_alive { + tracing::warn!( + "[delegation] rebuild: parent conversation {} of child {} is gone; not continuable", + parent_conversation_id, + c.child_conversation_id + ); + continue; + } + if c.task_id.trim().is_empty() { + tracing::warn!( + "[delegation] rebuild: child conversation {} carries an empty delegation_call_id; skipped", + c.child_conversation_id + ); + continue; + } + if inner.sessions.contains_key(&c.task_id) { + continue; + } + inner.sessions.insert( + c.task_id.clone(), + SessionEntry { + parent_conversation_id, + // No live run lease after a restart; ownership checks fall + // back to the (persistent) parent conversation id. + parent_connection_id: String::new(), + // A missing tool_use_id degrades to the synthetic shape, + // which the meta/event writers already skip. + parent_tool_use_id: c + .parent_tool_use_id + .clone() + .unwrap_or_else(|| format!("delegation-{}", c.task_id)), + agent_type: c.agent_type, + child_conversation_id: c.child_conversation_id, + child_connection_id: None, + status: c.status, + released: false, + orphan_suspect: false, + turn_version: 0, + turns: VecDeque::new(), + folder_id: Some(c.folder_id), + external_id: c.external_id.clone(), + working_dir: c.working_dir.clone(), + }, + ); + } + inner.rebuilding = false; + } + + /// DB status fallback for a task evicted from / never in the in-memory maps. + /// Scopes to the caller's conversation: a child whose `parent_id` doesn't + /// match (or when the caller has no active conversation) reports `Unknown`. + async fn status_from_db( + &self, + parent_conversation_id: Option, + task_id: &str, + ) -> DelegationTaskReport { + match self.status_lookup.find_by_call_id(task_id).await { + Some(rec) + if parent_conversation_id.is_some() && rec.parent_id == parent_conversation_id => + { + db_report(task_id, &rec) + } + _ => unknown_report(task_id), + } + } + + /// Test-only shim preserving the old blocking `handle_request` contract over + /// the async path: start the delegation, then block until it reaches a + /// terminal state (driven by the test's `complete_call` / cancel), mapping + /// the terminal report back to a `DelegationOutcome`. Keeps the broker's + /// extensive setup-window race tests exercising the same lifecycle without + /// each rewriting to the start/poll/collect shape. #[cfg(any(test, feature = "test-utils"))] - pub async fn pending_count(&self) -> usize { - self.pending.inner.lock().await.running.len() + pub async fn handle_request(&self, req: DelegationRequest) -> DelegationOutcome { + let parent_connection_id = req.parent_connection_id.clone(); + let parent_conversation_id = Some(req.parent_conversation_id); + let ack = self.start_delegation(req).await; + let task_id = match ack.task_id.clone() { + Some(id) => id, + // Setup failed before a task existed — the ack itself is terminal. + None => return report_to_outcome(&ack), + }; + if ack.status != TaskStatus::Running { + return report_to_outcome(&ack); + } + // Block until terminal via the long-poll path (re-issued so an + // indefinitely-pending task in a test simply parks here, mirroring the + // old unbounded `rx.await`). + loop { + let report = self + .get_task_status( + &parent_connection_id, + parent_conversation_id, + &task_id, + StatusWait::Bounded(3_600_000), + ) + .await; + if report.status != TaskStatus::Running { + return report_to_outcome(&report); + } + } + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn peek_first_pending_call_id(&self) -> Option { + self.pending + .inner + .lock() + .await + .running + .keys() + .next() + .cloned() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn pending_count(&self) -> usize { + self.pending.inner.lock().await.running.len() + } + + /// Count of cached completed results across all parents. + #[cfg(any(test, feature = "test-utils"))] + pub async fn completed_count(&self) -> usize { + self.pending.inner.lock().await.completed.len() + } + + /// Count of in-flight (registered-at-entry, not-yet-parked / not-yet-exited) + /// `handle_request` setups. Should return to 0 on every exit path. + #[cfg(any(test, feature = "test-utils"))] + pub async fn inflight_count(&self) -> usize { + self.pending.inner.lock().await.inflight.len() + } + + /// Count of in-setup (reserved, not-yet-parked) delegations. Each holds one + /// child and one call_id, so this counts both. + #[cfg(any(test, feature = "test-utils"))] + pub async fn reserved_child_count(&self) -> usize { + self.pending.inner.lock().await.setups.len() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn reserved_call_count(&self) -> usize { + self.pending.inner.lock().await.setups.len() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn early_cancel_count(&self) -> usize { + self.pending.inner.lock().await.early_cancels.len() + } + + #[cfg(any(test, feature = "test-utils"))] + pub async fn early_complete_count(&self) -> usize { + self.pending.inner.lock().await.early_completes.len() + } + + /// First reserved (mid-setup) `call_id`, if any — lets a test resolve a + /// delegation via `complete_call` while it's pinned in the reserve→park + /// window (its entry isn't parked yet, so `peek_first_pending_call_id` + /// can't see it). + #[cfg(any(test, feature = "test-utils"))] + pub async fn peek_reserved_call_id(&self) -> Option { + self.pending + .inner + .lock() + .await + .setups + .keys() + .next() + .cloned() + } +} + +/// `ConversationDepthLookup` over the live `AppDatabase`. Used by the +/// production wiring; tests use the in-module `MockDepth`. +pub struct DbDepthLookup { + pub db: Arc, +} + +#[async_trait] +impl ConversationDepthLookup for DbDepthLookup { + async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError> { + use sea_orm::EntityTrait; + let row = crate::db::entities::conversation::Entity::find_by_id(conversation_id) + .one(&self.db.conn) + .await + .map_err(|e| DelegationError::SubagentRuntimeError(format!("db: {e}")))?; + Ok(row.and_then(|r| r.parent_id)) + } +} + +/// `ChildStatusLookup` over the live `AppDatabase`. Recovers a delegation +/// task's terminal status (NOT its text — child output isn't in codeg's DB) +/// from the child conversation row once its in-memory result was evicted. +pub struct DbChildStatusLookup { + pub db: Arc, +} + +#[async_trait] +impl ChildStatusLookup for DbChildStatusLookup { + async fn find_by_call_id(&self, call_id: &str) -> Option { + let summary = crate::db::service::conversation_service::get_by_delegation_call_id( + &self.db.conn, + call_id, + ) + .await + .ok() + .flatten()?; + // `summary.status` is the serialized `ConversationStatus` string. + let status = match summary.status.as_str() { + "in_progress" => TaskStatus::Running, + "pending_review" | "completed" => TaskStatus::Completed, + "cancelled" => TaskStatus::Canceled, + _ => TaskStatus::Unknown, + }; + // Resolve the workspace path for a resume spawn (folder path). Missing + // folder rows degrade to `None` — the continuation then falls back to + // the parent connection's working_dir inside `spawn_for_resume`. + let working_dir = + crate::db::service::folder_service::get_folder_by_id(&self.db.conn, summary.folder_id) + .await + .ok() + .flatten() + .map(|f| f.path); + Some(ChildStatusRecord { + child_conversation_id: summary.id, + status, + agent_type: summary.agent_type, + parent_id: summary.parent_id, + folder_id: summary.folder_id, + external_id: summary.external_id.clone(), + working_dir, + }) + } + + async fn external_id_ref_count(&self, external_id: &str) -> usize { + use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; + crate::db::entities::conversation::Entity::find() + .filter(crate::db::entities::conversation::Column::ExternalId.eq(external_id)) + .filter(crate::db::entities::conversation::Column::DeletedAt.is_null()) + .count(&self.db.conn) + .await + // A failed count must not silently authorize a resume: report the + // ambiguous-side value so the caller refuses (fail closed). + .map(|n| n as usize) + .unwrap_or(usize::MAX) + } + + async fn list_rebuildable(&self) -> Vec { + use crate::db::entities::conversation; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let rows = match conversation::Entity::find() + .filter(conversation::Column::Kind.eq(conversation::ConversationKind::Delegate)) + .filter(conversation::Column::DeletedAt.is_null()) + .filter(conversation::Column::ExternalId.is_not_null()) + .filter(conversation::Column::DelegationCallId.is_not_null()) + .all(&self.db.conn) + .await + { + Ok(rows) => rows, + Err(e) => { + // An unreadable table yields an empty rebuild — sessions fall + // back to `Unknown` (queryable via the DB status path once the + // DB recovers). Logged, not swallowed silently. + tracing::warn!("[delegation] rebuild scan failed: {e}"); + return Vec::new(); + } + }; + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + // Ownership validation: the parent row must exist and not be + // soft-deleted. Resolved here so the broker stays storage-agnostic. + let parent_alive = match row.parent_id { + Some(pid) => conversation::Entity::find_by_id(pid) + .filter(conversation::Column::DeletedAt.is_null()) + .one(&self.db.conn) + .await + .ok() + .flatten() + .is_some(), + None => false, + }; + let status = match row.status { + conversation::ConversationStatus::InProgress => TaskStatus::Running, + conversation::ConversationStatus::PendingReview + | conversation::ConversationStatus::Completed => TaskStatus::Completed, + conversation::ConversationStatus::Cancelled => TaskStatus::Canceled, + }; + let working_dir = + crate::db::service::folder_service::get_folder_by_id(&self.db.conn, row.folder_id) + .await + .ok() + .flatten() + .map(|f| f.path); + let agent_type = match serde_json::from_value::(serde_json::Value::String( + row.agent_type.clone(), + )) { + Ok(at) => at, + Err(_) => { + tracing::warn!( + "[delegation] rebuild: child conversation {} has unknown agent_type {:?}; skipped", + row.id, + row.agent_type + ); + continue; + } + }; + out.push(RebuildCandidate { + task_id: row.delegation_call_id.clone().unwrap_or_default(), + child_conversation_id: row.id, + parent_conversation_id: row.parent_id, + parent_alive, + parent_tool_use_id: row.parent_tool_use_id.clone(), + agent_type, + status, + folder_id: row.folder_id, + external_id: row.external_id.clone(), + working_dir, + }); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::spawner::{mock::MockSpawner, SpawnerError}; + use crate::acp::delegation::types::DelegationSuccess; + use crate::models::AgentType; + + /// Test-only `ConversationDepthLookup` that resolves against a flat + /// (id, parent_id) table. Unknown ids return `Ok(None)` to keep test + /// setup small. + struct MockDepth(Vec<(i32, Option)>); + + #[async_trait] + impl ConversationDepthLookup for MockDepth { + async fn parent_of(&self, id: i32) -> Result, DelegationError> { + Ok(self.0.iter().find(|(c, _)| *c == id).and_then(|(_, p)| *p)) + } + } + + fn shallow_lookup() -> Arc { + // parent conversation is the root — depth = 0, no rejection. + Arc::new(MockDepth(vec![(1, None)])) as Arc + } + + fn request(parent_conv: i32, tool_use: &str) -> DelegationRequest { + DelegationRequest { + parent_connection_id: "parent-conn".into(), + parent_conversation_id: parent_conv, + parent_tool_use_id: tool_use.into(), + agent_type: AgentType::ClaudeCode, + task: "do x".into(), + working_dir: None, + requested_working_dir: None, + external_handle: None, + } + } + + fn request_with_handle(parent_conv: i32, tool_use: &str, handle: &str) -> DelegationRequest { + let mut r = request(parent_conv, tool_use); + r.external_handle = Some(handle.to_string()); + r + } + + /// Bring the broker's `enabled` switch up before driving any test that + /// hits `handle_request`. Production now defaults to `enabled: false`, + /// so a bare `DelegationBroker::new(...)` would short-circuit before + /// parking a pending entry. Tests that assert disabled behavior set + /// their own config explicitly and skip this helper. + async fn enable_delegation(broker: &DelegationBroker) { + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + } + + // -- Task 4.3 ----------------------------------------------------------- + + #[tokio::test] + async fn config_round_trip() { + let broker = DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + shallow_lookup(), + ); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 5, + ..DelegationConfig::default() + }) + .await; + let got = broker.config_snapshot().await; + assert!(!got.enabled); + assert_eq!(got.depth_limit, 5); + } + + #[tokio::test] + async fn disabled_returns_canceled_without_touching_spawner() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: false, + depth_limit: 2, + ..DelegationConfig::default() + }) + .await; + let outcome = broker.handle_request(request(1, "pt-1")).await; + match outcome { + DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), + _ => panic!("expected Err"), + } + assert!(mock.disconnects.lock().await.is_empty()); + } + + // -- Task 4.4: happy path ---------------------------------------------- + + #[tokio::test] + async fn happy_path_returns_ok_after_complete_call() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".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-1")).await }) + }; + + // Spin until the broker has registered the pending call so the test + // doesn't race the spawn/send awaits. + let call_id = loop { + if let Some(id) = broker.peek_first_pending_call_id().await { + break id; + } + tokio::time::sleep(Duration::from_millis(5)).await; + }; + + broker + .complete_call( + &call_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "4".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 50, + token_usage: None, + }), + ) + .await; + + let outcome = driver.await.unwrap(); + match outcome { + DelegationOutcome::Ok(s) => { + assert_eq!(s.text, "4"); + assert_eq!(s.child_conversation_id, 42); + } + other => panic!("expected Ok, got {other:?}"), + } + assert_eq!(broker.pending_count().await, 0); + // Completed keeps the child process for continue_with_session; only + // cancel-coded outcomes (and close_session) tear it down. + assert!( + mock.disconnects.lock().await.is_empty(), + "complete_call must not disconnect on success" + ); + } + + /// Property 3 (terminal-outcome routing): a FAILED outcome — any wire code + /// other than `"canceled"` — keeps the child connection alive exactly like + /// `Completed`, so `continue_with_session` can retry / follow up with the + /// child's context intact. Only cancel-coded outcomes tear the child down. + #[tokio::test] + async fn failed_outcome_keeps_child_connection_alive() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-fail-keep".into())).await; + mock.queue_send(Ok(43)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + + let ack = broker.start_delegation(request(1, "pt-1")).await; + assert_eq!(ack.status, TaskStatus::Running); + let task_id = ack.task_id.expect("running task carries an id"); + + broker + .complete_call( + &task_id, + DelegationOutcome::from_err(DelegationError::ChildRefusal, Some(43)), + ) + .await; + + let report = broker + .get_task_status("parent-conn", Some(1), &task_id, StatusWait::Immediate) + .await; + assert_eq!(report.status, TaskStatus::Failed); + assert!( + mock.disconnects.lock().await.is_empty(), + "a failed (non-cancel) outcome must keep the child alive" + ); + } + + /// Property 3 counterpart: a cancel-coded outcome reaching the terminal + /// path STILL disconnects the child — the parent abandoned the turn, so + /// nothing may keep its process around. + #[tokio::test] + async fn canceled_outcome_still_disconnects_child() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-cancel-drop".into())).await; + mock.queue_send(Ok(44)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + + let ack = broker.start_delegation(request(1, "pt-1")).await; + let task_id = ack.task_id.expect("running task carries an id"); + + broker + .complete_call( + &task_id, + DelegationOutcome::from_err( + DelegationError::Canceled { + reason: "test cancel".into(), + }, + Some(44), + ), + ) + .await; + + let report = broker + .get_task_status("parent-conn", Some(1), &task_id, StatusWait::Immediate) + .await; + assert_eq!(report.status, TaskStatus::Canceled); + assert_eq!( + mock.disconnects.lock().await.as_slice(), + &["c-cancel-drop"], + "a cancel-coded outcome must still tear the child down" + ); + } + + /// D2 three-layer split: the session registry (domain layer) records + /// ownership + the kept-alive connection independently of the + /// completed-cache (text layer). Dropping the parent's completed-cache + /// entries must NOT lose the session's keep-alive facts. + #[tokio::test] + async fn settled_session_survives_completed_cache_drop() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("c-keep".into())).await; + mock.queue_send(Ok(51)).await; + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + enable_delegation(&broker).await; + + let ack = broker.start_delegation(request(1, "pt-1")).await; + let task_id = ack.task_id.expect("running task carries an id"); + broker + .complete_call( + &task_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "first".into(), + child_conversation_id: 51, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + + // Simulate a full text-cache eviction for this parent. + { + let mut inner = broker.pending.inner.lock().await; + inner.drop_completed_for_parent("parent-conn"); + let s = inner + .sessions + .get(&task_id) + .expect("session must survive the cache drop"); + assert_eq!(s.parent_conversation_id, 1); + assert_eq!(s.parent_connection_id, "parent-conn"); + assert_eq!(s.parent_tool_use_id, "pt-1"); + assert_eq!(s.agent_type, AgentType::ClaudeCode); + assert_eq!(s.child_conversation_id, 51); + assert_eq!(s.status, TaskStatus::Completed); + assert_eq!( + s.child_connection_id.as_deref(), + Some("c-keep"), + "keep-alive connection must live on the session, not the text cache" + ); + } + + // A canceled turn instead clears the session's connection. + mock.queue_spawn(Ok("c-drop".into())).await; + mock.queue_send(Ok(52)).await; + let ack2 = broker.start_delegation(request(1, "pt-2")).await; + let task2 = ack2.task_id.expect("running task carries an id"); + broker + .cancel_task_by_id("parent-conn", Some(1), &task2) + .await; + let inner = broker.pending.inner.lock().await; + let s2 = inner.sessions.get(&task2).expect("session exists"); + assert_eq!(s2.status, TaskStatus::Canceled); + assert!( + s2.child_connection_id.is_none(), + "cancel must clear the session's connection" + ); + } + + /// Drive one full delegation to a Completed settle. Consumes one queued + /// `(spawn, send)` pair from the mock and returns the task id. + async fn settle_one( + broker: &DelegationBroker, + mock: &MockSpawner, + child_conn: &str, + child_conv: i32, + tool_use: &str, + ) -> String { + mock.queue_spawn(Ok(child_conn.into())).await; + mock.queue_send(Ok(child_conv)).await; + let ack = broker.start_delegation(request(1, tool_use)).await; + let task_id = ack.task_id.expect("running task carries an id"); + broker + .complete_call( + &task_id, + DelegationOutcome::Ok(DelegationSuccess { + text: format!("result of {child_conn}"), + child_conversation_id: child_conv, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + task_id + } + + /// Property 1 (connection conservation), count-eviction path: when the + /// number of kept-alive settled connections exceeds `kept_alive_cap`, the + /// OLDEST settled connection is handed to `disconnect` (never silently + /// dropped), its session's connection pointer is cleared, and the task's + /// result text survives so a later continuation can go through the resume + /// path (Requirements 5.4 / 5.6). + #[tokio::test] + async fn kept_alive_cap_fifo_evicts_oldest_settled_connection() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + kept_alive_cap: 2, + ..DelegationConfig::default() + }) + .await; + + let t0 = settle_one(&broker, &mock, "c-cap-0", 60, "pt-cap-0").await; + let t1 = settle_one(&broker, &mock, "c-cap-1", 61, "pt-cap-1").await; + assert!( + mock.disconnects.lock().await.is_empty(), + "under the cap nothing is evicted" + ); + + // Third settle exceeds cap=2 → the FIRST kept connection is evicted. + let _t2 = settle_one(&broker, &mock, "c-cap-2", 62, "pt-cap-2").await; + assert_eq!( + mock.disconnects.lock().await.as_slice(), + &["c-cap-0"], + "the oldest settled connection must be handed to disconnect" + ); + + let inner = broker.pending.inner.lock().await; + let s0 = inner.sessions.get(&t0).expect("session survives eviction"); + assert!( + s0.child_connection_id.is_none(), + "evicted connection must be cleared from the session" + ); + assert_eq!( + s0.status, + TaskStatus::Completed, + "eviction is a resource action, not a domain-status change" + ); + assert!( + inner.completed.contains_key(&t0), + "result text must survive connection eviction (Requirement 5.6)" + ); + let s1 = inner.sessions.get(&t1).expect("session exists"); + assert!( + s1.child_connection_id.is_some(), + "younger kept connections stay alive" + ); + } + + /// Property 1, per-parent scope (Requirement 5.5): the cap is enforced per + /// parent conversation as well. Exercised at the `PendingInner` level with + /// a per-parent cap tighter than the global one, because with equal values + /// the global check always fires first (parent count <= global count). + #[tokio::test] + async fn kept_alive_per_parent_cap_evicts_within_that_parent() { + let mut inner = PendingInner { + kept_alive_cap_global: 10, + kept_alive_cap_per_parent: 1, + ..Default::default() + }; + let entry = |parent: i32, conv: i32| SessionEntry { + parent_conversation_id: parent, + parent_connection_id: format!("pc-{parent}"), + parent_tool_use_id: "pt".into(), + agent_type: AgentType::ClaudeCode, + child_conversation_id: conv, + child_connection_id: Some(format!("conn-{conv}")), + status: TaskStatus::Running, + released: false, + orphan_suspect: false, + turn_version: 1, + turns: VecDeque::new(), + folder_id: None, + external_id: None, + working_dir: None, + }; + inner.sessions.insert("a1".into(), entry(1, 11)); + inner.sessions.insert("a2".into(), entry(1, 12)); + inner.sessions.insert("b1".into(), entry(2, 21)); + + let e1 = inner.settle_session("a1", TaskStatus::Completed, Some("conn-11".into())); + assert!(e1.is_empty(), "first kept conn of parent 1 fits"); + let e2 = inner.settle_session("b1", TaskStatus::Completed, Some("conn-21".into())); + assert!(e2.is_empty(), "parent 2 has its own budget"); + // Second kept conn for parent 1 breaches its per-parent cap of 1: the + // OLDER one of THAT parent is evicted; parent 2 is untouched. + let e3 = inner.settle_session("a2", TaskStatus::Completed, Some("conn-12".into())); + assert_eq!( + e3, + vec!["conn-11".to_string()], + "per-parent eviction must pick the oldest of the same parent" + ); + assert!(inner + .sessions + .get("a1") + .unwrap() + .child_connection_id + .is_none()); + assert!(inner + .sessions + .get("b1") + .unwrap() + .child_connection_id + .is_some()); + } + + /// Byte-valve counterpart of Property 1: evicting a task's TEXT from the + /// completed-cache must NOT touch its kept-alive connection — the session + /// still owns it (D2 decoupling), so nothing leaks and nothing is killed + /// by a text-budget decision. + #[tokio::test] + async fn byte_eviction_keeps_connection_on_session() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + // Tiny text budget: the second result evicts the first's text. + completed_cache_cap_bytes: 20, + ..DelegationConfig::default() + }) + .await; + + let t0 = settle_one(&broker, &mock, "c-byte-0", 70, "pt-byte-0").await; + let _t1 = settle_one(&broker, &mock, "c-byte-1", 71, "pt-byte-1").await; + + let inner = broker.pending.inner.lock().await; + assert!( + !inner.completed.contains_key(&t0), + "precondition: the first task's text was byte-evicted" + ); + assert_eq!( + inner + .sessions + .get(&t0) + .and_then(|s| s.child_connection_id.as_deref()), + Some("c-byte-0"), + "text eviction must not clear the session's kept connection" + ); + assert!( + mock.disconnects.lock().await.is_empty(), + "a text-budget decision must never kill a child process" + ); + } + + // -- Task 5.2: get_continuation_availability (design §D4) --------------- + + /// An id the broker has never seen folds into `NotContinuable` — the same + /// verdict as "cannot continue", so the query discloses nothing about + /// whether a conversation exists. + #[tokio::test] + async fn availability_unknown_child_is_not_continuable() { + let mock = Arc::new(MockSpawner::new()); + let broker = DelegationBroker::new(mock as Arc, shallow_lookup()); + assert_eq!( + broker.get_continuation_availability(424_242).await, + ContinuationAvailability::NotContinuable + ); + } + + /// The Running / ContinuableLive / Released tiers, driven through the + /// real lifecycle: an in-flight turn reports `Running`, a settled task + /// with its kept-alive connection reports `ContinuableLive`, and a closed + /// (released) session reports `Released`. + #[tokio::test] + async fn availability_running_live_and_released_tiers() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + + // Running: dispatched, not yet terminal. + mock.queue_spawn(Ok("c-av-run".into())).await; + mock.queue_send(Ok(80)).await; + let ack = broker.start_delegation(request(1, "pt-av-run")).await; + let running_task = ack.task_id.expect("running task id"); + assert_eq!( + broker.get_continuation_availability(80).await, + ContinuationAvailability::Running + ); + + // ContinuableLive: settled with the kept connection still alive. + broker + .complete_call( + &running_task, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: 80, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + assert_eq!( + broker.get_continuation_availability(80).await, + ContinuationAvailability::ContinuableLive + ); + + // Released: close_session retires it. + broker + .close_delegation_session("parent-conn", Some(1), &running_task, "cid-av-close") + .await; + assert_eq!( + broker.get_continuation_availability(80).await, + ContinuationAvailability::Released + ); + } + + /// Dead connection + resume credential (from the DB row — the session + /// cache is lazily filled) → `ContinuableResume`. The capability of the + /// dead connection is unknown to the mock (no entry), which must stay + /// optimistic rather than blocking the resume path. + #[tokio::test] + async fn availability_dead_connection_with_credential_is_resume() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(81)))); + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + let t = settle_one(&broker, &mock, "c-av-dead", 81, "pt-av-dead").await; + let _ = t; + mock.mark_dead("c-av-dead").await; + assert_eq!( + broker.get_continuation_availability(81).await, + ContinuationAvailability::ContinuableResume + ); + } + + /// Dead connection + NO resume credential anywhere (session cache empty, + /// DB lookup answers nothing) → `NotContinuable` (Requirement 3.2's + /// query-side mirror). + #[tokio::test] + async fn availability_dead_connection_without_credential_is_not_continuable() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + settle_one(&broker, &mock, "c-av-nocred", 82, "pt-av-nocred").await; + mock.mark_dead("c-av-nocred").await; + assert_eq!( + broker.get_continuation_availability(82).await, + ContinuationAvailability::NotContinuable + ); + } + + /// D3.1 capability consumption: the agent explicitly self-reported that it + /// supports neither `session/load` nor resume (LiveOnly). Once its process + /// is dead the context is gone for good — even with a stored credential + /// the verdict must be `NotContinuable`, not a doomed resume invitation. + #[tokio::test] + async fn availability_live_only_agent_after_death_is_not_continuable() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(83)))); + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + settle_one(&broker, &mock, "c-av-liveonly", 83, "pt-av-liveonly").await; + mock.set_capability( + "c-av-liveonly", + crate::acp::delegation::spawner::AgentContinuationCapability { + supports_load_session: false, + supports_resume: false, + }, + ) + .await; + mock.mark_dead("c-av-liveonly").await; + assert_eq!( + broker.get_continuation_availability(83).await, + ContinuationAvailability::NotContinuable + ); + + // Control: the same shape with resume capability stays continuable. + mock.set_capability( + "c-av-liveonly", + crate::acp::delegation::spawner::AgentContinuationCapability { + supports_load_session: true, + supports_resume: true, + }, + ) + .await; + assert_eq!( + broker.get_continuation_availability(83).await, + ContinuationAvailability::ContinuableResume + ); + } + + // -- Task 3.5/3.6: continue_delegation --------------------------------- + + /// Static `ChildStatusLookup` serving the folder / resume metadata a + /// continuation would otherwise read from the child conversation row. + struct StaticStatusLookup(ChildStatusRecord); + + #[async_trait] + impl ChildStatusLookup for StaticStatusLookup { + async fn find_by_call_id(&self, _call_id: &str) -> Option { + Some(self.0.clone()) + } + } + + /// Standard status record for continue tests: folder 7, matching child + /// conversation, resumable credential. + fn continue_status_record(child_conversation_id: i32) -> ChildStatusRecord { + ChildStatusRecord { + child_conversation_id, + status: TaskStatus::Completed, + agent_type: AgentType::ClaudeCode, + parent_id: Some(1), + folder_id: 7, + external_id: Some("sess-resume".into()), + working_dir: Some("/work".into()), + } + } + + /// Forwarding spawner that gates `send_followup_prompt` so a test can pin + /// `continue_delegation` INSIDE its dispatch window (in-flight registered, + /// `running` not yet re-inserted) and land a parent cancel there — the R3 + /// race. All other methods forward to the inner `MockSpawner`. Test-module + /// local on purpose: the trait's mock lives in `spawner.rs`, which this + /// task must not modify. + struct GatedFollowupSpawner { + inner: Arc, + entered_tx: Mutex>>, + gate: Mutex>>, + } + + #[async_trait] + impl ConnectionSpawner for GatedFollowupSpawner { + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.inner + .spawn( + parent_connection_id, + agent_type, + working_dir, + preferred_mode_id, + preferred_config_values, + ) + .await + } + + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: crate::acp::delegation::spawner::DelegationLink, + ) -> Result { + self.inner + .send_prompt_linked_for_delegation(conn_id, task, link) + .await + } + + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.inner.cancel(conn_id).await + } + + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.inner.disconnect(conn_id).await + } + + async fn spawn_for_resume( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.inner + .spawn_for_resume( + parent_connection_id, + agent_type, + working_dir, + session_id, + preferred_mode_id, + preferred_config_values, + ) + .await + } + + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), SpawnerError> { + if let Some(tx) = self.entered_tx.lock().await.take() { + let _ = tx.send(()); + } + let gate = self.gate.lock().await.take(); + if let Some(gate) = gate { + let _ = gate.await; + } + self.inner + .send_followup_prompt(conn_id, message, conversation_id, folder_id) + .await + } + + async fn is_alive(&self, conn_id: &str) -> bool { + self.inner.is_alive(conn_id).await + } + } + + /// Broker wired for continue tests: mock spawner + static status lookup. + fn continue_broker(mock: Arc, child_conv: i32) -> DelegationBroker { + DelegationBroker::new(mock as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record( + child_conv, + )))) + } + + /// Property 4 (task_id stability) + Requirement 2.3: a continuation on a + /// settled task reuses the kept-alive connection, adopts the existing + /// child row (Branch A shape: conversation_id + folder_id), returns a + /// Running report under the ORIGINAL task id, and the next settle reports + /// the new turn's result under that same id. + #[tokio::test] + async fn continue_on_live_connection_keeps_task_id_and_adopts_row() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "next step please".into(), + "op-1", + TurnOrigin::ParentAgent, + ) + .await; + assert_eq!(cont.status, TaskStatus::Running); + assert_eq!(cont.task_id.as_deref(), Some(task_id.as_str())); + assert_eq!(cont.child_conversation_id, Some(42)); + + let followups = mock.followups.lock().await; + assert_eq!(followups.len(), 1); + assert_eq!(followups[0].conn_id, "c-live"); + assert_eq!(followups[0].message, "next step please"); + assert_eq!(followups[0].conversation_id, 42); + assert_eq!(followups[0].folder_id, 7); + drop(followups); + + // The second settle reports under the SAME task id. + broker + .complete_call( + &task_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "second turn".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + let report = broker + .get_task_status("parent-conn", Some(1), &task_id, StatusWait::Immediate) + .await; + assert_eq!(report.status, TaskStatus::Completed); + assert_eq!(report.text.as_deref(), Some("second turn")); + assert_eq!(report.task_id.as_deref(), Some(task_id.as_str())); + assert!( + mock.disconnects.lock().await.is_empty(), + "both settles kept the child alive" + ); + + // Turn layer (Requirement 8.1): two dispatched turns, monotonic + // version, correct origins, distinct broker-internal turn ids. + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session exists"); + assert_eq!(s.turn_version, 2); + assert_eq!(s.turns.len(), 2); + assert_eq!(s.turns[0].origin, TurnOrigin::ParentAgent); + assert_eq!(s.turns[0].turn_version, 1); + assert_eq!(s.turns[1].origin, TurnOrigin::ParentAgent); + assert_eq!(s.turns[1].turn_version, 2); + assert_ne!(s.turns[0].turn_id, s.turns[1].turn_id); + } + + /// Property 2 (no new rows): a continuation must go through the follow-up + /// channel ONLY. The row-creating first-prompt channel and the spawner's + /// fresh-spawn path must not be touched (their queues are empty — any use + /// fails loudly), and no second spawn call is recorded. + #[tokio::test] + async fn continue_never_touches_the_first_prompt_channel() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "again".into(), + "op-1", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.status, TaskStatus::Running); + // Exactly the one spawn from the ORIGINAL delegation; a live-conn + // continuation neither spawns nor resumes. + assert_eq!(mock.spawn_args.lock().await.len(), 1); + assert!(mock.resume_args.lock().await.is_empty()); + assert_eq!(mock.followups.lock().await.len(), 1); + } + + /// Broker wired for continue-event tests: mock spawner + emitter + static + /// status lookup (T4.3). + fn continue_broker_with_emitter( + mock: Arc, + emitter: Arc, + child_conv: i32, + ) -> DelegationBroker { + DelegationBroker::with_writers( + mock as Arc, + shallow_lookup(), + Arc::new(crate::acp::delegation::meta_writer::mock::MockMetaWriter::new()) + as Arc, + emitter as Arc, + ) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record( + child_conv, + )))) + } + + /// Requirements 2.8 / 2.8a / 8.1 / 8.2 (T4.3): when a CONTINUED turn + /// settles, the broker emits ONE session-scoped update carrying the + /// four-tuple `(task_id, turn_id, turn_version, origin)` — and does NOT + /// re-emit a completion event against the original `parent_tool_use_id` + /// (its tool call already went terminal on turn 1). + #[tokio::test] + async fn continued_turn_settle_emits_session_update_not_second_completion() { + let mock = Arc::new(MockSpawner::new()); + let emitter = + Arc::new(crate::acp::delegation::event_emitter::mock::MockEventEmitter::new()); + let broker = continue_broker_with_emitter(mock.clone(), emitter.clone(), 42); + enable_delegation(&broker).await; + + let task_id = settle_one(&broker, &mock, "c-evt", 42, "pt-evt").await; + assert_eq!(emitter.count().await, 1, "first turn emits its completion"); + assert_eq!(emitter.session_update_count().await, 0); + + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "next".into(), + "op-evt-1", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.status, TaskStatus::Running); + broker + .complete_call( + &task_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "second".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + + assert_eq!( + emitter.count().await, + 1, + "no second completion against the terminal tool call (2.8a)" + ); + let updates = emitter.session_update_snapshot().await; + assert_eq!(updates.len(), 1, "exactly one session-scoped update"); + let u = &updates[0]; + assert_eq!(u.task_id, task_id); + assert_eq!(u.turn_version, 2); + assert_eq!(u.origin, TurnOrigin::User); + assert_eq!(u.child_conversation_id, 42); + assert_eq!(u.parent_connection_id, "parent-conn"); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session exists"); + assert_eq!( + u.turn_id, s.turns[1].turn_id, + "the update carries the CONTINUED turn's broker-internal id" + ); + } + + /// A canceled continued turn is terminal too: the teardown path emits the + /// session-scoped update (with the turn's origin) instead of re-emitting a + /// completion on the settled tool call. + #[tokio::test] + async fn canceled_continued_turn_emits_session_update_not_completion() { + let mock = Arc::new(MockSpawner::new()); + let emitter = + Arc::new(crate::acp::delegation::event_emitter::mock::MockEventEmitter::new()); + let broker = continue_broker_with_emitter(mock.clone(), emitter.clone(), 42); + enable_delegation(&broker).await; + + let task_id = settle_one(&broker, &mock, "c-evt-2", 42, "pt-evt-2").await; + mock.queue_followup(Ok(())).await; + broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "next".into(), + "op-evt-2", + TurnOrigin::ParentAgent, + ) + .await; + broker + .cancel_task_by_id("parent-conn", Some(1), &task_id) + .await; + + assert_eq!( + emitter.count().await, + 1, + "cancel of a continued turn must not re-emit completion (2.8a)" + ); + let updates = emitter.session_update_snapshot().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].turn_version, 2); + assert_eq!(updates[0].origin, TurnOrigin::ParentAgent); + } + + /// Requirement 2.4: continuing a task whose turn is still running is + /// rejected with the stable code `session_still_running`. + #[tokio::test] + async fn continue_while_running_returns_session_still_running() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = start_running(&broker, &mock, "c-run", 42, "pt-1").await; + + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "too early".into(), + "op-1", + TurnOrigin::ParentAgent, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("session_still_running")); + assert!( + mock.followups.lock().await.is_empty(), + "no prompt side effect on rejection" + ); + } + + /// Requirement 2.13: replaying the SAME continuation_id executes the + /// follow-up once and returns the first report to both callers. + #[tokio::test] + async fn continue_same_continuation_id_is_idempotent() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let first = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "do it".into(), + "op-dup", + TurnOrigin::User, + ) + .await; + let replay = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "do it".into(), + "op-dup", + TurnOrigin::User, + ) + .await; + assert_eq!(first.status, TaskStatus::Running); + assert_eq!(replay.status, TaskStatus::Running); + assert_eq!(replay.task_id, first.task_id); + assert_eq!( + mock.followups.lock().await.len(), + 1, + "the follow-up must be sent exactly once" + ); + assert!( + replay.error_code.is_none(), + "a replay is NOT session_still_running — it returns the first report" + ); + } + + /// Same continuation_id with a DIFFERENT payload is a caller bug — reject + /// with `continuation_conflict`, never silently pick either message. + #[tokio::test] + async fn continue_same_continuation_id_conflicting_payload_rejected() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let _first = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "message A".into(), + "op-x", + TurnOrigin::User, + ) + .await; + let conflict = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "message B".into(), + "op-x", + TurnOrigin::User, + ) + .await; + assert_eq!( + conflict.error_code.as_deref(), + Some("continuation_conflict") + ); + assert_eq!(mock.followups.lock().await.len(), 1); + } + + /// Ownership: another parent conversation cannot continue (or even probe) + /// this task — `Unknown`, no existence leak (Requirement 2.6). + #[tokio::test] + async fn continue_from_other_parent_is_unknown() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + let cont = broker + .continue_delegation( + "other-conn", + Some(99), + &task_id, + "mine now".into(), + "op-1", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.status, TaskStatus::Unknown); + assert!(mock.followups.lock().await.is_empty()); + } + + /// R3 race (Requirements 6.1 / 6.2): a parent cancel landing while the + /// continuation is INSIDE its dispatch window (in-flight registered, + /// follow-up prompt in flight, `running` not yet re-inserted) must cancel + /// that continuation turn — the exact window PR #375 left uncovered. + #[tokio::test] + async fn parent_cancel_during_continue_dispatch_cancels_the_turn() { + let mock = Arc::new(MockSpawner::new()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let gated = Arc::new(GatedFollowupSpawner { + inner: mock.clone(), + entered_tx: Mutex::new(Some(entered_tx)), + gate: Mutex::new(Some(release_rx)), + }); + let broker = DelegationBroker::new(gated as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(42)))); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let driver = { + let broker = broker.clone(); + let task_id = task_id.clone(); + tokio::spawn(async move { + broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "racing turn".into(), + "op-race", + TurnOrigin::ParentAgent, + ) + .await + }) + }; + // The continuation is now inside send_followup_prompt: in-flight is + // registered, `running` is NOT. This is the window `cancel_by_parent*` + // could historically miss. + entered_rx.await.expect("continue reached the send"); + broker.cancel_by_parent_turn("parent-conn").await; + let _ = release_tx.send(()); + + let report = driver.await.unwrap(); + assert_eq!( + report.error_code.as_deref(), + Some("canceled"), + "the racing continuation must resolve canceled, not keep running" + ); + // The turn never stays behind in `running`, and the child was torn + // down (cancel semantics), not left as an unmanaged process. + assert_eq!(broker.pending_count().await, 0); + assert!(mock.disconnects.lock().await.iter().any(|c| c == "c-live")); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session exists"); + assert_eq!(s.status, TaskStatus::Canceled); + assert!(s.child_connection_id.is_none()); + } + + // -- Task 3.7: close_delegation_session (release semantics) ------------ + + /// Poll until `pred` holds or ~2s elapse — for assertions on backgrounded + /// teardown I/O. + async fn wait_until(mut pred: F) + where + F: FnMut() -> Fut, + Fut: std::future::Future, + { + for _ in 0..200 { + if pred().await { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("condition not reached within the polling budget"); + } + + /// Requirements 2.7 / 5.3 + release semantics: close disconnects the kept + /// child, marks the session `released` (NOT a persistent "closed"), and a + /// later continuation is refused with `session_released` and zero prompt + /// side effects. + #[tokio::test] + async fn close_releases_kept_child_and_blocks_continue() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + assert_eq!(closed.status, TaskStatus::Completed, "last known status"); + wait_until(|| async { !mock.disconnects.lock().await.is_empty() }).await; + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c-live"]); + + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "more please".into(), + "op-after-close", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("session_released")); + assert!( + mock.followups.lock().await.is_empty(), + "a released session must take no prompt side effect" + ); + + let inner = broker.pending.inner.lock().await; + let s = inner + .sessions + .get(&task_id) + .expect("session survives release"); + assert!(s.released); + assert!(s.child_connection_id.is_none()); + assert!( + !inner.kept_alive_order.iter().any(|t| t == &task_id), + "a released session must leave the kept-alive FIFO" + ); } - /// Count of cached completed results across all parents. - #[cfg(any(test, feature = "test-utils"))] - pub async fn completed_count(&self) -> usize { - self.pending.inner.lock().await.completed.len() + /// Requirement 2.10: closing an already-released task returns the last + /// known status WITHOUT side effects — for both a replayed continuation_id + /// and a fresh one. + #[tokio::test] + async fn close_is_idempotent() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + let first = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + wait_until(|| async { !mock.disconnects.lock().await.is_empty() }).await; + + // Replay with the SAME continuation_id. + let replay = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + assert_eq!(replay.status, first.status); + // A FRESH continuation_id on an already-released task: same answer. + let again = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-2") + .await; + assert_eq!(again.status, first.status); + assert_eq!( + mock.disconnects.lock().await.len(), + 1, + "repeat closes must not re-run the teardown" + ); } - /// Count of in-flight (registered-at-entry, not-yet-parked / not-yet-exited) - /// `handle_request` setups. Should return to 0 on every exit path. - #[cfg(any(test, feature = "test-utils"))] - pub async fn inflight_count(&self) -> usize { - self.pending.inner.lock().await.inflight.len() + /// Requirement 2.11: a task with no live child connection (already swept / + /// evicted) still transitions to released. + #[tokio::test] + async fn close_without_live_connection_still_releases() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-gone", 42, "pt-1").await; + // Simulate a cap/sweep having already taken the connection. + { + let mut inner = broker.pending.inner.lock().await; + inner + .sessions + .get_mut(&task_id) + .expect("session exists") + .child_connection_id = None; + inner.kept_alive_order.retain(|t| t != &task_id); + } + + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + assert_eq!(closed.status, TaskStatus::Completed); + assert!( + mock.disconnects.lock().await.is_empty(), + "no connection to tear down" + ); + let inner = broker.pending.inner.lock().await; + assert!(inner.sessions.get(&task_id).expect("session").released); } - /// Count of in-setup (reserved, not-yet-parked) delegations. Each holds one - /// child and one call_id, so this counts both. - #[cfg(any(test, feature = "test-utils"))] - pub async fn reserved_child_count(&self) -> usize { - self.pending.inner.lock().await.setups.len() + /// Requirement 2.9: closing a RUNNING task cancels the in-flight turn + /// first (cancel + disconnect), settles it Canceled, and releases. + #[tokio::test] + async fn close_cancels_running_turn_first() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = start_running(&broker, &mock, "c-run", 42, "pt-1").await; + + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + assert_eq!(closed.status, TaskStatus::Canceled); + wait_until(|| async { !mock.disconnects.lock().await.is_empty() }).await; + assert_eq!(mock.cancels.lock().await.as_slice(), &["c-run"]); + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c-run"]); + assert_eq!(broker.pending_count().await, 0); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session"); + assert!(s.released); + assert_eq!(s.status, TaskStatus::Canceled); } - #[cfg(any(test, feature = "test-utils"))] - pub async fn reserved_call_count(&self) -> usize { - self.pending.inner.lock().await.setups.len() + /// Requirement 2.12: close and continue on the same task serialize under + /// the pending lock — a close arriving while a continuation is mid-dispatch + /// cancels that continuation turn, and the later arrival observes it. + #[tokio::test] + async fn close_serializes_with_inflight_continue() { + let mock = Arc::new(MockSpawner::new()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let gated = Arc::new(GatedFollowupSpawner { + inner: mock.clone(), + entered_tx: Mutex::new(Some(entered_tx)), + gate: Mutex::new(Some(release_rx)), + }); + let broker = DelegationBroker::new(gated as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(42)))); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + mock.queue_followup(Ok(())).await; + let driver = { + let broker = broker.clone(); + let task_id = task_id.clone(); + tokio::spawn(async move { + broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "racing".into(), + "op-race", + TurnOrigin::User, + ) + .await + }) + }; + entered_rx.await.expect("continue reached the send"); + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + let _ = release_tx.send(()); + + let cont = driver.await.unwrap(); + assert_eq!( + cont.error_code.as_deref(), + Some("canceled"), + "the in-dispatch continuation must observe the close as a cancel" + ); + assert_eq!(closed.status, TaskStatus::Canceled); + assert_eq!(broker.pending_count().await, 0); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session"); + assert!(s.released); } - #[cfg(any(test, feature = "test-utils"))] - pub async fn early_cancel_count(&self) -> usize { - self.pending.inner.lock().await.early_cancels.len() + /// Close racing an in-dispatch continuation whose follow-up SEND fails + /// (S4 `Err`, not the S3/S5 cancel checkpoints): the close deferred the + /// connection's teardown to the dispatch's compensation path + /// (`conn_to_disconnect = None`), so `abort_continuation`'s settle must + /// observe `released` and hand the prior connection to `disconnect` + /// instead of restoring it onto the released session (which would leak + /// the child process: `released = true` + a live pointer nobody owns). + #[tokio::test] + async fn close_racing_continue_send_failure_still_disconnects_released_connection() { + let mock = Arc::new(MockSpawner::new()); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let gated = Arc::new(GatedFollowupSpawner { + inner: mock.clone(), + entered_tx: Mutex::new(Some(entered_tx)), + gate: Mutex::new(Some(release_rx)), + }); + let broker = DelegationBroker::new(gated as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(42)))); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + // The gated follow-up FAILS once released: the S4 `Err` compensation + // (not a cancel checkpoint) runs against the now-released session. + mock.queue_followup(Err(SpawnerError::Send("agent hung up".into()))) + .await; + let driver = { + let broker = broker.clone(); + let task_id = task_id.clone(); + tokio::spawn(async move { + broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "racing".into(), + "op-race-senderr", + TurnOrigin::User, + ) + .await + }) + }; + entered_rx.await.expect("continue reached the send"); + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-senderr") + .await; + let _ = release_tx.send(()); + + let cont = driver.await.unwrap(); + assert_eq!( + cont.error_code.as_deref(), + Some("subagent_error"), + "the failed send surfaces as a runtime error, not a silent success" + ); + assert_eq!(closed.status, TaskStatus::Canceled); + assert!( + mock.disconnects.lock().await.iter().any(|c| c == "c-live"), + "the released session's connection must be handed to disconnect, \ + not restored onto the released session" + ); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session"); + assert!(s.released); + assert!( + s.child_connection_id.is_none(), + "a released session must never point at a connection again" + ); + assert_eq!( + s.status, + TaskStatus::Canceled, + "the close's terminal status must not be overwritten by the restore" + ); + assert!( + !inner.kept_alive_order.iter().any(|t| t == &task_id), + "a released session must not be resurrected into the kept-alive FIFO" + ); } - #[cfg(any(test, feature = "test-utils"))] - pub async fn early_complete_count(&self) -> usize { - self.pending.inner.lock().await.early_completes.len() + /// R3-A5 partial failure: a failing disconnect marks the session + /// `orphan_suspect` and schedules a background retry — never silently + /// swallowed as success. + #[tokio::test] + async fn close_disconnect_failure_marks_orphan_suspect_and_retries() { + /// Forwarding spawner whose FIRST disconnect fails (simulating a + /// wedged agent process); later attempts succeed. + struct FailingDisconnectSpawner { + inner: Arc, + attempts: std::sync::atomic::AtomicUsize, + } + #[async_trait] + impl ConnectionSpawner for FailingDisconnectSpawner { + async fn spawn( + &self, + p: &str, + a: AgentType, + w: Option, + m: Option, + c: BTreeMap, + ) -> Result { + self.inner.spawn(p, a, w, m, c).await + } + async fn send_prompt_linked_for_delegation( + &self, + conn_id: &str, + task: String, + link: crate::acp::delegation::spawner::DelegationLink, + ) -> Result { + self.inner + .send_prompt_linked_for_delegation(conn_id, task, link) + .await + } + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError> { + self.inner.cancel(conn_id).await + } + async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { + let n = self + .attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + return Err(SpawnerError::Disconnect("wedged process".into())); + } + self.inner.disconnect(conn_id).await + } + async fn spawn_for_resume( + &self, + p: &str, + a: AgentType, + w: Option, + s: Option, + m: Option, + c: BTreeMap, + ) -> Result { + self.inner.spawn_for_resume(p, a, w, s, m, c).await + } + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), SpawnerError> { + self.inner + .send_followup_prompt(conn_id, message, conversation_id, folder_id) + .await + } + async fn is_alive(&self, conn_id: &str) -> bool { + self.inner.is_alive(conn_id).await + } + } + + let mock = Arc::new(MockSpawner::new()); + let failing = Arc::new(FailingDisconnectSpawner { + inner: mock.clone(), + attempts: std::sync::atomic::AtomicUsize::new(0), + }); + let failing_probe = failing.clone(); + let broker = DelegationBroker::new(failing as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(continue_status_record(42)))); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-wedge", 42, "pt-1").await; + + let closed = broker + .close_delegation_session("parent-conn", Some(1), &task_id, "close-1") + .await; + assert_eq!(closed.status, TaskStatus::Completed); + // Background retry lands eventually; the first attempt failed. + wait_until(|| async { + failing_probe + .attempts + .load(std::sync::atomic::Ordering::SeqCst) + >= 2 + }) + .await; + let inner = broker.pending.inner.lock().await; + assert!( + inner + .sessions + .get(&task_id) + .expect("session") + .orphan_suspect, + "a failed disconnect must be observable, not swallowed" + ); } - /// First reserved (mid-setup) `call_id`, if any — lets a test resolve a - /// delegation via `complete_call` while it's pinned in the reserve→park - /// window (its entry isn't parked yet, so `peek_first_pending_call_id` - /// can't see it). - #[cfg(any(test, feature = "test-utils"))] - pub async fn peek_reserved_call_id(&self) -> Option { - self.pending - .inner - .lock() - .await - .setups - .keys() - .next() - .cloned() + // -- Task 3.8: resume pre-rejection + external_id validation ------------ + + /// Static lookup with a configurable duplicate count for the + /// `external_id` ambiguity check (Requirement 7.7). + struct CountingStatusLookup { + record: Option, + external_id_refs: usize, } -} -/// `ConversationDepthLookup` over the live `AppDatabase`. Used by the -/// production wiring; tests use the in-module `MockDepth`. -pub struct DbDepthLookup { - pub db: Arc, -} + #[async_trait] + impl ChildStatusLookup for CountingStatusLookup { + async fn find_by_call_id(&self, _call_id: &str) -> Option { + self.record.clone() + } + async fn external_id_ref_count(&self, _external_id: &str) -> usize { + self.external_id_refs + } + } -#[async_trait] -impl ConversationDepthLookup for DbDepthLookup { - async fn parent_of(&self, conversation_id: i32) -> Result, DelegationError> { - use sea_orm::EntityTrait; - let row = crate::db::entities::conversation::Entity::find_by_id(conversation_id) - .one(&self.db.conn) - .await - .map_err(|e| DelegationError::SubagentRuntimeError(format!("db: {e}")))?; - Ok(row.and_then(|r| r.parent_id)) + /// Happy resume path (Requirement 3.1): a dead child is revived through + /// `spawn_for_resume` carrying the persisted `external_id`, and the + /// follow-up goes to the REPLACEMENT connection while the session adopts + /// it. + #[tokio::test] + async fn continue_resumes_dead_child_with_external_id() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-dead", 42, "pt-1").await; + mock.mark_dead("c-dead").await; + + mock.queue_spawn(Ok("c-revived".into())).await; + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "welcome back".into(), + "op-res", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.status, TaskStatus::Running); + let resumes = mock.resume_args.lock().await; + assert_eq!(resumes.len(), 1); + assert_eq!(resumes[0].session_id.as_deref(), Some("sess-resume")); + drop(resumes); + let followups = mock.followups.lock().await; + assert_eq!(followups[0].conn_id, "c-revived"); + drop(followups); + let inner = broker.pending.inner.lock().await; + assert_eq!( + inner + .sessions + .get(&task_id) + .and_then(|s| s.child_connection_id.as_deref()), + Some("c-revived") + ); } -} -/// `ChildStatusLookup` over the live `AppDatabase`. Recovers a delegation -/// task's terminal status (NOT its text — child output isn't in codeg's DB) -/// from the child conversation row once its in-memory result was evicted. -pub struct DbChildStatusLookup { - pub db: Arc, -} + /// Requirement 3.3 (R2-B1): when the resume spawn itself fails, the + /// continuation is rejected with `resume_unavailable` BEFORE any prompt + /// side effect, and the session's resume credential is untouched. + #[tokio::test] + async fn resume_spawn_failure_is_resume_unavailable_before_prompt() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-dead", 42, "pt-1").await; + mock.mark_dead("c-dead").await; -#[async_trait] -impl ChildStatusLookup for DbChildStatusLookup { - async fn find_by_call_id(&self, call_id: &str) -> Option { - let summary = crate::db::service::conversation_service::get_by_delegation_call_id( - &self.db.conn, - call_id, - ) - .await - .ok() - .flatten()?; - // `summary.status` is the serialized `ConversationStatus` string. - let status = match summary.status.as_str() { - "in_progress" => TaskStatus::Running, - "pending_review" | "completed" => TaskStatus::Completed, - "cancelled" => TaskStatus::Canceled, - _ => TaskStatus::Unknown, - }; - Some(ChildStatusRecord { - child_conversation_id: summary.id, - status, - agent_type: summary.agent_type, - parent_id: summary.parent_id, - }) + mock.queue_spawn(Err(SpawnerError::Spawn("agent won't boot".into()))) + .await; + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "try again".into(), + "op-fail", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("resume_unavailable")); + assert!( + mock.followups.lock().await.is_empty(), + "no prompt side effect on a failed resume (3.3)" + ); + // The session keeps its settled state and can be retried later. + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session"); + assert_eq!(s.status, TaskStatus::Completed); + assert!(!s.released); } -} -#[cfg(test)] -mod tests { - use super::*; - use crate::acp::delegation::spawner::{mock::MockSpawner, SpawnerError}; - use crate::acp::delegation::types::DelegationSuccess; - use crate::models::AgentType; + /// Requirement 7.6: the DB row's agent_type must match the resume target — + /// a mismatch means the credential does not belong to this child; refuse + /// WITHOUT attempting the resume. + #[tokio::test] + async fn resume_rejected_when_agent_type_mismatches() { + let mock = Arc::new(MockSpawner::new()); + let mut rec = continue_status_record(42); + rec.agent_type = AgentType::Codex; // session was dispatched as ClaudeCode + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(StaticStatusLookup(rec))); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-dead", 42, "pt-1").await; + mock.mark_dead("c-dead").await; + + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "hello".into(), + "op-mismatch", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("not_continuable")); + assert!( + mock.resume_args.lock().await.is_empty(), + "must refuse BEFORE attempting the resume" + ); + assert!(mock.followups.lock().await.is_empty()); + } + + /// Requirement 7.6, folder leg: a cached folder binding that disagrees + /// with the DB row refuses the resume. + #[tokio::test] + async fn resume_rejected_when_folder_mismatches() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-dead", 42, "pt-1").await; + // The session believes folder 9; the DB row says folder 7. + { + let mut inner = broker.pending.inner.lock().await; + inner.sessions.get_mut(&task_id).expect("session").folder_id = Some(9); + } + mock.mark_dead("c-dead").await; + + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "hello".into(), + "op-folder", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("not_continuable")); + assert!(mock.resume_args.lock().await.is_empty()); + } - /// Test-only `ConversationDepthLookup` that resolves against a flat - /// (id, parent_id) table. Unknown ids return `Ok(None)` to keep test - /// setup small. - struct MockDepth(Vec<(i32, Option)>); + /// Requirement 7.7: an `external_id` referenced by more than one child row + /// cannot be trusted as a resume credential — refuse rather than guess. + #[tokio::test] + async fn duplicate_external_id_refuses_resume() { + let mock = Arc::new(MockSpawner::new()); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(Arc::new(CountingStatusLookup { + record: Some(continue_status_record(42)), + external_id_refs: 2, + })); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-dead", 42, "pt-1").await; + mock.mark_dead("c-dead").await; - #[async_trait] - impl ConversationDepthLookup for MockDepth { - async fn parent_of(&self, id: i32) -> Result, DelegationError> { - Ok(self.0.iter().find(|(c, _)| *c == id).and_then(|(_, p)| *p)) - } + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "hello".into(), + "op-dup-ext", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("resume_unavailable")); + assert!( + mock.resume_args.lock().await.is_empty(), + "an ambiguous credential must never be handed to the agent" + ); + assert!(mock.followups.lock().await.is_empty()); } - fn shallow_lookup() -> Arc { - // parent conversation is the root — depth = 0, no rejection. - Arc::new(MockDepth(vec![(1, None)])) as Arc + // -- Task 3.9: startup rebuild protocol --------------------------------- + + /// Lookup backing rebuild tests: serves `list_rebuildable` from a mutable + /// candidate list and answers `find_by_call_id` from the same rows (so a + /// rebuilt session can pass resume validation). + struct RebuildLookup { + candidates: Mutex>, } - fn request(parent_conv: i32, tool_use: &str) -> DelegationRequest { - DelegationRequest { - parent_connection_id: "parent-conn".into(), - parent_conversation_id: parent_conv, - parent_tool_use_id: tool_use.into(), + #[async_trait] + impl ChildStatusLookup for RebuildLookup { + async fn find_by_call_id(&self, call_id: &str) -> Option { + self.candidates + .lock() + .await + .iter() + .find(|c| c.task_id == call_id) + .map(|c| ChildStatusRecord { + child_conversation_id: c.child_conversation_id, + status: c.status, + agent_type: c.agent_type, + parent_id: c.parent_conversation_id, + folder_id: c.folder_id, + external_id: c.external_id.clone(), + working_dir: c.working_dir.clone(), + }) + } + async fn list_rebuildable(&self) -> Vec { + self.candidates.lock().await.clone() + } + } + + fn rebuild_candidate(task_id: &str, parent: Option, alive: bool) -> RebuildCandidate { + RebuildCandidate { + task_id: task_id.to_string(), + child_conversation_id: 42, + parent_conversation_id: parent, + parent_alive: alive, + parent_tool_use_id: Some("pt-reb".into()), agent_type: AgentType::ClaudeCode, - task: "do x".into(), - working_dir: None, - requested_working_dir: None, - external_handle: None, + status: TaskStatus::Completed, + folder_id: 7, + external_id: Some("sess-reb".into()), + working_dir: Some("/work".into()), } } - fn request_with_handle(parent_conv: i32, tool_use: &str, handle: &str) -> DelegationRequest { - let mut r = request(parent_conv, tool_use); - r.external_handle = Some(handle.to_string()); - r - } + /// Requirement 7.1: startup rebuilds the continuable index from child + /// conversation rows — a rebuilt session carries its ownership + resume + /// metadata, has NO turn history (not promised across restarts), and a + /// continuation on it goes through the resume path with the persisted + /// credential. Requirement 7.4: the operation ledger starts empty, so a + /// pre-restart continuation_id executes as new. + #[tokio::test] + async fn rebuild_restores_continuable_sessions_from_db() { + let mock = Arc::new(MockSpawner::new()); + let lookup = Arc::new(RebuildLookup { + candidates: Mutex::new(vec![rebuild_candidate("t-reb", Some(1), true)]), + }); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup); + enable_delegation(&broker).await; - /// Bring the broker's `enabled` switch up before driving any test that - /// hits `handle_request`. Production now defaults to `enabled: false`, - /// so a bare `DelegationBroker::new(...)` would short-circuit before - /// parking a pending entry. Tests that assert disabled behavior set - /// their own config explicitly and skip this helper. - async fn enable_delegation(broker: &DelegationBroker) { - broker - .set_config(DelegationConfig { - enabled: true, - ..DelegationConfig::default() - }) + broker.rebuild_sessions_from_db().await; + + { + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get("t-reb").expect("session rebuilt"); + assert_eq!(s.parent_conversation_id, 1); + assert_eq!(s.status, TaskStatus::Completed); + assert!( + s.child_connection_id.is_none(), + "no live process after restart" + ); + assert_eq!(s.external_id.as_deref(), Some("sess-reb")); + assert_eq!(s.turn_version, 0, "turn history does not cross restarts"); + assert!(s.turns.is_empty()); + assert!(inner.operations.is_empty(), "ledger never crosses restarts"); + assert!(!inner.rebuilding, "rebuild must clear its in-progress flag"); + } + + // A pre-restart continuation_id is unseen — it executes for real. + mock.queue_spawn(Ok("c-back".into())).await; + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn-2", + Some(1), + "t-reb", + "pick it back up".into(), + "op-from-before-restart", + TurnOrigin::User, + ) .await; + assert_eq!(cont.status, TaskStatus::Running); + let resumes = mock.resume_args.lock().await; + assert_eq!(resumes.len(), 1); + assert_eq!(resumes[0].session_id.as_deref(), Some("sess-reb")); } - // -- Task 4.3 ----------------------------------------------------------- - + /// Requirement 7.3 (single-row isolation) + ownership validation: rows + /// whose parent conversation is gone are skipped — they never enter the + /// index (observable as `Unknown`) and never abort the rest of the scan. #[tokio::test] - async fn config_round_trip() { - let broker = DelegationBroker::new( - Arc::new(MockSpawner::new()) as Arc, - shallow_lookup(), - ); - broker - .set_config(DelegationConfig { - enabled: false, - depth_limit: 5, - ..DelegationConfig::default() - }) + async fn rebuild_skips_rows_with_dead_parent_but_keeps_the_rest() { + let mock = Arc::new(MockSpawner::new()); + let lookup = Arc::new(RebuildLookup { + candidates: Mutex::new(vec![ + rebuild_candidate("t-orphan", Some(9), false), + rebuild_candidate("t-dangling", None, false), + rebuild_candidate("t-ok", Some(1), true), + ]), + }); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup); + enable_delegation(&broker).await; + + broker.rebuild_sessions_from_db().await; + + let inner = broker.pending.inner.lock().await; + assert!(inner.sessions.contains_key("t-ok"), "healthy row rebuilt"); + assert!(!inner.sessions.contains_key("t-orphan")); + assert!(!inner.sessions.contains_key("t-dangling")); + drop(inner); + let cont = broker + .continue_delegation( + "parent-conn", + Some(9), + "t-orphan", + "hello".into(), + "op-x", + TurnOrigin::User, + ) .await; - let got = broker.config_snapshot().await; - assert!(!got.enabled); - assert_eq!(got.depth_limit, 5); + assert_eq!(cont.status, TaskStatus::Unknown); } + /// Requirement 7.2: while the rebuild is in progress, a continuation (or + /// close) for a not-yet-indexed task answers the retryable `rebuilding` + /// code — never `Unknown` (which would read as "your session is lost"). #[tokio::test] - async fn disabled_returns_canceled_without_touching_spawner() { + async fn requests_during_rebuild_get_rebuilding_not_unknown() { let mock = Arc::new(MockSpawner::new()); - let broker = - DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); - broker - .set_config(DelegationConfig { - enabled: false, - depth_limit: 2, - ..DelegationConfig::default() - }) - .await; - let outcome = broker.handle_request(request(1, "pt-1")).await; - match outcome { - DelegationOutcome::Err { code, .. } => assert_eq!(code, "canceled"), - _ => panic!("expected Err"), + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + { + let mut inner = broker.pending.inner.lock().await; + inner.rebuilding = true; } - assert!(mock.disconnects.lock().await.is_empty()); + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + "t-not-yet", + "hello".into(), + "op-1", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("rebuilding")); + let closed = broker + .close_delegation_session("parent-conn", Some(1), "t-not-yet", "close-1") + .await; + assert_eq!(closed.error_code.as_deref(), Some("rebuilding")); } - // -- Task 4.4: happy path ---------------------------------------------- - + /// A rebuild must never clobber a LIVE session (e.g. a hot-restart path + /// calling it twice): the in-memory entry — which may hold a kept-alive + /// connection — wins over the DB reconstruction. #[tokio::test] - async fn happy_path_returns_ok_after_complete_call() { + async fn rebuild_does_not_clobber_live_sessions() { let mock = Arc::new(MockSpawner::new()); - mock.queue_spawn(Ok("child-conn-1".into())).await; - mock.queue_send(Ok(42)).await; + let lookup = Arc::new(RebuildLookup { + candidates: Mutex::new(Vec::new()), + }); let broker = - DelegationBroker::new(mock.clone() as Arc, shallow_lookup()); + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup.clone()); enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; - let driver = { - let broker = broker.clone(); - tokio::spawn(async move { broker.handle_request(request(1, "pt-1")).await }) - }; + lookup + .candidates + .lock() + .await + .push(rebuild_candidate(&task_id, Some(1), true)); + broker.rebuild_sessions_from_db().await; - // Spin until the broker has registered the pending call so the test - // doesn't race the spawn/send awaits. - let call_id = loop { - if let Some(id) = broker.peek_first_pending_call_id().await { - break id; - } - tokio::time::sleep(Duration::from_millis(5)).await; - }; + let inner = broker.pending.inner.lock().await; + assert_eq!( + inner + .sessions + .get(&task_id) + .and_then(|s| s.child_connection_id.as_deref()), + Some("c-live"), + "the live entry (with its kept connection) must win" + ); + } - broker - .complete_call( - &call_id, - DelegationOutcome::Ok(DelegationSuccess { - text: "4".into(), - child_conversation_id: 42, - child_agent_type: AgentType::Codex, - turn_count: 1, - duration_ms: 50, - token_usage: None, - }), + // -- Task 3.10: parent teardown vs conversation deletion (R2-B6) -------- + + /// Requirement 1.4: tearing down the parent CONNECTION releases only its + /// run lease — settled kept-alive children survive (the session serves the + /// user and a reconnected parent, not one connection). The text cache for + /// that connection is dropped (slower, not wrong — D2). + #[tokio::test] + async fn parent_connection_teardown_keeps_settled_children_alive() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-live", 42, "pt-1").await; + + broker.cancel_by_parent("parent-conn").await; + + assert!( + mock.disconnects.lock().await.is_empty(), + "a parent-connection teardown must not kill settled kept children" + ); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session survives"); + assert_eq!(s.child_connection_id.as_deref(), Some("c-live")); + assert!(!s.released); + drop(inner); + + // The session stays continuable for the reconnected parent (same + // parent CONVERSATION, new connection id — D5). + mock.queue_followup(Ok(())).await; + let cont = broker + .continue_delegation( + "parent-conn-reborn", + Some(1), + &task_id, + "still there?".into(), + "op-reconnect", + TurnOrigin::ParentAgent, ) .await; + assert_eq!(cont.status, TaskStatus::Running); + } - let outcome = driver.await.unwrap(); - match outcome { - DelegationOutcome::Ok(s) => { - assert_eq!(s.text, "4"); - assert_eq!(s.child_conversation_id, 42); - } - other => panic!("expected Ok, got {other:?}"), + /// Requirement 1.4a: deleting the parent CONVERSATION is the real owner + /// disappearing — every kept-alive child of that conversation is released + /// (disconnected + refused further continuation), while other parents' + /// children stay untouched. + #[tokio::test] + async fn deleting_parent_conversation_releases_its_children() { + let mock = Arc::new(MockSpawner::new()); + let lookup = Arc::new(RebuildLookup { + candidates: Mutex::new(Vec::new()), + }); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup.clone()); + enable_delegation(&broker).await; + let task_id = settle_one(&broker, &mock, "c-mine", 42, "pt-1").await; + // A second session under ANOTHER parent conversation, via rebuild. + lookup + .candidates + .lock() + .await + .push(rebuild_candidate("t-other", Some(2), true)); + broker.rebuild_sessions_from_db().await; + + broker.release_children_of_parent_conversation(1).await; + + assert_eq!( + mock.disconnects.lock().await.as_slice(), + &["c-mine"], + "conversation deletion frees exactly its own children" + ); + { + let inner = broker.pending.inner.lock().await; + assert!(inner.sessions.get(&task_id).expect("session").released); + assert!( + !inner + .sessions + .get("t-other") + .expect("other session") + .released, + "another parent's child must be untouched" + ); } + let cont = broker + .continue_delegation( + "parent-conn", + Some(1), + &task_id, + "hello?".into(), + "op-after-del", + TurnOrigin::User, + ) + .await; + assert_eq!(cont.error_code.as_deref(), Some("session_released")); + } + + /// Requirement 1.4a running leg: a running turn under the deleted parent + /// conversation is canceled and torn down, not left unmanaged. + #[tokio::test] + async fn deleting_parent_conversation_cancels_running_children() { + let mock = Arc::new(MockSpawner::new()); + let broker = continue_broker(mock.clone(), 42); + enable_delegation(&broker).await; + let task_id = start_running(&broker, &mock, "c-run", 42, "pt-1").await; + + broker.release_children_of_parent_conversation(1).await; + + wait_until(|| async { !mock.disconnects.lock().await.is_empty() }).await; + assert_eq!(mock.cancels.lock().await.as_slice(), &["c-run"]); + assert_eq!(mock.disconnects.lock().await.as_slice(), &["c-run"]); assert_eq!(broker.pending_count().await, 0); - // complete_call disconnects the child once. - assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-conn-1"]); + let inner = broker.pending.inner.lock().await; + let s = inner.sessions.get(&task_id).expect("session"); + assert!(s.released); + assert_eq!(s.status, TaskStatus::Canceled); } /// `StatusWait::Infinite` (the explicit `wait_ms = 0` escape hatch) must @@ -4150,7 +7728,12 @@ mod tests { DelegationOutcome::Ok(s) => assert_eq!(s.text, "done"), other => panic!("expected Ok, got {other:?}"), } - assert_eq!(mock.disconnects.lock().await.as_slice(), &["c1"]); + // Success keeps the child process for continue_with_session; only + // cancel / close_session tear it down. + assert!( + mock.disconnects.lock().await.is_empty(), + "complete_call must not disconnect on success" + ); } // -- Task 4.6: parent-cancel cascade ----------------------------------- @@ -6262,7 +9845,12 @@ mod tests { assert_eq!(broker.reserved_call_count().await, 0); assert_eq!(broker.reserved_child_count().await, 0); assert_eq!(broker.early_complete_count().await, 0); - assert_eq!(mock.disconnects.lock().await.as_slice(), &["c-fast-ok"]); + // An Ok early-complete settles as Completed — keep the child for + // continue_with_session (only cancel-coded outcomes disconnect). + assert!( + mock.disconnects.lock().await.is_empty(), + "early Ok complete must not disconnect" + ); // Meta trail: running (written pre-park) then completed (pickup). let calls = writer.snapshot().await; @@ -7767,7 +11355,10 @@ mod tests { // The event labels the card even when the parent tool call's // raw_input never carried the arguments (identity-less hosts). assert_eq!(task_preview, "do x"); - assert!(!task_id.is_empty(), "broker-minted task id must ride the event"); + assert!( + !task_id.is_empty(), + "broker-minted task id must ride the event" + ); } other => panic!("expected DelegationStarted, got {other:?}"), } @@ -7787,6 +11378,86 @@ mod tests { let _ = driver.await.unwrap(); } + /// T4.3 bridge proof: the session-scoped update rides the SAME + /// `emit_with_state` fanout as every other ACP event — per-connection + /// stream (the WS attach path that becomes the frontend `EventEnvelope`) + /// AND the `InternalEventBus`. Direct emitter call: the broker-side + /// emission is covered by the mock-backed tests above; this pins the + /// production fanout so the new variant can't silently become desktop-only. + #[tokio::test] + async fn real_emitter_fans_out_session_update_to_parent_stream_and_bus() { + use crate::acp::delegation::event_emitter::{ + ConnectionManagerEventEmitter, DelegationEventEmitter, + }; + use crate::acp::manager::ConnectionManager; + use crate::acp::types::AcpEvent; + use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; + + let manager = ConnectionManager::new(); + let broadcaster = Arc::new(WebEventBroadcaster::new()); + let parent_emitter = EventEmitter::test_web_only(broadcaster); + let bus = parent_emitter + .acp_event_bus() + .expect("WebOnly emitter must expose an InternalEventBus"); + manager + .insert_test_connection("parent-conn", AgentType::ClaudeCode, None, parent_emitter) + .await; + let mut bus_rx = bus.subscribe(); + let (parent_state, _) = manager + .get_state_and_emitter("parent-conn") + .await + .expect("parent just inserted"); + let mut stream_rx = parent_state.read().await.event_stream().subscribe(); + + let real_emitter = ConnectionManagerEventEmitter { + manager: Arc::new(manager.clone_ref()), + }; + real_emitter + .emit_session_update("parent-conn", 42, "task-su", "turn-su", 2, TurnOrigin::User) + .await; + + let envelope = tokio::time::timeout(Duration::from_millis(500), stream_rx.recv()) + .await + .expect("per-connection stream should receive DelegationSessionUpdate within 500ms") + .expect("envelope recv must not error"); + assert_eq!(envelope.connection_id, "parent-conn"); + match &envelope.payload { + AcpEvent::DelegationSessionUpdate { + parent_connection_id, + child_conversation_id, + task_id, + turn_id, + turn_version, + origin, + } => { + assert_eq!(parent_connection_id, "parent-conn"); + assert_eq!(*child_conversation_id, 42); + assert_eq!(task_id, "task-su"); + assert_eq!(turn_id, "turn-su"); + assert_eq!(*turn_version, 2); + assert_eq!(*origin, TurnOrigin::User); + } + other => panic!("expected DelegationSessionUpdate, got {other:?}"), + } + + let bus_envelope = tokio::time::timeout(Duration::from_millis(500), bus_rx.recv()) + .await + .expect("InternalEventBus should receive DelegationSessionUpdate within 500ms") + .expect("bus recv must not error"); + assert_eq!(bus_envelope.connection_id, "parent-conn"); + assert!(matches!( + bus_envelope.payload, + AcpEvent::DelegationSessionUpdate { .. } + )); + + // Wire-shape pin: snake_case tag + origin as `user` (the TS mirror in + // src/lib/types.ts binds to exactly this JSON). + let json = serde_json::to_value(&bus_envelope.payload).unwrap(); + assert_eq!(json["type"], "delegation_session_update"); + assert_eq!(json["origin"], "user"); + assert_eq!(json["turn_version"], 2); + } + #[tokio::test] async fn real_emitter_is_silent_no_op_when_parent_already_detached() { // Parent torn down mid-delegation: `get_state_and_emitter` returns diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 561fc9397..73caa2b08 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -5,15 +5,18 @@ //! The companion speaks newline-delimited JSON-RPC 2.0 on stdio: //! one request → one response per line, with concurrent dispatch so //! `notifications/cancelled` can race an in-flight `tools/call`. It exposes up -//! to six tools — `delegate_to_agent` (async; returns a `task_id` ack), +//! to eight tools — `delegate_to_agent` (async; returns a `task_id` ack), //! `get_delegation_status` (poll/long-poll for the result), `cancel_delegation`, -//! `check_user_feedback` (pull the user's mid-turn steering notes), -//! `ask_user_question` (block on a multiple-choice card), and `get_session_info` -//! (resolve a referenced session by id) — whose schemas are embedded at compile -//! time from [`TOOL_SCHEMA_JSON`] and gated by the `--features` groups (delegation -//! / feedback / ask / sessions). Only `delegate_to_agent` registers a broker-side -//! cancel handle; canceling a status / cancel / feedback / session round-trip -//! merely suppresses its response — and for `check_user_feedback` also skips the +//! `continue_with_session` (follow-up in the same child session), +//! `close_session` (release a child — free its process and refuse further +//! continuation in this run), `check_user_feedback` (pull the user's mid-turn +//! steering notes), `ask_user_question` (block on a multiple-choice card), and +//! `get_session_info` (resolve a referenced session by id) — whose schemas are +//! embedded at compile time from [`TOOL_SCHEMA_JSON`] and gated by the +//! `--features` groups (delegation / feedback / ask / sessions). Only +//! `delegate_to_agent` registers a broker-side cancel handle; canceling a +//! status / cancel / continue / close / feedback / session round-trip merely +//! suppresses its response — and for `check_user_feedback` also skips the //! delivery commit, so a cancelled note stays pending. //! //! Notifications (id = None) produce no response, matching MCP's expectation @@ -42,11 +45,13 @@ use serde_json::{json, Value}; use tokio::sync::{oneshot, Mutex}; use crate::acp::delegation::transport::{ - client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback, + client_ask_round_trip, client_cancel, client_cancel_task_round_trip, + client_close_session_round_trip, client_commit_feedback, client_continue_round_trip, client_feedback_round_trip, client_round_trip, client_session_round_trip, client_status_round_trip, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, - BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerRequest, BrokerResponse, - BrokerSessionRequest, BrokerStatusRequest, + BrokerCloseSessionRequest, BrokerCommitFeedbackRequest, BrokerContinueRequest, + BrokerFeedbackRequest, BrokerRequest, BrokerResponse, BrokerSessionRequest, + BrokerStatusRequest, }; use crate::acp::question::parse_questions; use crate::acp::session_info::MAX_SESSION_MESSAGES; @@ -179,7 +184,11 @@ impl CompanionFeatures { "check_user_feedback" => self.feedback, "ask_user_question" => self.ask, "get_session_info" => self.sessions, - "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" => self.delegation, + "delegate_to_agent" + | "get_delegation_status" + | "cancel_delegation" + | "continue_with_session" + | "close_session" => self.delegation, _ => false, } } @@ -479,6 +488,94 @@ async fn build_tools_call_spawn( Box::pin(async move { client_cancel_task_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_task_report).await } + "continue_with_session" => { + let task_id = match arguments.get("task_id").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => { + return LineAction::Respond(err( + id, + -32602, + "continue_with_session requires a non-empty string task_id", + )); + } + }; + let message = match arguments.get("message").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + return LineAction::Respond(err( + id, + -32602, + "continue_with_session requires a non-empty string message", + )); + } + }; + // R2-B5: the caller mints the idempotency key; the companion and + // listener never generate one on its behalf (a generated id would + // make a retried request unrecognizable as a retry). + let continuation_id = match arguments.get("continuation_id").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + return LineAction::Respond(err( + id, + -32602, + "continue_with_session requires a non-empty string continuation_id \ + (mint a fresh unique id per request; reuse the SAME id only when \ + retrying this exact request)", + )); + } + }; + let tool_use_id = params + .get("_meta") + .and_then(|m| m.get("tool_use_id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let req = BrokerContinueRequest { + token: ctx.token.clone(), + parent_connection_id: ctx.parent_connection_id.clone(), + parent_tool_use_id: tool_use_id, + task_id, + message, + continuation_id, + }; + // No external_handle: canceling a continue round-trip only + // suppresses its response — the broker's operation ledger makes a + // retried request with the same continuation_id idempotent. + let round_trip = + Box::pin(async move { client_continue_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_task_report).await + } + "close_session" => { + let task_id = match arguments.get("task_id").and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => { + return LineAction::Respond(err( + id, + -32602, + "close_session requires a non-empty string task_id", + )); + } + }; + let continuation_id = match arguments.get("continuation_id").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => { + return LineAction::Respond(err( + id, + -32602, + "close_session requires a non-empty string continuation_id \ + (mint a fresh unique id per request; reuse the SAME id only when \ + retrying this exact request)", + )); + } + }; + let req = BrokerCloseSessionRequest { + token: ctx.token.clone(), + task_id, + continuation_id, + }; + let round_trip = + Box::pin(async move { client_close_session_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_task_report).await + } "check_user_feedback" => { let req = BrokerFeedbackRequest { token: ctx.token.clone(), @@ -957,7 +1054,10 @@ pub fn render_ask_result(outcome: &Value) -> Value { } else { selected.join(", ") }; - s.push_str(&format!("{}. [{header}] {question}\n → {joined}\n", i + 1)); + s.push_str(&format!( + "{}. [{header}] {question}\n → {joined}\n", + i + 1 + )); } s }; @@ -1100,8 +1200,14 @@ fn render_session_summary_text(o: &Value) -> String { .get("truncated") .and_then(|v| v.as_bool()) .unwrap_or(false); - let suffix = if truncated { ", older turns omitted" } else { "" }; - out.push_str(&format!("\nRecent messages ({included}/{total}{suffix}):\n")); + let suffix = if truncated { + ", older turns omitted" + } else { + "" + }; + out.push_str(&format!( + "\nRecent messages ({included}/{total}{suffix}):\n" + )); if let Some(items) = messages.get("items").and_then(|v| v.as_array()) { for item in items { let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("?"); @@ -1202,16 +1308,37 @@ mod tests { } #[tokio::test] - async fn tools_list_returns_three_delegation_tools() { + async fn tools_list_returns_five_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(), 3); + assert_eq!(tools.len(), 5); 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(&"continue_with_session")); + assert!(names.contains(&"close_session")); + // continue_with_session requires the caller-minted idempotency key + // (R2-B5) — deviation from upstream PR #375, which has no such param. + let cont = tools + .iter() + .find(|t| t["name"] == "continue_with_session") + .unwrap(); + let required: Vec<&str> = cont["inputSchema"]["required"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(required.contains(&"continuation_id")); + let close = tools.iter().find(|t| t["name"] == "close_session").unwrap(); + // R2-B4 release semantics: the close description must speak of + // releasing/freeing the child, never of a permanent close. + let desc = close["description"].as_str().unwrap(); + assert!(desc.contains("Release"), "close description: {desc}"); + assert!(!desc.to_lowercase().contains("permanently")); // delegate_to_agent schema still enumerates all 12 agent types. let delegate = tools .iter() @@ -1254,6 +1381,68 @@ mod tests { assert!(e.message.contains("task_ids")); } + // -- T4.2: continue_with_session / close_session argument validation ---- + + /// `continuation_id` is a REQUIRED caller-minted idempotency key (R2-B5: + /// the companion/listener never generate one — a generated id would make + /// a retried request unrecognizable as a retry). + #[tokio::test] + async fn continue_with_session_missing_continuation_id_is_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":31, + "method":"tools/call", + "params": { "name": "continue_with_session", + "arguments": { "task_id": "t1", "message": "go on" } } + }"#; + let resp = unwrap_respond(dispatch_for_test(line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!( + e.message.contains("continuation_id"), + "rejection must name the missing field, got: {}", + e.message + ); + } + + #[tokio::test] + async fn continue_with_session_missing_message_is_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":32, + "method":"tools/call", + "params": { "name": "continue_with_session", + "arguments": { "task_id": "t1", "continuation_id": "c1" } } + }"#; + let resp = unwrap_respond(dispatch_for_test(line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!( + e.message.contains("message"), + "rejection must name the missing field, got: {}", + e.message + ); + } + + #[tokio::test] + async fn close_session_missing_continuation_id_is_rejected() { + let line = r#"{ + "jsonrpc":"2.0", + "id":33, + "method":"tools/call", + "params": { "name": "close_session", + "arguments": { "task_id": "t1" } } + }"#; + let resp = unwrap_respond(dispatch_for_test(line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!( + e.message.contains("continuation_id"), + "rejection must name the missing field, got: {}", + e.message + ); + } + #[tokio::test] async fn notifications_initialized_produces_no_response() { let line = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; @@ -1694,7 +1883,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(), 3); + assert_eq!(names.len(), 5); } #[tokio::test] @@ -1703,7 +1892,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(), 4); + assert_eq!(names.len(), 6); } #[tokio::test] @@ -1767,8 +1956,11 @@ mod tests { ); assert!(!off.contains(&"ask_user_question".to_string())); let on = list_tool_names( - dispatch_with_features(ASK_ONLY, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#) - .await, + dispatch_with_features( + ASK_ONLY, + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, + ) + .await, ); assert_eq!(on, vec!["ask_user_question".to_string()]); } @@ -1899,7 +2091,11 @@ mod tests { #[tokio::test] async fn get_session_info_missing_or_bad_id_rejected_synchronously() { - for args in [json!({}), json!({ "session_id": "abc" }), json!({ "session_id": true })] { + for args in [ + json!({}), + json!({ "session_id": "abc" }), + json!({ "session_id": true }), + ] { let line = json!({ "jsonrpc": "2.0", "id": 32, "method": "tools/call", "params": { "name": "get_session_info", "arguments": args } @@ -1955,10 +2151,7 @@ mod tests { parse_max_messages(&json!({ "max_messages": 4_294_967_296_u64 })), 200 ); - assert_eq!( - parse_max_messages(&json!({ "max_messages": 1e30 })), - 200 - ); + assert_eq!(parse_max_messages(&json!({ "max_messages": 1e30 })), 200); // Invalid / negative / fractional → default (optional knob, not an error). assert_eq!(parse_max_messages(&json!({ "max_messages": "abc" })), 20); assert_eq!(parse_max_messages(&json!({ "max_messages": -5 })), 20); @@ -2091,8 +2284,12 @@ mod tests { let (mut c1, _) = listener.accept().await.unwrap(); let _: BrokerResponse = match read_frame::<_, BrokerMessage>(&mut c1).await.unwrap() { BrokerMessage::Feedback(_) => { - write_frame(&mut c1, &feedback_resp_with_ids(&["f1"])).await.unwrap(); - BrokerResponse { outcome: Value::Null } + write_frame(&mut c1, &feedback_resp_with_ids(&["f1"])) + .await + .unwrap(); + BrokerResponse { + outcome: Value::Null, + } } other => panic!("expected Feedback, got {other:?}"), }; @@ -2101,7 +2298,14 @@ mod tests { if let BrokerMessage::CommitFeedback(req) = read_frame(&mut c2).await.unwrap() { committed2.lock().await.push(req.ids); } - write_frame(&mut c2, &BrokerResponse { outcome: Value::Null }).await.unwrap(); + write_frame( + &mut c2, + &BrokerResponse { + outcome: Value::Null, + }, + ) + .await + .unwrap(); }); let inflight = Arc::new(InflightCalls::new()); @@ -2110,7 +2314,9 @@ mod tests { Value::from(1), sock, "tok".into(), - BrokerFeedbackRequest { token: "tok".into() }, + BrokerFeedbackRequest { + token: "tok".into(), + }, ) .await; let LineAction::Spawn(call) = action else { @@ -2200,6 +2406,9 @@ mod tests { ); server.abort(); // Crucially: no commit was sent for a cancelled (undelivered) check. - assert!(!*saw_commit.lock().await, "a cancelled check must not commit"); + assert!( + !*saw_commit.lock().await, + "a cancelled check must not commit" + ); } } diff --git a/src-tauri/src/acp/delegation/event_emitter.rs b/src-tauri/src/acp/delegation/event_emitter.rs index 561cf7ef6..5f32a8184 100644 --- a/src-tauri/src/acp/delegation/event_emitter.rs +++ b/src-tauri/src/acp/delegation/event_emitter.rs @@ -33,6 +33,7 @@ use async_trait::async_trait; use std::sync::Arc; +use crate::acp::delegation::broker::TurnOrigin; use crate::acp::manager::ConnectionManager; use crate::acp::types::{AcpEvent, DelegationResultSummary}; use crate::models::AgentType; @@ -77,6 +78,23 @@ pub trait DelegationEventEmitter: Send + Sync { agent_type: AgentType, result: DelegationResultSummary, ); + + /// Publish the session-scoped `AcpEvent::DelegationSessionUpdate` for a + /// settled CONTINUED turn (turn_version > 1). Replaces the suppressed + /// second `DelegationCompleted` (Requirement 2.8a): the payload is + /// session-addressed — `(task_id, turn_id, turn_version, origin)` plus + /// routing ids — and consumers re-query `get_delegation_status` / + /// the availability endpoint as the authoritative source (Requirement + /// 8.4: the event is an increment notification, not a state carrier). + async fn emit_session_update( + &self, + parent_connection_id: &str, + child_conversation_id: i32, + task_id: &str, + turn_id: &str, + turn_version: u64, + origin: TurnOrigin, + ); } /// Default emitter used when the broker is constructed via the short-form @@ -112,6 +130,17 @@ impl DelegationEventEmitter for NoopEventEmitter { _result: DelegationResultSummary, ) { } + + async fn emit_session_update( + &self, + _parent_connection_id: &str, + _child_conversation_id: i32, + _task_id: &str, + _turn_id: &str, + _turn_version: u64, + _origin: TurnOrigin, + ) { + } } /// Production impl backed by `ConnectionManager`. Resolves the parent @@ -193,6 +222,37 @@ impl DelegationEventEmitter for ConnectionManagerEventEmitter { ) .await; } + + async fn emit_session_update( + &self, + parent_connection_id: &str, + child_conversation_id: i32, + task_id: &str, + turn_id: &str, + turn_version: u64, + origin: TurnOrigin, + ) { + let Some((state_arc, emitter)) = self + .manager + .get_state_and_emitter(parent_connection_id) + .await + else { + return; + }; + emit_with_state( + &state_arc, + &emitter, + AcpEvent::DelegationSessionUpdate { + parent_connection_id: parent_connection_id.to_string(), + child_conversation_id, + task_id: task_id.to_string(), + turn_id: turn_id.to_string(), + turn_version, + origin, + }, + ) + .await; + } } #[cfg(any(test, feature = "test-utils"))] @@ -208,6 +268,7 @@ pub mod mock { pub struct MockEventEmitter { pub calls: Mutex>, pub started_calls: Mutex>, + pub session_updates: Mutex>, } #[derive(Debug, Clone)] @@ -231,6 +292,16 @@ pub mod mock { pub task_id: String, } + #[derive(Debug, Clone)] + pub struct SessionUpdateCall { + pub parent_connection_id: String, + pub child_conversation_id: i32, + pub task_id: String, + pub turn_id: String, + pub turn_version: u64, + pub origin: TurnOrigin, + } + impl MockEventEmitter { pub fn new() -> Self { Self::default() @@ -251,6 +322,14 @@ pub mod mock { pub async fn started_count(&self) -> usize { self.started_calls.lock().await.len() } + + pub async fn session_update_snapshot(&self) -> Vec { + self.session_updates.lock().await.clone() + } + + pub async fn session_update_count(&self) -> usize { + self.session_updates.lock().await.len() + } } #[async_trait] @@ -295,5 +374,24 @@ pub mod mock { result, }); } + + async fn emit_session_update( + &self, + parent_connection_id: &str, + child_conversation_id: i32, + task_id: &str, + turn_id: &str, + turn_version: u64, + origin: TurnOrigin, + ) { + self.session_updates.lock().await.push(SessionUpdateCall { + parent_connection_id: parent_connection_id.to_string(), + child_conversation_id, + task_id: task_id.to_string(), + turn_id: turn_id.to_string(), + turn_version, + origin, + }); + } } } diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index db57cda62..585a7c7ff 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -34,7 +34,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; - /// Pluggable "what conversation is this parent currently in?" lookup. The /// production impl wraps `ConnectionManager.get_state`; tests use an /// in-memory map. @@ -212,6 +211,10 @@ impl DelegationListener { reports_response(reports)? } BrokerMessage::CancelTask(req) => report_response(self.process_cancel_task(req).await)?, + BrokerMessage::Continue(req) => report_response(self.process_continue(req).await)?, + BrokerMessage::CloseSession(req) => { + report_response(self.process_close_session(req).await)? + } BrokerMessage::Feedback(req) => { // at-least-once delivery: READ pending notes (no mutation), // WRITE the response, and COMMIT them delivered ONLY on a @@ -224,10 +227,7 @@ impl DelegationListener { write_frame(conn, &feedback_response(&[])?).await?; } Some(parent_conn_id) => { - let pending = self - .feedback - .read_pending_feedback(&parent_conn_id) - .await; + let pending = self.feedback.read_pending_feedback(&parent_conn_id).await; // Read-only: the response carries the note ids // (`_commit_ids`); delivery is committed LATER, by the // companion's `CommitFeedback` once it actually returns @@ -419,6 +419,97 @@ impl DelegationListener { .await } + /// Validate the token and continue a settled child session under the SAME + /// `task_id` (Requirement 2.3). Backs `continue_with_session`. The + /// `continuation_id` arrived REQUIRED on the wire (R2-B5 — never minted + /// here); the broker enforces non-empty and runs the operation ledger. + async fn process_continue( + &self, + req: crate::acp::delegation::transport::BrokerContinueRequest, + ) -> DelegationTaskReport { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return unknown_report(&req.task_id); + }; + if entry.parent_connection_id != req.parent_connection_id { + return unknown_report(&req.task_id); + } + // Identity restoration for identity-less hosts (Cursor) — same + // call-time rename as `process_status` / `process_cancel_task`. + let mut rename_input = serde_json::Map::new(); + rename_input.insert( + "task_id".into(), + serde_json::Value::String(req.task_id.clone()), + ); + rename_input.insert( + "message".into(), + serde_json::Value::String(req.message.clone()), + ); + rename_input.insert( + "continuation_id".into(), + serde_json::Value::String(req.continuation_id.clone()), + ); + self.broker + .rewrite_identityless_tool_call( + &entry.parent_connection_id, + crate::acp::delegation::CONTINUE_TOOL_REWRITE_TITLE, + serde_json::Value::Object(rename_input), + ) + .await; + let parent_conversation_id = self + .parent_lookup + .current_conversation_id(&entry.parent_connection_id) + .await; + self.broker + .continue_delegation( + &entry.parent_connection_id, + parent_conversation_id, + &req.task_id, + req.message, + &req.continuation_id, + crate::acp::delegation::broker::TurnOrigin::ParentAgent, + ) + .await + } + + /// Validate the token and RELEASE a child session (R2-B4 release + /// semantics). Backs `close_session`. + async fn process_close_session( + &self, + req: crate::acp::delegation::transport::BrokerCloseSessionRequest, + ) -> DelegationTaskReport { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return unknown_report(&req.task_id); + }; + let mut rename_input = serde_json::Map::new(); + rename_input.insert( + "task_id".into(), + serde_json::Value::String(req.task_id.clone()), + ); + rename_input.insert( + "continuation_id".into(), + serde_json::Value::String(req.continuation_id.clone()), + ); + self.broker + .rewrite_identityless_tool_call( + &entry.parent_connection_id, + crate::acp::delegation::CLOSE_TOOL_REWRITE_TITLE, + serde_json::Value::Object(rename_input), + ) + .await; + let parent_conversation_id = self + .parent_lookup + .current_conversation_id(&entry.parent_connection_id) + .await; + self.broker + .close_delegation_session( + &entry.parent_connection_id, + parent_conversation_id, + &req.task_id, + &req.continuation_id, + ) + .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. @@ -742,10 +833,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() } @@ -765,9 +853,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] @@ -1636,7 +1722,10 @@ mod tests { let commit_ids = resp.outcome["_commit_ids"].as_array().unwrap(); assert_eq!(commit_ids, &vec!["f1", "f2"]); // Read was scoped to the token's parent connection id. - assert_eq!(feedback.read_conn.lock().await.as_deref(), Some("parent-conn")); + assert_eq!( + feedback.read_conn.lock().await.as_deref(), + Some("parent-conn") + ); // The Feedback arm is READ-ONLY — it does NOT commit (delivery is // committed later, by the companion's CommitFeedback). assert!(feedback.committed.lock().await.is_empty()); @@ -1955,7 +2044,10 @@ mod tests { .await .expect("serve_one must return after peer close"); result.unwrap().unwrap(); - assert_eq!(questions.canceled.lock().await.as_slice(), &["q-1".to_string()]); + assert_eq!( + questions.canceled.lock().await.as_slice(), + &["q-1".to_string()] + ); } /// An invalid token never registers a question and returns a `declined` @@ -1963,7 +2055,8 @@ mod tests { #[tokio::test] async fn ask_invalid_token_declined() { let questions = Arc::new(StubQuestion::default()); - let listener = make_question_listener(Arc::new(TokenRegistry::default()), questions.clone()); + let listener = + make_question_listener(Arc::new(TokenRegistry::default()), questions.clone()); let (mut client, mut server) = duplex(8 * 1024); let server_task = tokio::spawn(async move { listener.serve_one(&mut server).await.unwrap(); @@ -1977,4 +2070,111 @@ mod tests { assert!(questions.registered.lock().await.is_empty()); } + // -- T4.2: continue_with_session / close_session dispatch --------------- + + /// The `Continue` arm dispatches through the broker under the token's + /// parent identity; an unknown task id yields an `unknown` report (no + /// existence leak). + #[tokio::test] + async fn continue_dispatch_round_trips_unknown_task() { + let broker = make_broker(Arc::new(MockSpawner::new())).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker, tokens, Some(1)); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = + BrokerMessage::Continue(crate::acp::delegation::transport::BrokerContinueRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: None, + task_id: "no-such-task".into(), + message: "carry on".into(), + continuation_id: "c-listener-1".into(), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["status"], "unknown"); + assert_eq!(resp.outcome["task_id"], "no-such-task"); + } + + /// A token whose parent doesn't match the request's claimed parent + /// connection is rejected as `unknown` — same no-leak posture as status. + #[tokio::test] + async fn continue_token_parent_mismatch_reports_unknown() { + let broker = make_broker(Arc::new(MockSpawner::new())).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "other-parent".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker, tokens, Some(1)); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = + BrokerMessage::Continue(crate::acp::delegation::transport::BrokerContinueRequest { + token: "tok".into(), + parent_connection_id: "parent-conn".into(), + parent_tool_use_id: None, + task_id: "task-x".into(), + message: "carry on".into(), + continuation_id: "c-listener-2".into(), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["status"], "unknown"); + } + + /// The `CloseSession` arm resolves the parent from the token (the wire + /// request carries no parent id, mirroring `cancel_delegation`). + #[tokio::test] + async fn close_session_dispatch_round_trips_unknown_task() { + let broker = make_broker(Arc::new(MockSpawner::new())).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_listener(broker, tokens, Some(1)); + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::CloseSession( + crate::acp::delegation::transport::BrokerCloseSessionRequest { + token: "tok".into(), + task_id: "no-such-task".into(), + continuation_id: "c-listener-3".into(), + }, + ); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["status"], "unknown"); + assert_eq!(resp.outcome["task_id"], "no-such-task"); + } } diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index 43b25450d..b7a0cb46f 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -25,10 +25,15 @@ //! parent LLM ◄── MCP tool_result ◄── DelegationOutcome ◄───┘ //! ``` //! -//! v1 is one-shot (function-call semantics): after the child's first -//! `TurnComplete`, the broker resolves the pending call, sends `disconnect` -//! to the child, and returns. v2 will introduce `continue_with_session` / -//! `close_session` tools without protocol breakage. +//! After each child turn settles, the broker keeps the child process alive +//! (completed / failed) so `continue_with_session` can send follow-ups in the +//! SAME session with full prior context. `close_session` (or cancel / parent +//! conversation deletion) RELEASES the child — frees the process and refuses +//! further continuation in this codeg run (release semantics, R2-B4: a +//! restart naturally expires the lease; permanent disposal is deleting the +//! child conversation row). Re-spawning with the child's agent `external_id` +//! covers the case where the process was reaped while the conversation row +//! still exists. pub mod broker; pub mod companion; @@ -55,3 +60,5 @@ pub mod types; pub const DELEGATE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__delegate_to_agent"; pub const STATUS_TOOL_REWRITE_TITLE: &str = "codeg-mcp__get_delegation_status"; pub const CANCEL_TOOL_REWRITE_TITLE: &str = "codeg-mcp__cancel_delegation"; +pub const CONTINUE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__continue_with_session"; +pub const CLOSE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__close_session"; diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs index c6e2501ef..30e49bba6 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -42,6 +42,17 @@ pub enum SpawnerError { Cancel(String), } +/// D3.1 continuation capability an agent self-reported on `initialize` +/// (`agent_capabilities.load_session` / `session_capabilities.resume`). Read +/// off a connection's session state; consumed by the broker's +/// `get_continuation_availability` to tell a persistent-resume agent from a +/// live-only one whose context dies with the process. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AgentContinuationCapability { + pub supports_load_session: bool, + pub supports_resume: bool, +} + /// Capabilities the delegation broker needs from whatever owns the ACP /// connections. v1 production impl is `Arc` (see /// `acp/manager.rs`); tests use `mock::MockSpawner`. @@ -98,6 +109,64 @@ pub trait ConnectionSpawner: Send + Sync { /// resolved (or failed) the pending call, to enforce v1's one-shot /// semantics. async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError>; + + /// Like [`ConnectionSpawner::spawn`], but revives a child that has already + /// existed: `session_id` is the child conversation row's persisted + /// `external_id` (the agent-assigned ACP session id), handed to + /// `session/resume` → `session/load` so the revived process keeps the + /// earlier turns' context. `None` means "no resume credential available" — + /// the impl then behaves exactly like `spawn` (cold session, context lost), + /// which the caller must surface to the user rather than silently accept. + /// + /// Passing a `session_id` also opts into `ConnectionManager`'s connection + /// dedup, so a still-live process for the same (agent, working_dir, + /// session) is reused instead of double-spawned. + /// + /// Returns the connection id to use for the next prompt. + async fn spawn_for_resume( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result; + + /// Send a follow-up prompt onto a child that ALREADY owns a conversation + /// row — i.e. every turn after the delegation's first one. + /// + /// Unlike [`ConnectionSpawner::send_prompt_linked_for_delegation`], this + /// must NOT carry a `DelegationLink`: the link is what drives the + /// create-a-new-child-row branch, so reusing it here would append a second + /// child conversation per follow-up. The impl adopts the existing row by + /// passing `conversation_id` + `folder_id` with `delegation: None`. + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), SpawnerError>; + + /// Whether `conn_id` still names a usable connection — i.e. it is + /// registered and its status is neither `Disconnected` nor `Error`. + /// The broker uses this to choose between sending straight onto a kept + /// alive connection and going through `spawn_for_resume`. Unknown ids are + /// dead (a swept / never-existing connection is equally unusable). + async fn is_alive(&self, conn_id: &str) -> bool; + + /// The continuation capability the agent on `conn_id` self-reported at + /// `initialize` (D3.1), read off its session state. `None` when the + /// connection is unknown — its state left with the process, so the caller + /// falls back to an optimistic verdict and the actual resume attempt + /// reports `resume_unavailable` if the capability was truly absent. + /// + /// Default `None` keeps every existing test spawner compiling; the + /// production impl and `MockSpawner` override it. + async fn continuation_capability(&self, _conn_id: &str) -> Option { + None + } } #[cfg(any(test, feature = "test-utils"))] @@ -117,9 +186,28 @@ pub mod mock { pub struct MockSpawner { pub spawn_results: Mutex>>, pub send_results: Mutex>>, + /// Pre-queued results for `send_followup_prompt`, staged with + /// [`MockSpawner::queue_followup`]. Kept separate from `send_results` + /// so a test can't accidentally satisfy a follow-up with a first-prompt + /// result (they return different types and mean different branches). + pub followup_results: Mutex>>, pub cancels: Mutex>, pub disconnects: Mutex>, pub spawn_args: Mutex>, + /// Every `spawn_for_resume` invocation, in call order — notably the + /// `session_id` the broker forwarded as the resume credential. + pub resume_args: Mutex>, + /// Every `send_followup_prompt` invocation, in call order. + pub followups: Mutex>, + /// Connection ids `is_alive` must report as dead. Populated by + /// [`MockSpawner::mark_dead`] (simulating idle sweep / process death) + /// and by `disconnect` (a torn-down connection cannot be alive). + pub dead_connections: Mutex>, + /// Per-connection continuation capability served by + /// `continuation_capability`. Staged with [`MockSpawner::set_capability`]; + /// connections without an entry answer `None` (state gone), matching + /// the production behavior for an unknown id. + pub capabilities: Mutex>, /// When set, `send_prompt_linked_for_delegation` awaits this receiver /// before returning — lets a test hold `handle_request` in the window /// AFTER it has reserved the child (post-spawn) but BEFORE it parks the @@ -143,6 +231,29 @@ pub mod mock { pub preferred_config_values: BTreeMap, } + /// Recorded `spawn_for_resume` call. Same shape as [`SpawnCallArgs`] plus + /// the `session_id` resume credential, which is the whole reason the + /// resume variant exists. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ResumeCallArgs { + pub parent_connection_id: String, + pub agent_type: AgentType, + pub working_dir: Option, + pub session_id: Option, + pub preferred_mode_id: Option, + pub preferred_config_values: BTreeMap, + } + + /// Recorded `send_followup_prompt` call. `conversation_id` + `folder_id` + /// let a test assert the follow-up adopted the existing child row. + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct FollowupCallArgs { + pub conn_id: String, + pub message: String, + pub conversation_id: i32, + pub folder_id: i32, + } + impl MockSpawner { pub fn new() -> Self { Self::default() @@ -156,6 +267,29 @@ pub mod mock { self.send_results.lock().await.push_back(r); } + /// Stage the next `send_followup_prompt` outcome. + pub async fn queue_followup(&self, r: Result<(), SpawnerError>) { + self.followup_results.lock().await.push_back(r); + } + + /// Bury `conn_id`: subsequent `is_alive` calls report false. Simulates + /// idle sweep / agent process death without a real ACP connection. + pub async fn mark_dead(&self, conn_id: &str) { + let mut dead = self.dead_connections.lock().await; + if !dead.iter().any(|c| c == conn_id) { + dead.push(conn_id.to_string()); + } + } + + /// Stage the capability `continuation_capability` reports for + /// `conn_id`. Unset connections answer `None` (unknown id). + pub async fn set_capability(&self, conn_id: &str, cap: AgentContinuationCapability) { + self.capabilities + .lock() + .await + .insert(conn_id.to_string(), cap); + } + /// Install a one-shot gate that holds the next /// `send_prompt_linked_for_delegation` until the returned sender fires. /// Used to deterministically pin `handle_request` in the @@ -235,8 +369,76 @@ pub mod mock { async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { self.disconnects.lock().await.push(conn_id.to_string()); + // A torn-down connection must also stop reporting alive, otherwise + // a broker test could exercise the impossible + // "disconnected but still sendable" state. + self.mark_dead(conn_id).await; Ok(()) } + + async fn spawn_for_resume( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.resume_args.lock().await.push(ResumeCallArgs { + parent_connection_id: parent_connection_id.to_string(), + agent_type, + working_dir, + session_id, + preferred_mode_id, + preferred_config_values, + }); + // Shares the `spawn_results` queue: from a broker test's point of + // view both paths "produce the next child connection id", and the + // recorded args (`spawn_args` vs `resume_args`) already distinguish + // which path ran. + self.spawn_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued spawn result".into()))) + } + + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), SpawnerError> { + self.followups.lock().await.push(FollowupCallArgs { + conn_id: conn_id.to_string(), + message, + conversation_id, + folder_id, + }); + self.followup_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Send("no queued followup result".into()))) + } + + async fn is_alive(&self, conn_id: &str) -> bool { + !self + .dead_connections + .lock() + .await + .iter() + .any(|c| c == conn_id) + } + + async fn continuation_capability( + &self, + conn_id: &str, + ) -> Option { + self.capabilities.lock().await.get(conn_id).copied() + } } #[cfg(test)] @@ -316,5 +518,84 @@ pub mod mock { assert_eq!(args[0].preferred_mode_id.as_deref(), Some("auto")); assert_eq!(args[0].preferred_config_values, cfg); } + + /// `spawn_for_resume` must record the resume credential it was handed + /// (`session_id`) so broker tests can assert the `external_id` from the + /// DB row is actually forwarded down to `spawn_agent` — the whole point + /// of the resume path. + #[tokio::test] + async fn mock_spawn_for_resume_records_session_id() { + let m = MockSpawner::new(); + m.queue_spawn(Ok("revived-1".into())).await; + let id = m + .spawn_for_resume( + "p1", + AgentType::ClaudeCode, + Some("/work".into()), + Some("ext-abc".into()), + None, + BTreeMap::new(), + ) + .await + .unwrap(); + assert_eq!(id, "revived-1"); + let args = m.resume_args.lock().await; + assert_eq!(args.len(), 1); + assert_eq!(args[0].session_id.as_deref(), Some("ext-abc")); + assert_eq!(args[0].working_dir.as_deref(), Some("/work")); + // The plain `spawn` recorder must stay untouched so existing + // assertions on fresh spawns can't be satisfied by a resume. + assert!(m.spawn_args.lock().await.is_empty()); + } + + /// `send_followup_prompt` records (conn, message, conversation_id, + /// folder_id) so a broker test can assert the follow-up adopted the + /// EXISTING child conversation row (Branch A) instead of creating one. + #[tokio::test] + async fn mock_send_followup_prompt_records_call() { + let m = MockSpawner::new(); + m.queue_followup(Ok(())).await; + m.send_followup_prompt("child-1", "round two".into(), 42, 7) + .await + .unwrap(); + let calls = m.followups.lock().await; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].conn_id, "child-1"); + assert_eq!(calls[0].message, "round two"); + assert_eq!(calls[0].conversation_id, 42); + assert_eq!(calls[0].folder_id, 7); + } + + #[tokio::test] + async fn mock_unqueued_followup_fails_loudly() { + let m = MockSpawner::new(); + let err = m + .send_followup_prompt("child-1", "hi".into(), 1, 2) + .await + .unwrap_err(); + match err { + SpawnerError::Send(msg) => assert!(msg.contains("no queued")), + other => panic!("expected SpawnerError::Send, got {other:?}"), + } + } + + /// A connection is alive until it is explicitly buried — either by the + /// test (`mark_dead`, simulating idle sweep / process death) or by a + /// real `disconnect`, which must bury it as a side effect so the broker + /// can't be tested against an impossible "disconnected but alive" state. + #[tokio::test] + async fn mock_is_alive_false_after_mark_dead_or_disconnect() { + let m = MockSpawner::new(); + assert!(m.is_alive("c-live").await); + + m.mark_dead("c-swept").await; + assert!(!m.is_alive("c-swept").await); + + m.disconnect("c-gone").await.unwrap(); + assert!(!m.is_alive("c-gone").await); + + // Unrelated connections are unaffected. + assert!(m.is_alive("c-live").await); + } } } diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 1bfe5485c..53fb17ee7 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). After a task finishes (completed or failed), prefer continue_with_session(task_id, message, continuation_id) to send a follow-up in THAT SAME child session (keeps the sub-agent's prior context and avoids redoing work) instead of calling delegate_to_agent again. Use close_session(task_id, continuation_id) when you are done with a child and want to free it. The sub-agent CANNOT see this conversation, your open files, or earlier turns on the FIRST turn — 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.", "inputSchema": { "type": "object", "required": ["agent_type", "task"], @@ -37,7 +37,7 @@ }, { "name": "get_delegation_status", - "description": "Collect delegated task results — or check on them — by the task_ids from delegate_to_agent. Pass a single id in the array to poll one task, or many at once to poll a whole fan-out in a single call. The response is ALWAYS a JSON object {\"tasks\":[...]} with one entry per id, in the order you asked — one entry for a single id, several for a fan-out; every entry carries that task's task_id and status, so you can always tell which task it is and where it stands. The wait_ms argument picks the mode: omit it for a non-blocking snapshot (poll), or pass wait_ms to wait — wait_ms>0 blocks up to that many ms (capped at 60000; re-call to keep waiting), wait_ms=0 blocks with no timeout for as long as the sub-agent runs. When polling several, the wait returns as soon as ANY requested task reaches a terminal state, so re-call to keep collecting the rest. When you just need the results, prefer blocking over repeated polling. Each task reports its current state — running, or a terminal completed / failed / canceled (or unknown if the id isn't recognized); a finished task includes its full result text. While tasks are still running and you are simply waiting, do NOT emit any user-facing message between calls — just call again silently. Skip status narration like \"still running\", \"not returned yet\", or \"continuing to wait\"; it fragments the conversation. Speak to the user only when there is a terminal result to report or something genuinely needs their input.", + "description": "Collect delegated task results — or check on them — by the task_ids from delegate_to_agent or continue_with_session. Pass a single id in the array to poll one task, or many at once to poll a whole fan-out in a single call. The response is ALWAYS a JSON object {\"tasks\":[...]} with one entry per id, in the order you asked — one entry for a single id, several for a fan-out; every entry carries that task's task_id and status, so you can always tell which task it is and where it stands. The wait_ms argument picks the mode: omit it for a non-blocking snapshot (poll), or pass wait_ms to wait — wait_ms>0 blocks up to that many ms (capped at 60000; re-call to keep waiting), wait_ms=0 blocks with no timeout for as long as the sub-agent runs. When polling several, the wait returns as soon as ANY requested task reaches a terminal state, so re-call to keep collecting the rest. When you just need the results, prefer blocking over repeated polling. Each task reports its current state — running, or a terminal completed / failed / canceled (or unknown if the id isn't recognized); a finished task includes its full result text. After completed/failed, prefer continue_with_session(task_id, message, continuation_id) for follow-ups instead of a new delegate_to_agent. While tasks are still running and you are simply waiting, do NOT emit any user-facing message between calls — just call again silently. Skip status narration like \"still running\", \"not returned yet\", or \"continuing to wait\"; it fragments the conversation. Speak to the user only when there is a terminal result to report or something genuinely needs their input.", "inputSchema": { "type": "object", "required": ["task_ids"], @@ -58,7 +58,7 @@ }, { "name": "cancel_delegation", - "description": "Stop a running delegated task by its task_id and tear down its sub-agent — use this only when you no longer want the result. Don't cancel just because a task is taking a while: substantial work can legitimately run for a long time, so prefer waiting on it (get_delegation_status with wait_ms) over canceling. If the task has already finished, nothing is canceled and its final result is returned instead.", + "description": "Stop a running delegated task by its task_id and tear down its sub-agent — use this only when you no longer want the result. Don't cancel just because a task is taking a while: substantial work can legitimately run for a long time, so prefer waiting on it (get_delegation_status with wait_ms) over canceling. If the task has already finished, nothing is canceled and its final result is returned instead. After cancel, you may still try continue_with_session if the child conversation remains (best-effort resume); use close_session only when you want to release the child for good in this run.", "inputSchema": { "type": "object", "required": ["task_id"], @@ -70,6 +70,46 @@ } } }, + { + "name": "continue_with_session", + "description": "Send a follow-up message into an EXISTING delegated sub-agent session (same task_id from delegate_to_agent) so it continues with full prior context — do NOT start a new delegate_to_agent for the same work. Use this when a previous delegation completed, failed partway, or needs another step (fix, refine, finish remaining items). ASYNCHRONOUS: returns a Running ack for the same task_id immediately; collect the new turn's result with get_delegation_status. Rejects if the task is still running (wait or cancel first), was released with close_session, or is unknown to this parent. Prefer this over re-delegating to avoid redoing exploration and burning tokens.", + "inputSchema": { + "type": "object", + "required": ["task_id", "message", "continuation_id"], + "properties": { + "task_id": { + "type": "string", + "description": "The task_id returned by delegate_to_agent (or still shown by get_delegation_status)." + }, + "message": { + "type": "string", + "description": "The follow-up prompt for the SAME sub-agent session. It already has the prior turns of that session — only send what is new (corrections, next steps, remaining work)." + }, + "continuation_id": { + "type": "string", + "description": "An idempotency id YOU mint for this specific request — generate a fresh unique value (e.g. a UUID) per follow-up. If you retry THIS EXACT request after an error or timeout, pass the SAME id so the follow-up is not dispatched twice. Never reuse an id for a different message." + } + } + } + }, + { + "name": "close_session", + "description": "Release a delegated sub-agent session by task_id: cancel if still running, free the child process, and mark the session released so continue_with_session will refuse it for the rest of this codeg run. The child conversation and its transcript are kept — this frees resources, it does not delete anything. Use when you no longer need that child (fan-out cleanup, abandoned branch). Prefer leaving sessions open if you may continue them. If already finished and released, returns the last known status.", + "inputSchema": { + "type": "object", + "required": ["task_id", "continuation_id"], + "properties": { + "task_id": { + "type": "string", + "description": "The task_id returned by delegate_to_agent." + }, + "continuation_id": { + "type": "string", + "description": "An idempotency id YOU mint for this specific request — a fresh unique value (e.g. a UUID). If you retry THIS close after an error or timeout, pass the SAME id so the result is replayed instead of re-executed." + } + } + } + }, { "name": "check_user_feedback", "description": "IMPORTANT: This tool gives you the ability to receive the user's real-time guidance while you work, preventing you from going down the wrong path and wasting effort. The user can type corrections, new requirements, or hints at any time during your turn — but you will NEVER see them unless you call this tool. Without it, you could spend minutes on work the user has already tried to redirect.\n\nWhen to call:\n- After thinking through your approach but BEFORE starting implementation — the user may have clarified requirements.\n- Before making a significant decision (choosing an architecture, picking which files to modify, deciding on an approach) — the user may prefer a different direction.\n- After completing a sub-task and before moving to the next step — the user may want to adjust priorities.\n- When you pause to plan or organize your next steps — a natural checkpoint to read any pending guidance.\n\nThis is a quick, non-blocking snapshot that returns instantly. It either returns the user's messages (treat as HIGH-PRIORITY steering and adjust immediately) or an empty result (continue your current plan). Call it proactively — do not wait to be asked.", diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 41b81728a..54fd62e48 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -183,6 +183,37 @@ pub struct BrokerSessionRequest { pub max_messages: Option, } +/// Continue a previously-settled delegated child with a follow-up message. +/// Backs `continue_with_session`. Authenticated like `Call`; returns a +/// [`super::types::DelegationTaskReport`] (usually a Running ack). +/// +/// `continuation_id` is the caller-generated idempotency key and is REQUIRED +/// at the wire level (no serde default — R2-B5: the listener never mints one, +/// otherwise a retried request could not be recognized as a retry). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerContinueRequest { + pub token: String, + pub parent_connection_id: String, + /// Optional ACP tool_use_id for the continue MCP call (identity rewrite). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_tool_use_id: Option, + pub task_id: String, + pub message: String, + pub continuation_id: String, +} + +/// RELEASE a delegated child session (R2-B4 release semantics — the wire tool +/// name stays `close_session` for upstream PR #375 compatibility, but the +/// child is freed, not permanently retired; a restart naturally expires the +/// lease). Backs `close_session`. `continuation_id` is REQUIRED (same ledger +/// as `Continue` — a replayed close returns the first report). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerCloseSessionRequest { + pub token: String, + pub task_id: String, + pub continuation_id: String, +} + /// Tagged top-level message dispatched by the listener. Adding new variants /// is the wire-stable way to grow the broker protocol without touching the /// frame layer. @@ -193,6 +224,8 @@ pub enum BrokerMessage { Cancel(BrokerCancelRequest), Status(BrokerStatusRequest), CancelTask(BrokerCancelTaskRequest), + Continue(BrokerContinueRequest), + CloseSession(BrokerCloseSessionRequest), Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), @@ -302,6 +335,22 @@ pub async fn client_cancel_task_round_trip( message_round_trip(socket_path, &BrokerMessage::CancelTask(req.clone())).await } +/// Dispatch a `continue_with_session` request and read back the task report. +pub async fn client_continue_round_trip( + socket_path: &str, + req: &BrokerContinueRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::Continue(req.clone())).await +} + +/// Dispatch a `close_session` request and read back the task report. +pub async fn client_close_session_round_trip( + socket_path: &str, + req: &BrokerCloseSessionRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::CloseSession(req.clone())).await +} + /// Dispatch a `check_user_feedback` query and read back the /// `{ "feedback": [..], "count": N }` envelope (the pending notes drained for /// the parent session, possibly empty). @@ -588,3 +637,82 @@ mod tests { server.await.unwrap(); } } + +#[cfg(test)] +mod continue_close_wire_tests { + use super::*; + use tokio::io::duplex; + + /// T4.2 red: `continue_with_session` rides a tagged `Continue` variant + /// carrying the REQUIRED caller-generated `continuation_id` (R2-B5). + #[tokio::test] + async fn continue_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let msg = BrokerMessage::Continue(BrokerContinueRequest { + token: "tok".into(), + parent_connection_id: "p1".into(), + parent_tool_use_id: Some("pt1".into()), + task_id: "task-9".into(), + message: "carry on".into(), + continuation_id: "c-1".into(), + }); + write_frame(&mut a, &msg).await.unwrap(); + let got: BrokerMessage = read_frame(&mut b).await.unwrap(); + match got { + BrokerMessage::Continue(req) => { + assert_eq!(req.token, "tok"); + assert_eq!(req.parent_connection_id, "p1"); + assert_eq!(req.parent_tool_use_id.as_deref(), Some("pt1")); + assert_eq!(req.task_id, "task-9"); + assert_eq!(req.message, "carry on"); + assert_eq!(req.continuation_id, "c-1"); + } + other => panic!("expected Continue variant, got {other:?}"), + } + } + + #[tokio::test] + async fn close_session_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let msg = BrokerMessage::CloseSession(BrokerCloseSessionRequest { + token: "tok".into(), + task_id: "task-9".into(), + continuation_id: "c-2".into(), + }); + write_frame(&mut a, &msg).await.unwrap(); + let got: BrokerMessage = read_frame(&mut b).await.unwrap(); + match got { + BrokerMessage::CloseSession(req) => { + assert_eq!(req.token, "tok"); + assert_eq!(req.task_id, "task-9"); + assert_eq!(req.continuation_id, "c-2"); + } + other => panic!("expected CloseSession variant, got {other:?}"), + } + } + + /// R2-B5: the listener never mints a `continuation_id` on the caller's + /// behalf — the wire type makes an absent id a hard decode failure, so a + /// skipped retry can never be silently accepted as a fresh operation. + #[test] + fn continue_request_missing_continuation_id_fails_to_decode() { + let raw = serde_json::json!({ + "kind": "continue", + "token": "tok", + "parent_connection_id": "p1", + "task_id": "task-9", + "message": "carry on", + }); + assert!(serde_json::from_value::(raw).is_err()); + } + + #[test] + fn close_request_missing_continuation_id_fails_to_decode() { + let raw = serde_json::json!({ + "kind": "close_session", + "token": "tok", + "task_id": "task-9", + }); + assert!(serde_json::from_value::(raw).is_err()); + } +} diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index 416351fa8..1ce5bd157 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -125,6 +125,51 @@ pub enum DelegationError { Canceled { reason: String }, #[error("parent session is gone")] ParentSessionGone, + /// `continue_with_session` targeted a task whose current turn is still + /// running (Requirement 2.4). + #[error("subagent session is still running; wait for it or cancel first")] + SessionStillRunning, + /// The session was RELEASED by `close_session` / parent-conversation + /// deletion (R2-B4 release semantics — not a permanent close; a process + /// restart naturally expires the lease). Upstream PR #375 calls this + /// `session_closed`; see [`canonical_continuation_code`]. + #[error("subagent session was released; start a new delegation for further work")] + SessionReleased, + /// The task exists but cannot be continued: empty message, missing + /// resume credential after process death, binding mismatch, etc. + /// (Requirements 3.2, 7.6). + #[error("subagent session cannot be continued: {0}")] + NotContinuable(String), + /// The resume chain cannot restore the prior context. Returned BEFORE any + /// prompt side effect — never silently degraded to a context-losing + /// `session/new` (Requirements 3.3, 7.7). + #[error("resume unavailable: {0}")] + ResumeUnavailable(String), + /// The same `continuation_id` was reused with a different payload + /// (Requirement 2.13 — idempotency keys bind to one exact request). + #[error("continuation_id was already used with a different payload")] + ContinuationConflict, + /// Retryable startup state: the broker is still rebuilding its session + /// index (Requirement 7.2) — answering `Unknown` would read as "your + /// session is lost", so this distinct code tells callers to retry. + #[error("the broker is rebuilding its session index after startup; retry shortly")] + Rebuilding, +} + +/// Historical alias accepted on the facade (R2-B4): upstream PR #375 named the +/// released state `session_closed`; this build reports `session_released`. +/// Anything comparing / routing on continuation error codes should normalize +/// through [`canonical_continuation_code`] instead of matching the alias. +pub const SESSION_CLOSED_LEGACY_ALIAS: &str = "session_closed"; + +/// Map a wire error code to its canonical form: the upstream `session_closed` +/// alias folds into `session_released`; every other code passes through. +pub fn canonical_continuation_code(code: &str) -> &str { + if code == SESSION_CLOSED_LEGACY_ALIAS { + "session_released" + } else { + code + } } /// The single value the broker hands back to the listener / MCP companion. @@ -204,6 +249,18 @@ pub struct DelegationTaskReport { pub duration_ms: Option, } +impl DelegationTaskReport { + /// Attach a known `task_id` onto a setup-style error report that was built + /// without one (e.g. continue/close failures after the id is already + /// known). Never clobbers an id the report already carries. + pub fn with_task_id(mut self, task_id: &str) -> Self { + if self.task_id.is_none() { + self.task_id = Some(task_id.to_string()); + } + self + } +} + impl DelegationOutcome { /// Project a `DelegationError` onto the wire-stable `code` string used by /// the frontend and MCP companion. Keep these strings stable — they ship @@ -222,6 +279,12 @@ impl DelegationOutcome { DelegationError::ChildUnknown(_) => "child_unknown", DelegationError::Canceled { .. } => "canceled", DelegationError::ParentSessionGone => "canceled", + DelegationError::SessionStillRunning => "session_still_running", + DelegationError::SessionReleased => "session_released", + DelegationError::NotContinuable(_) => "not_continuable", + DelegationError::ResumeUnavailable(_) => "resume_unavailable", + DelegationError::ContinuationConflict => "continuation_conflict", + DelegationError::Rebuilding => "rebuilding", }; DelegationOutcome::Err { code: code.to_string(), @@ -230,3 +293,96 @@ impl DelegationOutcome { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// T4.1 red: the five continuation error codes (plus `rebuilding`) must be + /// typed `DelegationError` variants with wire-stable `from_err` codes — + /// not broker-local string constants. + #[test] + fn from_err_maps_continuation_variants_to_wire_codes() { + let cases: Vec<(DelegationError, &str)> = vec![ + ( + DelegationError::SessionStillRunning, + "session_still_running", + ), + (DelegationError::SessionReleased, "session_released"), + ( + DelegationError::NotContinuable("x".into()), + "not_continuable", + ), + ( + DelegationError::ResumeUnavailable("y".into()), + "resume_unavailable", + ), + ( + DelegationError::ContinuationConflict, + "continuation_conflict", + ), + (DelegationError::Rebuilding, "rebuilding"), + ]; + for (err, want) in cases { + match DelegationOutcome::from_err(err, None) { + DelegationOutcome::Err { code, .. } => assert_eq!(code, want), + other => panic!("expected Err outcome, got {other:?}"), + } + } + } + + /// R2-B4: the facade accepts upstream PR #375's `session_closed` as a + /// historical alias for `session_released`; every other code is passed + /// through canonical unchanged. + #[test] + fn session_closed_is_accepted_as_alias_for_session_released() { + assert_eq!( + canonical_continuation_code("session_closed"), + "session_released" + ); + assert_eq!( + canonical_continuation_code("session_released"), + "session_released" + ); + assert_eq!( + canonical_continuation_code("not_continuable"), + "not_continuable" + ); + } + + /// `with_task_id` attaches a known id onto a setup-style report built + /// without one, and never clobbers an existing id. + #[test] + fn with_task_id_fills_only_missing_ids() { + let report = DelegationTaskReport { + task_id: None, + status: TaskStatus::Failed, + child_conversation_id: None, + agent_type: None, + text: None, + error_code: Some("not_continuable".into()), + message: None, + duration_ms: None, + }; + let filled = report.with_task_id("t-1"); + assert_eq!(filled.task_id.as_deref(), Some("t-1")); + let kept = filled.with_task_id("t-2"); + assert_eq!(kept.task_id.as_deref(), Some("t-1")); + } + + /// The detail payloads ride the Display string so per-site context + /// survives the typed funnel (broker call sites embed the reason). + #[test] + fn detail_variants_carry_reason_in_message() { + let out = DelegationOutcome::from_err( + DelegationError::NotContinuable("message is empty".into()), + None, + ); + match out { + DelegationOutcome::Err { message, .. } => { + assert!(message.contains("message is empty"), "got: {message}"); + } + other => panic!("expected Err outcome, got {other:?}"), + } + } +} diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 7fb050810..12ad1f187 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -186,7 +186,17 @@ pub(crate) async fn handle_event( }; let conversation_id = state_arc.read().await.conversation_id; if let Some(cid) = conversation_id { - conversation_service::update_external_id(db_conn, cid, session_id.clone()).await?; + // Resume-safe (Requirement 3.3a): a Delegate child row's + // credential is never overwritten by a differing session id — + // that shape only arises from the resume chain's silent + // `session/new` fallback, and recording it would permanently + // destroy the resume credential. Root rows update as before. + conversation_service::update_external_id_resume_safe( + db_conn, + cid, + session_id.clone(), + ) + .await?; // The external_id just landed on the row. The create-time // sidebar upsert carried `external_id: null` (no session yet), // so re-broadcast the full summary on `conversation://changed` diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 17e2c820b..77c7ae2de 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -15,8 +15,8 @@ use crate::acp::connection::{ }; use crate::acp::error::AcpError; use crate::acp::feedback::{ - bounded_feedback_batch, FeedbackItem, FeedbackStatus, PendingFeedback, - SessionFeedbackAccess, MAX_FEEDBACK_CHARS, MAX_FEEDBACK_RESPONSE_BYTES, + bounded_feedback_batch, FeedbackItem, FeedbackStatus, PendingFeedback, SessionFeedbackAccess, + MAX_FEEDBACK_CHARS, MAX_FEEDBACK_RESPONSE_BYTES, }; use crate::acp::plan_approval::{ PlanApprovalAnswer, RegisteredPlanApproval, SessionPlanApprovalAccess, @@ -449,7 +449,9 @@ impl ConnectionManager { let connection_id = uuid::Uuid::new_v4().to_string(); tracing::info!( "[ACP] spawning connection id={} owner_window={} agent={:?}", - connection_id, owner_window_label, agent_type + connection_id, + owner_window_label, + agent_type ); // `spawn_agent_connection` inserts the entry into `self.connections`, @@ -617,7 +619,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 } @@ -994,7 +1001,12 @@ impl ConnectionManager { (s.conversation_id, s.external_id.clone()) }; if let (Some(cid), Some(eid)) = (cid_opt, eid_opt) { - conversation_service::update_external_id(&db.conn, cid, eid) + // Resume-safe (Requirement 3.3a): this sync snapshot write also + // runs on the continuation follow-up path (Branch A adoption via + // `send_followup_prompt`), where `state.external_id` may carry a + // fallback `session/new` id — a Delegate row's existing + // credential must win over it. + conversation_service::update_external_id_resume_safe(&db.conn, cid, eid) .await .map_err(|e| AcpError::protocol(e.to_string()))?; // SessionStarted arrived BEFORE this link, so the lifecycle @@ -1446,7 +1458,9 @@ impl ConnectionManager { // Surface failures even when the caller is gone (the detached task's // Result would otherwise be dropped silently). if let Err(ref e) = outcome { - tracing::error!("[ACP][ERROR] fork persistence failed (conn={conn_id_for_task}): {e}"); + tracing::error!( + "[ACP][ERROR] fork persistence failed (conn={conn_id_for_task}): {e}" + ); } outcome }); @@ -1823,7 +1837,8 @@ impl ConnectionManager { } tracing::info!( "[ACP] disconnect by owner window owner_window={} count={}", - owner_window_label, disconnected + owner_window_label, + disconnected ); disconnected } @@ -1865,8 +1880,7 @@ impl ConnectionManager { let mut out = Vec::new(); for (id, conn) in connections.iter() { let state = conn.state.read().await; - let (Some(conversation_id), Some(folder_id)) = - (state.conversation_id, state.folder_id) + let (Some(conversation_id), Some(folder_id)) = (state.conversation_id, state.folder_id) else { continue; }; @@ -1965,11 +1979,8 @@ impl ConnectionManager { if !state.read().await.feedback_tool_available { return Err(AcpError::FeedbackDisabled); } - 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 @@ -2151,7 +2162,12 @@ impl ConnectionManager { state: &std::sync::Arc>, emitter: &EventEmitter, ) -> bool { - if self.pending_questions.lock().await.contains_key(question_id) { + if self + .pending_questions + .lock() + .await + .contains_key(question_id) + { return false; } emit_with_state( @@ -2189,7 +2205,9 @@ impl ConnectionManager { // (peer-close) at the same instant; the resolved-event below still clears // the card. let _ = entry.sender.send(outcome); - if let Some((state, emitter)) = self.get_state_and_emitter(&entry.parent_connection_id).await + if let Some((state, emitter)) = self + .get_state_and_emitter(&entry.parent_connection_id) + .await { emit_with_state( &state, @@ -2214,7 +2232,9 @@ impl ConnectionManager { let Some(entry) = removed else { return; }; - if let Some((state, emitter)) = self.get_state_and_emitter(&entry.parent_connection_id).await + if let Some((state, emitter)) = self + .get_state_and_emitter(&entry.parent_connection_id) + .await { emit_with_state( &state, @@ -2335,7 +2355,12 @@ impl ConnectionManager { state: &std::sync::Arc>, emitter: &EventEmitter, ) -> bool { - if self.pending_plan_approvals.lock().await.contains_key(approval_id) { + if self + .pending_plan_approvals + .lock() + .await + .contains_key(approval_id) + { return false; } emit_with_state( @@ -2372,8 +2397,9 @@ impl ConnectionManager { // (teardown) at the same instant; the resolved event below still clears // the card. let _ = entry.sender.send(answer); - if let Some((state, emitter)) = - self.get_state_and_emitter(&entry.parent_connection_id).await + if let Some((state, emitter)) = self + .get_state_and_emitter(&entry.parent_connection_id) + .await { emit_with_state( &state, @@ -2412,8 +2438,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; } } } @@ -2519,13 +2549,20 @@ pub struct ConnectionManagerSpawner { pub data_dir: Arc, } -#[async_trait::async_trait] -impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpawner { - async fn spawn( +impl ConnectionManagerSpawner { + /// Shared body of `spawn` (fresh child, `session_id: None`) and + /// `spawn_for_resume` (revive a known child by its persisted + /// `external_id`). Everything except the resume credential is identical: + /// the child must inherit the parent's emitter / owner window / working_dir + /// and get the same codeg-built runtime env, otherwise it emits events no + /// frontend sees and skips the user's agent configuration. + #[allow(clippy::too_many_arguments)] + async fn spawn_child_inner( &self, parent_connection_id: &str, agent_type: AgentType, working_dir: Option, + session_id: Option, preferred_mode_id: Option, preferred_config_values: BTreeMap, ) -> Result { @@ -2572,7 +2609,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa .spawn_agent( agent_type, effective_working_dir, - None, + session_id, runtime_env, owner_window, emitter, @@ -2582,6 +2619,49 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa .await .map_err(|e| SpawnerError::Spawn(e.to_string())) } +} + +#[async_trait::async_trait] +impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpawner { + async fn spawn( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + // Delegation children are brand-new sessions: no resume credential. + self.spawn_child_inner( + parent_connection_id, + agent_type, + working_dir, + None, + preferred_mode_id, + preferred_config_values, + ) + .await + } + + async fn spawn_for_resume( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.spawn_child_inner( + parent_connection_id, + agent_type, + working_dir, + session_id, + preferred_mode_id, + preferred_config_values, + ) + .await + } async fn send_prompt_linked_for_delegation( &self, @@ -2652,6 +2732,65 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa .await .map_err(|e| crate::acp::delegation::spawner::SpawnerError::Disconnect(e.to_string())) } + + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + use crate::acp::delegation::spawner::SpawnerError; + // Branch A (adopt the caller's existing row): pass both + // `conversation_id` and `folder_id`, and `delegation: None`. Passing a + // `DelegationLink` here would be rejected outright (the manager refuses + // link + caller conversation_id) and, on the create-row branch, would + // append a SECOND child conversation row per follow-up. The child's + // delegation linkage was already persisted on its first prompt. + self.manager + .send_prompt_linked( + &self.db, + conn_id, + vec![PromptInputBlock::Text { text: message }], + Some(folder_id), + Some(conversation_id), + None, + ) + .await + .map_err(|e| SpawnerError::Send(e.to_string()))?; + Ok(()) + } + + async fn is_alive(&self, conn_id: &str) -> bool { + let Some(state) = self.manager.get_state(conn_id).await else { + // Unknown id: swept, disconnected-and-removed, or never existed. + return false; + }; + let status = state.read().await.status.clone(); + !matches!( + status, + crate::acp::types::ConnectionStatus::Disconnected + | crate::acp::types::ConnectionStatus::Error + ) + } + + async fn continuation_capability( + &self, + conn_id: &str, + ) -> Option { + // D3.1: the agent self-reported these on `initialize`; connection.rs + // stored them into the SessionState next to its capability log line. + // An unknown id (state removed with the process) answers None — the + // capability is then unknowable, not "unsupported". + let state = self.manager.get_state(conn_id).await?; + let s = state.read().await; + Some( + crate::acp::delegation::spawner::AgentContinuationCapability { + supports_load_session: s.agent_supports_load_session, + supports_resume: s.agent_supports_resume, + }, + ) + } } /// Production impl of `ParentSessionLookup` for the delegation listener. @@ -2685,10 +2824,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 @@ -3653,10 +3789,10 @@ mod tests { // Empty / whitespace / image-only prompts seed no title (stays NULL, // backfilled on first detail load as before). assert!(delegation_child_title_seed(&[]).is_none()); - assert!( - delegation_child_title_seed(&[PromptInputBlock::Text { text: " \n ".into() }]) - .is_none() - ); + assert!(delegation_child_title_seed(&[PromptInputBlock::Text { + text: " \n ".into() + }]) + .is_none()); let img = vec![PromptInputBlock::Image { data: "x".into(), mime_type: "image/png".into(), @@ -4985,7 +5121,10 @@ mod tests { .unwrap(); let (mgr, join) = manager_with_fake_fork("c-restack", pre.id, "session-S2", "session-S1").await; - let result = mgr.fork_session(&db, "c-restack", None, None).await.unwrap(); + let result = mgr + .fork_session(&db, "c-restack", None, None) + .await + .unwrap(); let _ = join.await; let current = conversation_service::get_by_id(&db.conn, pre.id) @@ -5597,7 +5736,11 @@ mod tests { let at_bound = "y".repeat(MAX_FEEDBACK_CHARS); assert!(mgr.submit_feedback("c1", at_bound).await.is_ok()); let state = mgr.get_state("c1").await.unwrap(); - assert_eq!(state.read().await.feedback.len(), 1, "only the valid note stuck"); + assert_eq!( + state.read().await.feedback.len(), + 1, + "only the valid note stuck" + ); } // --- ask_user_question: register / answer / cancel ------------------- @@ -5907,7 +6050,12 @@ mod tests { // The first is still the pending one and still answerable. let state = mgr.get_state("cc2").await.unwrap(); assert_eq!( - state.read().await.pending_question.as_ref().map(|p| p.question_id.clone()), + state + .read() + .await + .pending_question + .as_ref() + .map(|p| p.question_id.clone()), Some(first.question_id.clone()) ); mgr.answer_question( @@ -5943,12 +6091,7 @@ mod tests { assert_eq!(texts, vec!["a", "b"]); // A second read still returns them — read is non-destructive, so an // abandoned (peer-closed) call leaves the notes retryable. - assert_eq!( - mgr.read_pending_feedback("c1") - .await - .len(), - 2 - ); + assert_eq!(mgr.read_pending_feedback("c1").await.len(), 2); { let state = mgr.get_state("c1").await.unwrap(); assert!(state @@ -5963,10 +6106,7 @@ mod tests { mgr.commit_feedback_delivered("c1", vec![a.id.clone(), b.id.clone()]) .await; // Now READ returns nothing (delivered notes are filtered out). - assert!(mgr - .read_pending_feedback("c1") - .await - .is_empty()); + assert!(mgr.read_pending_feedback("c1").await.is_empty()); let state = mgr.get_state("c1").await.unwrap(); assert!(state .read() @@ -5982,12 +6122,9 @@ mod tests { #[tokio::test] async fn read_pending_missing_connection_returns_empty() { let mgr = ConnectionManager::new(); - assert!(mgr - .read_pending_feedback("nope") - .await - .is_empty()); + assert!(mgr.read_pending_feedback("nope").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; } - } diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index f172031e9..ef785fa1f 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -373,6 +373,18 @@ pub struct SessionState { /// (possibly later-toggled) global setting. pub feedback_tool_available: bool, + /// Continuation capabilities the agent SELF-REPORTED in its `initialize` + /// response (design D3.1): `agent_capabilities.load_session`, + /// `session_capabilities.resume` (Claude's raw-meta resume), and + /// `session_capabilities.fork`. Stored — not just logged — so the broker / + /// the user-side continuation-availability query can classify a child as + /// `Resumable` (resume or load available) vs `LiveOnly` without static + /// tables or failure probing. Fixed for the connection's lifetime (agent + /// capabilities can't change after the handshake). + pub agent_supports_load_session: bool, + pub agent_supports_resume: bool, + pub agent_supports_fork: bool, + /// Concatenated text content of the just-completed turn's assistant /// message. Captured at TurnComplete (just before live_message is /// cleared) so the lifecycle subscriber can surface it as the @@ -479,6 +491,9 @@ impl SessionState { recent_events: RecentEventsBuffer::new(), delegation_token: None, feedback_tool_available: false, + agent_supports_load_session: false, + agent_supports_resume: false, + agent_supports_fork: false, last_assistant_text: None, pending_user_message: None, pending_user_message_started_at: None, @@ -984,11 +999,14 @@ impl SessionState { AcpEvent::ClaudeSdkMessage { .. } | AcpEvent::SessionLoadFailed { .. } | AcpEvent::TurnRetrying { .. } - | AcpEvent::UserPromptSent { .. } => { + | AcpEvent::UserPromptSent { .. } + | AcpEvent::DelegationSessionUpdate { .. } => { // 这些事件不直接修改 SessionState 的可见字段。 // UserPromptSent 是纯通知事件,仅供 chat-channel 推送消费。 // TurnRetrying 与 Claude 的 api_retry 一样是前端瞬态提示(重试横幅), // 不进快照——回合边界会清除它。 + // DelegationSessionUpdate 是纯通知(Requirement 8.4:查询才是 + // 状态真源),消费方收到后回查 delegation status,不进快照。 } } self.last_activity_at = Utc::now(); @@ -1488,6 +1506,26 @@ mod tests { ) } + /// D3.1 (T4.5 red): the agent's self-reported continuation capabilities + /// (`initialize`: `agent_capabilities.load_session` / + /// `session_capabilities.resume` / `.fork`) are STORED on the session + /// state — queryable via `manager.get_state` by the broker / the + /// user-side availability query (T5) — instead of being log-only. + #[test] + fn continuation_capabilities_default_off_and_are_settable() { + let mut s = fresh_state(); + assert!( + !s.agent_supports_load_session && !s.agent_supports_resume && !s.agent_supports_fork, + "capabilities default to false until the agent self-reports them" + ); + s.agent_supports_load_session = true; + s.agent_supports_resume = true; + s.agent_supports_fork = true; + assert!(s.agent_supports_load_session); + assert!(s.agent_supports_resume); + assert!(s.agent_supports_fork); + } + #[test] fn plan_approval_applies_clears_by_id_and_survives_snapshot() { let mut s = fresh_state(); diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 6db403b54..0b447d090 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -295,6 +295,26 @@ pub enum AcpEvent { agent_type: crate::models::agent::AgentType, result: DelegationResultSummary, }, + /// A CONTINUED delegation turn (turn_version > 1, dispatched via + /// `continue_with_session` or the user-side entry) reached a terminal + /// state. Session-addressed — it replaces the tool-scoped + /// `DelegationCompleted`, which is emitted ONLY for the original turn (a + /// tool call completes exactly once; Requirement 2.8a). Consumers treat + /// this as an increment NOTIFICATION and re-query the delegation status + /// as the authoritative source; `turn_version` orders events (drop lower + /// than last applied, re-query on gaps — Requirements 8.3/8.4). + DelegationSessionUpdate { + parent_connection_id: String, + child_conversation_id: i32, + /// Broker task id — stable across every continuation of the session. + task_id: String, + /// Broker-internal id of the settled turn. + turn_id: String, + /// Monotonic per-session dispatch counter (1 = original delegation). + turn_version: u64, + /// Who dispatched the settled turn (`parent_agent` / `user`). + origin: crate::acp::delegation::broker::TurnOrigin, + }, /// A human submitted a prompt from the Codeg conversation UI (desktop or /// web). Synthetic, notification-only event: it mutates no `SessionState` /// field and exists purely to drive the chat-channel "user message" push. @@ -373,10 +393,7 @@ pub enum AcpEvent { /// clear its "restart to apply" banner. Carried into `SessionState` so a /// snapshot attach (web reconnect, window refresh, new tile) recovers the /// staleness the one-shot event won't replay for it. - SessionConfigStale { - stale: bool, - kind: ConfigStaleKind, - }, + SessionConfigStale { stale: bool, kind: ConfigStaleKind }, } /// One background task settled by a `` transcript record, diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 07b30df01..525001653 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -213,9 +213,7 @@ async fn async_main() -> ExitCode { // bearer credential and must never enter the durable log files or the // in-app log viewer. `eprintln!` bypasses the tracing sinks (file + // ring buffer); only the local terminal / Docker stderr sees it. - eprintln!( - "[SERVER] No CODEG_TOKEN set; generated an access token (persisted): {token}" - ); + eprintln!("[SERVER] No CODEG_TOKEN set; generated an access token (persisted): {token}"); eprintln!("[SERVER] Pin your own by setting the CODEG_TOKEN environment variable."); } @@ -306,6 +304,11 @@ async fn async_main() -> ExitCode { // timeout to apply here. codeg_lib::commands::delegation::apply_persisted_config(&state.db.conn, &delegation_broker) .await; + // Rebuild the continuable delegation-session index from persisted child + // conversation rows (Requirement 7.1) before the listener / HTTP surface + // starts serving — so a post-restart continuation resolves instead of + // reading as "unknown task". + delegation_broker.rebuild_sessions_from_db().await; // Same for the live-feedback enable flag, so the first companion launch // sees the operator's configured behavior. codeg_lib::commands::feedback::apply_persisted_feedback_config( @@ -508,9 +511,11 @@ async fn async_main() -> ExitCode { // Publish runtime state so the settings page (served by us) shows // the truth — running on `actual_port` with this token — instead of // the placeholder "stopped" that triggers the stale-port banner. - state - .web_server_state - .mark_externally_running(advertised_host.clone(), actual_port, token.clone()); + state.web_server_state.mark_externally_running( + advertised_host.clone(), + actual_port, + token.clone(), + ); let addresses = addresses_for_bind(&advertised_host, actual_port); // Token on stderr ONLY (bearer credential — keep it out of the log files diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs index fc94b7f20..a74d30c38 100644 --- a/src-tauri/src/chat_channel/session_event_subscriber.rs +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -119,7 +119,11 @@ async fn handle_acp_envelope( AcpEvent::SessionStarted { session_id } => { let mut guard = bridge.lock().await; if let Some(session) = guard.get_mut(connection_id) { - let _ = conversation_service::update_external_id( + // Root-session-only writer: bridge sessions are chat-channel + // ROOT conversations. If this ever points at a `Delegate` row, + // its `external_id` is a resume credential the delegation + // lifecycle owns — the guarded variant refuses that write. + let _ = conversation_service::update_external_id_skip_delegate( db, session.conversation_id, session_id.clone(), @@ -509,7 +513,9 @@ async fn handle_acp_envelope( next TurnComplete" ); } else { - tracing::error!("[SessionEventSub] failed to send deferred kickoff: {e}"); + tracing::error!( + "[SessionEventSub] failed to send deferred kickoff: {e}" + ); let msg = RichMessage::error(format!("Failed to send task: {e}")); let _ = manager.send_to_target(&target, &msg).await; } @@ -949,9 +955,7 @@ mod delegation_relay_tests { assert!(is_delegation_title("delegate_to_agent")); assert!(is_delegation_title("Delegate To Agent")); assert!(is_delegation_title("delegate-to-agent")); - assert!(is_delegation_title( - "mcp__codeg-mcp__delegate_to_agent" - )); + assert!(is_delegation_title("mcp__codeg-mcp__delegate_to_agent")); assert!(is_delegation_title("Run mcp__codeg__delegate_to_agent")); assert!(!is_delegation_title("agent")); assert!(!is_delegation_title("write")); @@ -1578,4 +1582,82 @@ mod error_terminal_gate_tests { ConversationStatus::Cancelled ); } + + /// The bridge's `SessionStarted` writer is ROOT-session-only: a `kind = + /// Delegate` child row's `external_id` is the resume credential owned by + /// the delegation lifecycle, so a bridge session that (mis)points at a + /// delegate row must never overwrite it — the write is refused, not + /// re-pointed. + #[tokio::test] + async fn session_started_never_overwrites_delegate_resume_credential() { + use crate::acp::delegation::spawner::DelegationLink; + use crate::db::service::conversation_service; + + let db = test_helpers::fresh_in_memory_db().await; + let folder_id = test_helpers::seed_folder(&db, "/tmp/chat-delegate-guard").await; + let child = conversation_service::create_with_delegation( + &db.conn, + folder_id, + AgentType::ClaudeCode, + Some("delegated child".into()), + None, + Some(DelegationLink { + parent_conversation_id: 1, + parent_tool_use_id: "tu-guard".into(), + delegation_call_id: "call-guard".into(), + }), + ) + .await + .expect("delegate child row"); + conversation_service::update_external_id_resume_safe( + &db.conn, + child.id, + "sess-credential".into(), + ) + .await + .expect("mint the resume credential"); + + let bridge = Arc::new(Mutex::new(SessionBridge::new())); + bridge.lock().await.register( + "c-delegate".to_string(), + ActiveSession { + channel_id: 7, + sender_id: "u1".into(), + target: crate::chat_channel::types::ChannelMessageTarget::channel(7), + conversation_id: child.id, + connection_id: "c-delegate".to_string(), + agent_type: AgentType::ClaudeCode, + content_buffer: String::new(), + tool_calls: Vec::new(), + tool_call_inputs: std::collections::HashMap::new(), + delegation_rendered: std::collections::HashSet::new(), + last_flushed: Instant::now(), + pending_prompt: None, + permission_pending: None, + }, + ); + let chat_mgr = ChatChannelManager::new(); + let conn_mgr = ConnectionManager::new(); + let envelope = EventEnvelope { + seq: 1, + connection_id: "c-delegate".to_string(), + payload: AcpEvent::SessionStarted { + session_id: "sess-hijack".into(), + }, + }; + handle_acp_envelope(&envelope, &bridge, &chat_mgr, &conn_mgr, &db.conn).await; + + use crate::db::entities::conversation; + use sea_orm::EntityTrait; + let row = conversation::Entity::find_by_id(child.id) + .one(&db.conn) + .await + .unwrap() + .expect("row exists"); + assert_eq!( + row.external_id.as_deref(), + Some("sess-credential"), + "the delegate row's resume credential must survive a bridge SessionStarted" + ); + } } diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index b35a60a28..494da2e1a 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -11,14 +11,14 @@ use crate::parsers::claude::ClaudeParser; use crate::parsers::cline::ClineParser; use crate::parsers::codebuddy::CodeBuddyParser; use crate::parsers::codex::CodexParser; -use crate::parsers::gemini::GeminiParser; use crate::parsers::cursor::CursorParser; +use crate::parsers::gemini::GeminiParser; use crate::parsers::grok::GrokParser; use crate::parsers::hermes::HermesParser; use crate::parsers::kimi_code::KimiCodeParser; -use crate::parsers::pi::PiParser; use crate::parsers::openclaw::OpenClawParser; use crate::parsers::opencode::OpenCodeParser; +use crate::parsers::pi::PiParser; use crate::parsers::{ folder_name_from_path, normalize_path_for_matching, path_eq_for_matching, AgentParser, ParseError, @@ -473,7 +473,9 @@ async fn load_folder_rows( fn index_folder_rows(rows: &[ScanFolderRow]) -> HashMap { let mut index: HashMap = HashMap::new(); for row in rows { - let slot = index.entry(normalize_path_for_matching(&row.path)).or_insert(row); + let slot = index + .entry(normalize_path_for_matching(&row.path)) + .or_insert(row); if slot.deleted && !row.deleted { *slot = row; } @@ -517,7 +519,9 @@ fn build_scan_result( // Reuse the stored row's exact path string so the import-side // add_folder upsert hits the same UNIQUE(path) key instead of // minting a near-duplicate from a trailing-slash/case variant. - path: row.map(|r| r.path.clone()).unwrap_or_else(|| raw_path.clone()), + path: row + .map(|r| r.path.clone()) + .unwrap_or_else(|| raw_path.clone()), name: row .map(|r| r.name.clone()) .or_else(|| summary.folder_name.clone()) @@ -552,8 +556,7 @@ fn build_scan_result( let mut folders: Vec = groups .into_values() .map(|mut g| { - g.sessions - .sort_by_key(|s| std::cmp::Reverse(s.started_at)); + g.sessions.sort_by_key(|s| std::cmp::Reverse(s.started_at)); ScanFolder { path: g.path, name: g.name, @@ -757,9 +760,9 @@ pub(crate) async fn import_selected_from_summaries( skipped: tally.skipped, }); if failed_in_group > 0 && result.errors.len() < MAX_ERRORS { - result - .errors - .push(format!("{target_path}: {failed_in_group} session(s) failed")); + result.errors.push(format!( + "{target_path}: {failed_in_group} session(s) failed" + )); } // Broadcast every touched folder: even a pre-existing row may // have flipped is_open/deleted_at in add_folder, and clients @@ -922,102 +925,113 @@ pub async fn get_folder_conversation_core( let (mut turns, session_stats, resolved_ext_id, parsed_title, transcript_watermark) = if let Some(ref ext_id) = summary.external_id { - let at = summary.agent_type; - let eid = ext_id.clone(); - let db_created_at = summary.created_at; - let folder_path_for_fallback = { - let folder = folder_service::get_folder_by_id(conn, summary.folder_id) - .await - .ok() - .flatten(); - folder.map(|f| f.path) - }; - tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> { - let parser: Box = match at { - AgentType::ClaudeCode => Box::new(ClaudeParser::new()), - AgentType::Codex => Box::new(CodexParser::new()), - AgentType::OpenCode => Box::new(OpenCodeParser::new()), - AgentType::Gemini => Box::new(GeminiParser::new()), - AgentType::OpenClaw => Box::new(OpenClawParser::new()), - AgentType::Cline => Box::new(ClineParser::new()), - AgentType::Hermes => Box::new(HermesParser::new()), - AgentType::CodeBuddy => Box::new(CodeBuddyParser::new()), - AgentType::KimiCode => Box::new(KimiCodeParser::new()), - AgentType::Pi => Box::new(PiParser::new()), - AgentType::Grok => Box::new(GrokParser::new()), - AgentType::Cursor => Box::new(CursorParser::new()), + let at = summary.agent_type; + let eid = ext_id.clone(); + let db_created_at = summary.created_at; + let folder_path_for_fallback = { + let folder = folder_service::get_folder_by_id(conn, summary.folder_id) + .await + .ok() + .flatten(); + folder.map(|f| f.path) }; - match parser.get_conversation(&eid) { - Ok(d) => Ok(( - d.turns, - d.session_stats, - None, - d.summary.title, - d.transcript_watermark, - )), - Err(crate::parsers::ParseError::ConversationNotFound(_)) => { - // The external_id may no longer match any local file — - // e.g. an ACP session UUID (OpenClaw, Cline) or a stale - // ID after session/new fallback overwrote the original - // (Gemini CLI). Fall back to matching by folder_path - // and started_at from the parsed conversation list. - if matches!( - at, - AgentType::OpenClaw | AgentType::Cline | AgentType::Gemini - ) { - if let Ok(all) = parser.list_conversations() { - // Filter by folder_path first, then find the closest - // started_at match within 300 seconds of db_created_at. - let matched = all - .into_iter() - .filter(|c| { - c.folder_path - .as_ref() - .zip(folder_path_for_fallback.as_ref()) - .is_some_and(|(a, b)| path_eq_for_matching(a, b)) - }) - .min_by_key(|c| { - (c.started_at - db_created_at).num_seconds().unsigned_abs() - }) - .filter(|c| { - let diff = - (c.started_at - db_created_at).num_seconds().unsigned_abs(); - diff < 300 - }); - if let Some(conv) = matched { - let new_ext_id = conv.id.clone(); - if let Ok(d) = parser.get_conversation(&new_ext_id) { - return Ok(( - d.turns, - d.session_stats, - Some(new_ext_id), - d.summary.title, - d.transcript_watermark, - )); + tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> { + let parser: Box = match at { + AgentType::ClaudeCode => Box::new(ClaudeParser::new()), + AgentType::Codex => Box::new(CodexParser::new()), + AgentType::OpenCode => Box::new(OpenCodeParser::new()), + AgentType::Gemini => Box::new(GeminiParser::new()), + AgentType::OpenClaw => Box::new(OpenClawParser::new()), + AgentType::Cline => Box::new(ClineParser::new()), + AgentType::Hermes => Box::new(HermesParser::new()), + AgentType::CodeBuddy => Box::new(CodeBuddyParser::new()), + AgentType::KimiCode => Box::new(KimiCodeParser::new()), + AgentType::Pi => Box::new(PiParser::new()), + AgentType::Grok => Box::new(GrokParser::new()), + AgentType::Cursor => Box::new(CursorParser::new()), + }; + match parser.get_conversation(&eid) { + Ok(d) => Ok(( + d.turns, + d.session_stats, + None, + d.summary.title, + d.transcript_watermark, + )), + Err(crate::parsers::ParseError::ConversationNotFound(_)) => { + // The external_id may no longer match any local file — + // e.g. an ACP session UUID (OpenClaw, Cline) or a stale + // ID after session/new fallback overwrote the original + // (Gemini CLI). Fall back to matching by folder_path + // and started_at from the parsed conversation list. + if matches!( + at, + AgentType::OpenClaw | AgentType::Cline | AgentType::Gemini + ) { + if let Ok(all) = parser.list_conversations() { + // Filter by folder_path first, then find the closest + // started_at match within 300 seconds of db_created_at. + let matched = all + .into_iter() + .filter(|c| { + c.folder_path + .as_ref() + .zip(folder_path_for_fallback.as_ref()) + .is_some_and(|(a, b)| path_eq_for_matching(a, b)) + }) + .min_by_key(|c| { + (c.started_at - db_created_at).num_seconds().unsigned_abs() + }) + .filter(|c| { + let diff = (c.started_at - db_created_at) + .num_seconds() + .unsigned_abs(); + diff < 300 + }); + if let Some(conv) = matched { + let new_ext_id = conv.id.clone(); + if let Ok(d) = parser.get_conversation(&new_ext_id) { + return Ok(( + d.turns, + d.session_stats, + Some(new_ext_id), + d.summary.title, + d.transcript_watermark, + )); + } } } } + Ok((vec![], None, None, None, None)) } - Ok((vec![], None, None, None, None)) + Err(e) => Err(parse_error_to_app_error(e)), } - Err(e) => Err(parse_error_to_app_error(e)), - } - }) - .await - .map_err(|e| { - AppCommandError::task_execution_failed( - "Failed to read conversation turns from session file", - ) - .with_detail(e.to_string()) - })?? - } else { - (vec![], None, None, None, None) - }; + }) + .await + .map_err(|e| { + AppCommandError::task_execution_failed( + "Failed to read conversation turns from session file", + ) + .with_detail(e.to_string()) + })?? + } else { + (vec![], None, None, None, None) + }; // If we resolved a different external_id (e.g. ACP UUID → parser branch ID), - // update the database so future lookups are direct. + // update the database so future lookups are direct. Skip-delegate variant + // (not resume-safe): for a `Delegate` child row `external_id` is the resume + // credential, and this heuristic folder/started_at transcript match must + // never replace it — nor mint one when it is empty (a parser branch id is + // not a resume credential). Delegate rows just re-run the fallback on the + // next load; a read-path convenience write never outranks the credential. if let Some(new_ext_id) = resolved_ext_id { - let _ = conversation_service::update_external_id(conn, conversation_id, new_ext_id).await; + let _ = conversation_service::update_external_id_skip_delegate( + conn, + conversation_id, + new_ext_id, + ) + .await; } let mut summary = summary; @@ -1623,19 +1637,20 @@ pub async fn create_chat_conversation_core( // soft-deleting the just-created hidden folder — otherwise it would linger as // an orphan (active, conversation-less, never reached by the delete path) and // pollute the active-folder scope. - let model = - match conversation_service::create_chat(conn, folder.id, agent_type, title, None).await { - Ok(model) => model, - Err(create_err) => { - if let Err(cleanup_err) = folder_service::remove_folder(conn, &folder.path).await { - tracing::error!( + let model = match conversation_service::create_chat(conn, folder.id, agent_type, title, None) + .await + { + Ok(model) => model, + Err(create_err) => { + if let Err(cleanup_err) = folder_service::remove_folder(conn, &folder.path).await { + tracing::error!( "[conversations] failed to clean up orphan chat folder {} after conversation create error: {cleanup_err}", folder.id ); - } - return Err(AppCommandError::from(create_err)); } - }; + return Err(AppCommandError::from(create_err)); + } + }; Ok(CreateChatConversationResult { conversation_id: model.id, @@ -1677,7 +1692,9 @@ pub async fn create_chat_conversation( /// conversation are still created lazily on first send (reusing this dir). #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn create_chat_dir(app: tauri::AppHandle) -> Result { +pub async fn create_chat_dir( + app: tauri::AppHandle, +) -> Result { use tauri::Manager; let data_dir = app .path() @@ -1773,7 +1790,8 @@ pub async fn update_conversation_title( ) -> Result<(), AppCommandError> { update_conversation_title_core(&db.conn, conversation_id, title).await?; emit_conversation_upsert(&EventEmitter::Tauri(app), &db.conn, conversation_id).await; - sync_conversation_title_to_channels_core(&db.conn, &chat_channel_manager, conversation_id).await; + sync_conversation_title_to_channels_core(&db.conn, &chat_channel_manager, conversation_id) + .await; Ok(()) } @@ -1890,8 +1908,20 @@ pub async fn delete_conversation( db: tauri::State<'_, AppDatabase>, conversation_id: i32, ) -> Result<(), AppCommandError> { + use tauri::Manager; + let broker = app + .state::>() + .inner() + .clone(); let emitter = EventEmitter::Tauri(app); - delete_conversation_with_cleanup_core(&emitter, &db.conn, conversation_id).await + delete_conversation_with_cleanup_core(&emitter, &db.conn, conversation_id).await?; + // The conversation is gone for good — release every delegated child it + // owned (kept-alive processes freed, further continuation refused; + // Requirement 1.4a). A row with no children is a cheap no-op. + broker + .release_children_of_parent_conversation(conversation_id) + .await; + Ok(()) } fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats { @@ -2063,11 +2093,21 @@ mod tests { assistant_text_turn("turn-1", "reply", at(-29), true), user_text_turn("turn-2", "hello", at(1)), ]; - let stamped = - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); - assert_eq!(stamped.as_deref(), Some("msg-live"), "reports the stamped id"); + let stamped = apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); + assert_eq!( + stamped.as_deref(), + Some("msg-live"), + "reports the stamped id" + ); assert_eq!(turns[2].id, "msg-live"); - assert_eq!(turns[0].id, "turn-0", "earlier identical-position turn intact"); + assert_eq!( + turns[0].id, "turn-0", + "earlier identical-position turn intact" + ); assert_eq!(turns[1].id, "turn-1"); } @@ -2086,11 +2126,18 @@ mod tests { user_text_turn("turn-0", "hello", at(1)), assistant_text_turn("turn-1", "partial...", at(2), true), ]; - let stamped = - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); + let stamped = apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); assert_eq!(stamped.as_deref(), Some("msg-live")); assert_eq!(turns[0].id, "msg-live"); - assert_eq!(turns.len(), 2, "the partial reply is preserved (not dropped)"); + assert_eq!( + turns.len(), + 2, + "the partial reply is preserved (not dropped)" + ); assert_eq!(turns[1].id, "turn-1", "the partial reply is untouched"); } @@ -2121,10 +2168,16 @@ mod tests { assistant_text_turn("turn-1", "reply", at(-29), true), user_text_turn("turn-2", "hello", at(1)), ]; - let stamped = - apply_in_flight_message_id(&mut turns, &pending_text("turn-0", "hello"), Some(turn_started())); + let stamped = apply_in_flight_message_id( + &mut turns, + &pending_text("turn-0", "hello"), + Some(turn_started()), + ); assert_eq!(stamped, None, "colliding broadcast id → no stamp"); - assert_eq!(turns[2].id, "turn-2", "the in-flight prompt keeps its parser id"); + assert_eq!( + turns[2].id, "turn-2", + "the in-flight prompt keeps its parser id" + ); assert_eq!(turns[0].id, "turn-0", "the colliding turn is untouched"); } @@ -2139,9 +2192,16 @@ mod tests { user_text_turn("turn-2", "ok", at(1)), assistant_text_turn("turn-3", "b", at(2), false), ]; - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); + apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); assert_eq!(turns[0].id, "turn-0"); - assert_eq!(turns[2].id, "turn-2", "non-matching tail user turn untouched"); + assert_eq!( + turns[2].id, "turn-2", + "non-matching tail user turn untouched" + ); } #[test] @@ -2153,7 +2213,11 @@ mod tests { assistant_text_turn("turn-1", "a", at(2), false), assistant_text_turn("turn-2", "b", at(3), false), ]; - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); + apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); assert_eq!(turns[0].id, "turn-0", "left untouched"); } @@ -2173,30 +2237,43 @@ mod tests { model: None, completed_at: None, }; - let pending_image = |message_id: &str, data: &str| { - crate::acp::session_state::PendingUserMessage { + let pending_image = + |message_id: &str, data: &str| crate::acp::session_state::PendingUserMessage { message_id: message_id.into(), blocks: vec![crate::acp::types::UserMessageBlock::Image { data: data.into(), mime_type: "image/png".into(), }], - } - }; + }; let mut turns = vec![image_turn("turn-0", "AAAA")]; - apply_in_flight_message_id(&mut turns, &pending_image("msg-live", "AAAA"), Some(turn_started())); - assert_eq!(turns[0].id, "msg-live", "uri difference is ignored, data matches"); + apply_in_flight_message_id( + &mut turns, + &pending_image("msg-live", "AAAA"), + Some(turn_started()), + ); + assert_eq!( + turns[0].id, "msg-live", + "uri difference is ignored, data matches" + ); let mut turns = vec![image_turn("turn-0", "AAAA")]; - apply_in_flight_message_id(&mut turns, &pending_image("msg-live", "BBBB"), Some(turn_started())); + apply_in_flight_message_id( + &mut turns, + &pending_image("msg-live", "BBBB"), + Some(turn_started()), + ); assert_eq!(turns[0].id, "turn-0", "different image bytes → no stamp"); } #[test] fn empty_turns_is_a_noop() { let mut turns: Vec = vec![]; - let stamped = - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); + let stamped = apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); assert_eq!(stamped, None); assert!(turns.is_empty()); } @@ -2214,7 +2291,11 @@ mod tests { user_text_turn("turn-0", "continue", at(-60)), assistant_text_turn("turn-1", "done", at(-58), true), ]; - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "continue"), Some(turn_started())); + apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "continue"), + Some(turn_started()), + ); assert_eq!(turns[0].id, "turn-0", "older identical prompt → untouched"); } @@ -2233,8 +2314,15 @@ mod tests { // backend broadcasts `UserMessage` before issuing the agent request), so // a turn exactly at the start qualifies — the boundary is inclusive. let mut turns = vec![user_text_turn("turn-0", "hello", at(0))]; - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); - assert_eq!(turns[0].id, "msg-live", "persisted exactly at the start is in-flight"); + apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); + assert_eq!( + turns[0].id, "msg-live", + "persisted exactly at the start is in-flight" + ); } #[test] @@ -2242,8 +2330,15 @@ mod tests { // Strict gate, no backward tolerance: a turn even one second before the // start belongs to an earlier turn, never the in-flight prompt. let mut turns = vec![user_text_turn("turn-0", "hello", at(-1))]; - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "hello"), Some(turn_started())); - assert_eq!(turns[0].id, "turn-0", "one second before the start is not in-flight"); + apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "hello"), + Some(turn_started()), + ); + assert_eq!( + turns[0].id, "turn-0", + "one second before the start is not in-flight" + ); } #[test] @@ -2259,10 +2354,19 @@ mod tests { user_text_turn("turn-0", "continue", at(-1)), assistant_text_turn("turn-1", "done", at(0), true), ]; - let stamped = - apply_in_flight_message_id(&mut turns, &pending_text("msg-live", "continue"), Some(turn_started())); - assert_eq!(stamped, None, "fast prior identical prompt → nothing reported"); - assert_eq!(turns[0].id, "turn-0", "fast prior identical prompt → untouched"); + let stamped = apply_in_flight_message_id( + &mut turns, + &pending_text("msg-live", "continue"), + Some(turn_started()), + ); + assert_eq!( + stamped, None, + "fast prior identical prompt → nothing reported" + ); + assert_eq!( + turns[0].id, "turn-0", + "fast prior identical prompt → untouched" + ); assert_eq!(turns.len(), 2, "the prior completed reply is preserved"); } @@ -2554,10 +2658,9 @@ mod tests { assert!(summary.git_branch.is_none()); // It surfaces in the default sidebar query (active-folder scope). - let rows = - list_all_conversations_core(&db.conn, None, None, None, None, None, false) - .await - .expect("list"); + let rows = list_all_conversations_core(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); assert!(rows.iter().any(|c| c.id == result.conversation_id)); } @@ -2750,7 +2853,10 @@ mod tests { .await .expect("gc"); - assert_eq!(removed, 0, "a fresh dir below the staleness threshold is spared"); + assert_eq!( + removed, 0, + "a fresh dir below the staleness threshold is spared" + ); assert!( std::path::Path::new(&fresh).is_dir(), "fresh dir retained (anti-race)" @@ -2830,13 +2936,10 @@ mod tests { symlink(real.path(), &link).expect("symlink"); // GC runs under the symlinked spelling; the live dir must still be spared. - let removed = gc_orphan_chat_dirs_core_with_threshold( - &db.conn, - &link, - std::time::Duration::ZERO, - ) - .await - .expect("gc"); + let removed = + gc_orphan_chat_dirs_core_with_threshold(&db.conn, &link, std::time::Duration::ZERO) + .await + .expect("gc"); assert_eq!( removed, 0, @@ -3081,7 +3184,9 @@ mod tests { let (broadcaster, emitter) = sync_test_emitter(); let mut rx = broadcaster.subscribe(); - delete_conversation_core(&db.conn, c1).await.expect("delete"); + delete_conversation_core(&db.conn, c1) + .await + .expect("delete"); cleanup_tabs_for_deleted_conversation(&emitter, &db.conn, c1).await; let snap = list_opened_tabs_core(&db.conn).await.expect("list"); @@ -3096,7 +3201,8 @@ mod tests { } #[tokio::test] - async fn cleanup_tabs_for_deleted_conversation_bumps_barrier_without_emitting_when_no_open_tab() { + async fn cleanup_tabs_for_deleted_conversation_bumps_barrier_without_emitting_when_no_open_tab() + { let db = fresh_in_memory_db().await; let folder_id = seed_folder(&db, "/tmp/codeg-tab-conv-del-noop").await; let c1 = create_conversation_core(&db.conn, folder_id, AgentType::ClaudeCode, None) @@ -3177,7 +3283,9 @@ mod tests { assert_eq!(saved.version, 1); // Server deletes c1 and atomically cleans its tab → v2 (only c2 remains). - delete_conversation_core(&db.conn, c1).await.expect("delete c1"); + delete_conversation_core(&db.conn, c1) + .await + .expect("delete c1"); cleanup_tabs_for_deleted_conversation(&EventEmitter::Noop, &db.conn, c1).await; // A client still on the pre-cleanup version re-saves the OLD set (with c1 @@ -3243,7 +3351,10 @@ mod tests { ) .await .expect("stale save returns Ok"); - assert!(!stale.accepted, "save on the pre-removal version must be rejected"); + assert!( + !stale.accepted, + "save on the pre-removal version must be rejected" + ); let snap = list_opened_tabs_core(&db.conn).await.expect("list"); assert!( @@ -3282,11 +3393,16 @@ mod tests { // c1 deleted with no persisted c1 tab → zero rows removed, but the // version barrier still advances (v1 → v2) and nothing is broadcast. - delete_conversation_core(&db.conn, c1).await.expect("delete c1"); + delete_conversation_core(&db.conn, c1) + .await + .expect("delete c1"); let (broadcaster, emitter) = sync_test_emitter(); let mut rx = broadcaster.subscribe(); cleanup_tabs_for_deleted_conversation(&emitter, &db.conn, c1).await; - assert!(rx.try_recv().is_err(), "zero-row cleanup must not broadcast"); + assert!( + rx.try_recv().is_err(), + "zero-row cleanup must not broadcast" + ); // A's debounced save (built on v1, still including the now-deleted c1) is // rejected by the barrier — c1 must not be persisted as a ghost. @@ -3848,7 +3964,10 @@ mod tests { .unwrap(); assert_eq!(folder_rows.len(), 1); assert_eq!(folder_rows[0].path, "/tmp/proj-a"); - assert!(folder_rows[0].is_open, "created folder must open in sidebar"); + assert!( + folder_rows[0].is_open, + "created folder must open in sidebar" + ); let convs = conversation::Entity::find().all(&db.conn).await.unwrap(); assert_eq!(convs.len(), 2); @@ -3978,8 +4097,9 @@ mod tests { #[tokio::test] async fn batch_import_never_resurrects_a_deleted_conversation() { - use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, - Set}; + use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, + }; let db = fresh_in_memory_db().await; let make = || { diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs index 9c2e406e3..ccb8e02ab 100644 --- a/src-tauri/src/commands/delegation.rs +++ b/src-tauri/src/commands/delegation.rs @@ -24,10 +24,15 @@ use std::sync::Arc; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; -use crate::acp::delegation::broker::{DelegationBroker, DelegationConfig}; -use crate::acp::delegation::types::AgentDelegationDefaults; +use crate::acp::delegation::broker::TurnOrigin; +use crate::acp::delegation::broker::{ + ContinuationAvailability, DelegationBroker, DelegationConfig, +}; +use crate::acp::delegation::types::{ + AgentDelegationDefaults, DelegationError, DelegationOutcome, DelegationTaskReport, TaskStatus, +}; use crate::app_error::AppCommandError; -use crate::db::service::app_metadata_service; +use crate::db::service::{app_metadata_service, conversation_service}; use crate::models::AgentType; pub const KEY_DELEGATION_ENABLED: &str = "delegation.enabled"; @@ -109,6 +114,9 @@ impl DelegationSettings { // value from wrapping on 32-bit `usize` targets. completed_cache_cap_bytes: (self.completed_cache_max_mb as usize) .saturating_mul(1024 * 1024), + // Not user-configurable yet: `kept_alive_cap` rides the broker + // default until a settings surface exists (T5 scope). + ..DelegationConfig::default() } } } @@ -197,6 +205,172 @@ pub async fn set_delegation_settings_core( Ok(clamped) } +// -------- User-side continuation entry (Task 5 · design §用户侧) ------------- + +/// Synthetic "connection id" stamped on user-side continuation dispatches. +/// The broker's ownership check runs on the parent CONVERSATION id (D5); the +/// connection id only labels the run lease + in-flight registration, and this +/// value never collides with a real ACP connection UUID — so a parent +/// connection teardown can never sweep a user-dispatched turn by accident. +pub const USER_ENTRY_CONNECTION_ID: &str = "user-entry"; + +/// How a user-supplied child conversation id resolves against the DB. +enum DelegationTarget { + /// No live row with that id — answered with a non-disclosing `Unknown`. + NotFound, + /// A row exists but is not a delegation subsession (`parent_id` null or + /// no `delegation_call_id`) — refused: a regular conversation can never + /// be driven through the delegation broker. + NotASubsession, + /// A delegation child: the broker task id (= `delegation_call_id`) plus + /// its owning parent conversation id. + Target { + task_id: String, + parent_conversation_id: i32, + }, +} + +/// Resolve a child conversation id to its broker task (D5: the user side +/// addresses sessions by conversation id; the broker speaks task ids). +async fn resolve_delegation_target( + conn: &DatabaseConnection, + child_conversation_id: i32, +) -> Result { + let Some(row) = conversation_service::get_by_id_optional(conn, child_conversation_id) + .await + .map_err(AppCommandError::from)? + else { + return Ok(DelegationTarget::NotFound); + }; + match (row.parent_id, row.delegation_call_id) { + (Some(parent_conversation_id), Some(task_id)) if !task_id.trim().is_empty() => { + Ok(DelegationTarget::Target { + task_id, + parent_conversation_id, + }) + } + _ => Ok(DelegationTarget::NotASubsession), + } +} + +/// The non-disclosing verdict for an id with no live conversation row — +/// mirrors the broker's own `unknown_report` shape (status `Unknown`, no +/// error code) so callers can't tell "never existed" from "not yours". +fn unknown_target_report() -> DelegationTaskReport { + DelegationTaskReport { + task_id: None, + status: TaskStatus::Unknown, + child_conversation_id: None, + agent_type: None, + text: None, + error_code: None, + message: Some( + "Unknown conversation — it never existed, is not a delegation \ + subsession you can address, or was deleted." + .to_string(), + ), + duration_ms: None, + } +} + +/// Refusal report for a conversation that exists but is not a delegation +/// subsession. Rides `types.rs`' error contract (`not_continuable`) so the +/// frontend surfaces the same stable code the broker uses. +fn not_a_subsession_report(child_conversation_id: i32) -> DelegationTaskReport { + let err = + DelegationError::NotContinuable("this conversation is not a delegation subsession".into()); + match DelegationOutcome::from_err(err, Some(child_conversation_id)) { + DelegationOutcome::Err { + code, + message, + child_conversation_id, + } => DelegationTaskReport { + task_id: None, + status: TaskStatus::Failed, + child_conversation_id, + agent_type: None, + text: None, + error_code: Some(code), + message: Some(message), + duration_ms: None, + }, + DelegationOutcome::Ok(_) => unreachable!("from_err never yields Ok"), + } +} + +/// User-side continue: locate the broker task by the child CONVERSATION id +/// (D5) and dispatch the follow-up under the same `task_id` the parent AI +/// holds (Requirement 4.2), with `TurnOrigin::User`. `continuation_id` is the +/// caller-minted idempotency key — the frontend reuses it on retry. +pub async fn continue_delegation_core( + conn: &DatabaseConnection, + broker: &DelegationBroker, + child_conversation_id: i32, + message: String, + continuation_id: String, +) -> Result { + match resolve_delegation_target(conn, child_conversation_id).await? { + DelegationTarget::NotFound => Ok(unknown_target_report()), + DelegationTarget::NotASubsession => Ok(not_a_subsession_report(child_conversation_id)), + DelegationTarget::Target { + task_id, + parent_conversation_id, + } => Ok(broker + .continue_delegation( + USER_ENTRY_CONNECTION_ID, + Some(parent_conversation_id), + &task_id, + message, + &continuation_id, + TurnOrigin::User, + ) + .await), + } +} + +/// User-side close (RELEASE semantics — frees the child process; not a +/// permanent close). Same target resolution as continue. +pub async fn close_delegation_session_core( + conn: &DatabaseConnection, + broker: &DelegationBroker, + child_conversation_id: i32, + continuation_id: String, +) -> Result { + match resolve_delegation_target(conn, child_conversation_id).await? { + DelegationTarget::NotFound => Ok(unknown_target_report()), + DelegationTarget::NotASubsession => Ok(not_a_subsession_report(child_conversation_id)), + DelegationTarget::Target { + task_id, + parent_conversation_id, + } => Ok(broker + .close_delegation_session( + USER_ENTRY_CONNECTION_ID, + Some(parent_conversation_id), + &task_id, + &continuation_id, + ) + .await), + } +} + +/// User-side availability query (design §D4 five tiers). Ids that don't +/// resolve to a delegation subsession fold into `NotContinuable` — one +/// verdict, no existence disclosure. +pub async fn get_continuation_availability_core( + conn: &DatabaseConnection, + broker: &DelegationBroker, + child_conversation_id: i32, +) -> Result { + match resolve_delegation_target(conn, child_conversation_id).await? { + DelegationTarget::NotFound | DelegationTarget::NotASubsession => { + Ok(ContinuationAvailability::NotContinuable) + } + DelegationTarget::Target { .. } => Ok(broker + .get_continuation_availability(child_conversation_id) + .await), + } +} + // -------- Tauri commands ----------------------------------------------------- #[cfg_attr(feature = "tauri-runtime", tauri::command)] @@ -231,6 +405,73 @@ pub async fn set_delegation_settings( } } +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn continue_delegation( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] broker: tauri::State<'_, Arc>, + child_conversation_id: i32, + message: String, + continuation_id: String, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + continue_delegation_core( + &db.conn, + broker.inner(), + child_conversation_id, + message, + continuation_id, + ) + .await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = (child_conversation_id, message, continuation_id); + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn close_delegation_session( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] broker: tauri::State<'_, Arc>, + child_conversation_id: i32, + continuation_id: String, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + close_delegation_session_core( + &db.conn, + broker.inner(), + child_conversation_id, + continuation_id, + ) + .await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = (child_conversation_id, continuation_id); + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_continuation_availability( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] broker: tauri::State<'_, Arc>, + child_conversation_id: i32, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + get_continuation_availability_core(&db.conn, broker.inner(), child_conversation_id).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = child_conversation_id; + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index e77f7a75d..4e70fa6fb 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -70,7 +70,10 @@ pub async fn create_with_delegation( } else { ConversationKind::Regular }; - create_inner(conn, folder_id, agent_type, title, git_branch, delegation, kind).await + create_inner( + conn, folder_id, agent_type, title, git_branch, delegation, kind, + ) + .await } async fn create_inner( @@ -267,6 +270,81 @@ pub async fn update_external_id( Ok(()) } +/// Resume-safe variant of [`update_external_id`] (Requirement 3.3a): for a +/// `kind = Delegate` child row, `external_id` is the RESUME CREDENTIAL — once +/// set, a DIFFERING incoming session id can only originate from a +/// context-losing `session/new` fallback (`session/resume` / `session/load` +/// both keep the agent-side id stable), so the write is refused as a silent +/// no-op rather than destroying the credential. Root/regular rows keep the +/// historical full-overwrite semantics (session churn on reconnect is +/// legitimate there), as do delegate rows whose credential is still empty +/// (the first spawn's id is the credential being minted). +/// +/// This is the single choke point for the guard: every `SessionStarted`-driven +/// writer (lifecycle subscriber, `send_prompt_linked`'s sync snapshot) routes +/// through here so no path can silently swap a child's credential. +pub async fn update_external_id_resume_safe( + conn: &DatabaseConnection, + conversation_id: i32, + external_id: String, +) -> Result<(), DbError> { + let Some(row) = conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await? + else { + // No live row — same silent no-op contract as `update_external_id`. + return Ok(()); + }; + if row.kind == ConversationKind::Delegate { + if let Some(existing) = row.external_id.as_deref() { + if !existing.is_empty() && existing != external_id { + tracing::info!( + "[conversation] refusing to overwrite delegate row {conversation_id}'s \ + resume credential (existing session id kept; incoming id looks like a \ + context-losing session/new fallback — Requirement 3.3a)" + ); + return Ok(()); + } + } + } + update_external_id(conn, conversation_id, external_id).await +} + +/// Root-writer variant of [`update_external_id`]: refuses to touch a +/// `kind = Delegate` child row at all. For delegate children `external_id` is +/// the RESUME CREDENTIAL owned by the delegation lifecycle +/// ([`update_external_id_resume_safe`] is its guarded writer); the callers +/// here — the chat-channel bridge's `SessionStarted` handler and the viewer's +/// stale-id re-resolution — only ever manage ROOT conversations, and the ids +/// they carry are root-session artifacts (a bridge session id / a heuristic +/// transcript match). Unlike the resume-safe variant this refuses even the +/// minting write on an EMPTY credential: a root-session id must never BECOME +/// a delegate row's resume credential. +pub async fn update_external_id_skip_delegate( + conn: &DatabaseConnection, + conversation_id: i32, + external_id: String, +) -> Result<(), DbError> { + let Some(row) = conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await? + else { + // No live row — same silent no-op contract as `update_external_id`. + return Ok(()); + }; + if row.kind == ConversationKind::Delegate { + tracing::warn!( + "[conversation] refusing root-writer external_id update on delegate row \ + {conversation_id} (external_id is the resume credential; this writer \ + only manages root conversations)" + ); + return Ok(()); + } + update_external_id(conn, conversation_id, external_id).await +} + pub async fn soft_delete(conn: &DatabaseConnection, conversation_id: i32) -> Result<(), DbError> { let conv = conversation::Entity::find_by_id(conversation_id) .filter(conversation::Column::DeletedAt.is_null()) @@ -375,6 +453,21 @@ pub async fn get_by_id( Ok(summary) } +/// Like [`get_by_id`] but "not found" is a legitimate answer (`Ok(None)`), +/// not an error. Used by the user-side delegation entry, where an unknown id +/// must fold into a non-disclosing `Unknown` report rather than an HTTP +/// failure — while a real DB fault still propagates as `Err`. +pub async fn get_by_id_optional( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + let conv = conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await?; + Ok(conv.map(conv_to_summary)) +} + /// Look up a child conversation by its `delegation_call_id` (the broker's /// `task_id`). Returns `Ok(None)` when no row matches — used by the broker's /// `ChildStatusLookup` DB fallback to recover a delegation task's terminal @@ -449,9 +542,10 @@ pub async fn list_by_folder( /// returns conversations across every non-deleted folder (open or not). /// /// `include_children` controls visibility of delegation sub-sessions. When -/// `false` (the default for the top-level list), rows whose `parent_id` is -/// non-null are filtered out — they belong to their parent's tool-call view, -/// not the workspace conversation list. Rows with `kind = 'loop'` are always +/// `false` (the default for the top-level list), rows carrying ANY delegation +/// marker (`parent_id`, `kind = 'delegate'`, `delegation_call_id`) are filtered +/// out — they belong to their parent's tool-call view / sidebar subtree, not the +/// workspace conversation list. Rows with `kind = 'loop'` are always /// excluded — they belong to the loops workbench. pub async fn list_all( conn: &DatabaseConnection, @@ -469,7 +563,16 @@ pub async fn list_all( query = query.filter(conversation::Column::Kind.ne(ConversationKind::Loop)); if !include_children { - query = query.filter(conversation::Column::ParentId.is_null()); + // Root list = no delegation children. All three markers are checked + // independently (not just `parent_id`): `create_with_delegation` writes + // them together, so a row carrying any one of them belongs to a + // delegation task — an orphan whose `parent_id` was cleared must still + // stay out of the root list instead of surfacing as a folder peer. + // Mirrors the frontend `isSidebarRootConversation` predicate. + query = query + .filter(conversation::Column::ParentId.is_null()) + .filter(conversation::Column::Kind.ne(ConversationKind::Delegate)) + .filter(conversation::Column::DelegationCallId.is_null()); } match folder_ids { @@ -643,9 +746,15 @@ mod tests { async fn list_children_orders_newest_first() { let db = fresh_in_memory_db().await; let folder = seed_folder(&db, "/tmp/codeg-list-children-order").await; - let parent = create(&db.conn, folder, AgentType::ClaudeCode, Some("P".into()), None) - .await - .expect("parent"); + let parent = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("P".into()), + None, + ) + .await + .expect("parent"); // Two children created oldest → newest under the same parent. let first = create_with_delegation( &db.conn, @@ -744,7 +853,9 @@ mod tests { let folder = seed_folder(&db, "/tmp/codeg-child-count-deleted").await; let (parent, child) = seed_parent_with_child(&db.conn, folder).await; - soft_delete(&db.conn, child).await.expect("soft delete child"); + soft_delete(&db.conn, child) + .await + .expect("soft delete child"); // A removed sub-session must not keep the parent's chevron alive: the // aggregate filters deleted_at IS NULL, matching list_children. @@ -762,9 +873,15 @@ mod tests { async fn update_pin_sets_and_clears_without_bumping_updated_at() { let db = fresh_in_memory_db().await; let folder = seed_folder(&db, "/tmp/codeg-update-pin").await; - let conv = create(&db.conn, folder, AgentType::ClaudeCode, Some("c".into()), None) - .await - .expect("create"); + let conv = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("c".into()), + None, + ) + .await + .expect("create"); // Freshly created rows are unpinned, and the summary projection carries // the field through (conv_to_summary mapping). @@ -779,7 +896,10 @@ mod tests { // preference, not activity). update_pin(&db.conn, conv.id, true).await.expect("pin"); let pinned = get_by_id(&db.conn, conv.id).await.expect("get pinned"); - assert!(pinned.pinned_at.is_some(), "pinned_at must be set after pin"); + assert!( + pinned.pinned_at.is_some(), + "pinned_at must be set after pin" + ); assert_eq!( pinned.updated_at, updated_at_before, "pinning must not bump updated_at" @@ -817,11 +937,20 @@ mod tests { async fn create_leaves_title_unlocked() { let db = fresh_in_memory_db().await; let folder = seed_folder(&db, "/tmp/codeg-title-unlocked").await; - let row = create(&db.conn, folder, AgentType::ClaudeCode, Some("hi".into()), None) - .await - .expect("create"); + let row = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("hi".into()), + None, + ) + .await + .expect("create"); let summary = get_by_id(&db.conn, row.id).await.expect("get"); - assert!(!summary.title_locked, "new conversation must start unlocked"); + assert!( + !summary.title_locked, + "new conversation must start unlocked" + ); } #[tokio::test] @@ -839,6 +968,157 @@ mod tests { assert!(summary.title_locked, "manual rename must lock the title"); } + /// Requirement 3.3a (T4.5 red): a Delegate child row's `external_id` is + /// the RESUME CREDENTIAL — a differing overwrite can only come from a + /// context-losing `session/new` fallback (resume/load keep the same id), + /// so the resume-safe write refuses it. Root rows keep the historical + /// full-overwrite semantics (session churn on reconnect is legitimate). + #[tokio::test] + async fn resume_safe_write_never_overwrites_a_delegate_rows_credential() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-extid-guard").await; + let parent = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("parent"); + let child = create_with_delegation( + &db.conn, + folder, + AgentType::Codex, + None, + None, + Some(crate::acp::delegation::spawner::DelegationLink { + parent_conversation_id: parent.id, + parent_tool_use_id: "pt-guard".into(), + delegation_call_id: "task-guard".into(), + }), + ) + .await + .expect("delegate child"); + + async fn raw(conn: &DatabaseConnection, id: i32) -> conversation::Model { + conversation::Entity::find_by_id(id) + .one(conn) + .await + .expect("query") + .expect("row") + } + + // 1. Empty credential → the first write lands. + update_external_id_resume_safe(&db.conn, child.id, "sess-original".into()) + .await + .expect("first write"); + assert_eq!( + raw(&db.conn, child.id).await.external_id.as_deref(), + Some("sess-original") + ); + + // 2. A DIFFERING overwrite (the session/new fallback shape) is + // refused — silent no-op, never an error (3.3a). + update_external_id_resume_safe(&db.conn, child.id, "sess-cold-fallback".into()) + .await + .expect("refusal must be a no-op, not an error"); + assert_eq!( + raw(&db.conn, child.id).await.external_id.as_deref(), + Some("sess-original"), + "a delegate row's resume credential must never be overwritten" + ); + + // 3. Re-writing the SAME value is an idempotent no-op. + update_external_id_resume_safe(&db.conn, child.id, "sess-original".into()) + .await + .expect("idempotent rewrite"); + assert_eq!( + raw(&db.conn, child.id).await.external_id.as_deref(), + Some("sess-original") + ); + + // 4. Root rows keep the historical overwrite semantics. + update_external_id_resume_safe(&db.conn, parent.id, "root-s1".into()) + .await + .expect("root first"); + update_external_id_resume_safe(&db.conn, parent.id, "root-s2".into()) + .await + .expect("root overwrite"); + assert_eq!( + raw(&db.conn, parent.id).await.external_id.as_deref(), + Some("root-s2"), + "root conversations still update freely on session churn" + ); + } + + /// The skip-delegate (root-writer) variant refuses BOTH shapes on a + /// delegate row — overwriting an existing credential AND minting one on + /// an empty credential (stricter than resume-safe: a root-session id or + /// heuristic transcript match must never become a credential). Root rows + /// keep the full-overwrite semantics. + #[tokio::test] + async fn skip_delegate_write_never_touches_a_delegate_row() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-extid-skipdelegate").await; + let parent = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("parent"); + let child = create_with_delegation( + &db.conn, + folder, + AgentType::Codex, + None, + None, + Some(crate::acp::delegation::spawner::DelegationLink { + parent_conversation_id: parent.id, + parent_tool_use_id: "pt-skip".into(), + delegation_call_id: "task-skip".into(), + }), + ) + .await + .expect("delegate child"); + + async fn raw(conn: &DatabaseConnection, id: i32) -> conversation::Model { + conversation::Entity::find_by_id(id) + .one(conn) + .await + .expect("query") + .expect("row") + } + + // 1. EMPTY credential: even the minting write is refused (this is the + // behavioral difference from `update_external_id_resume_safe`). + update_external_id_skip_delegate(&db.conn, child.id, "not-a-credential".into()) + .await + .expect("refusal must be a silent no-op, not an error"); + assert_eq!( + raw(&db.conn, child.id).await.external_id, + None, + "a root-writer id must never mint a delegate row's credential" + ); + + // 2. EXISTING credential: refused as well. + update_external_id_resume_safe(&db.conn, child.id, "sess-real".into()) + .await + .expect("mint via the delegation-owned writer"); + update_external_id_skip_delegate(&db.conn, child.id, "sess-hijack".into()) + .await + .expect("no-op"); + assert_eq!( + raw(&db.conn, child.id).await.external_id.as_deref(), + Some("sess-real"), + "a delegate row's credential must survive a root-writer update" + ); + + // 3. Root rows keep the historical full-overwrite semantics. + update_external_id_skip_delegate(&db.conn, parent.id, "root-a".into()) + .await + .expect("root first"); + update_external_id_skip_delegate(&db.conn, parent.id, "root-b".into()) + .await + .expect("root overwrite"); + assert_eq!( + raw(&db.conn, parent.id).await.external_id.as_deref(), + Some("root-b"), + "root conversations still update freely" + ); + } + #[tokio::test] async fn update_external_id_skips_soft_deleted_row() { // A late/stale `SessionStarted` write — e.g. a fork's SessionStarted{S2} @@ -1078,4 +1358,94 @@ mod tests { "loop row must be excluded" ); } + + /// A delegation child whose `parent_id` was lost (parent hard-deleted, or a + /// partially-written row) must still be kept out of the ROOT list: the + /// `kind = 'delegate'` and `delegation_call_id` markers are each sufficient + /// on their own. Without this the orphan surfaces as a folder peer, exactly + /// what the sidebar sub-session filter is meant to prevent. + #[tokio::test] + async fn list_all_root_filter_excludes_delegate_kind_without_parent_id() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-root-filter-orphan").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + // Simulate the orphan: clear parent_id but keep the other two markers. + let row = conversation::Entity::find_by_id(child) + .one(&db.conn) + .await + .expect("find child") + .expect("child row"); + let mut active: conversation::ActiveModel = row.into(); + active.parent_id = Set(None); + active.update(&db.conn).await.expect("orphan the child"); + + let rows = list_all(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!(ids.contains(&parent), "parent must remain visible: {ids:?}"); + assert!( + !ids.contains(&child), + "orphaned delegate row must stay out of the root list: {ids:?}" + ); + } + + /// The third marker alone (`delegation_call_id` set on an otherwise regular + /// row) is also disqualifying — the broker stamps it at spawn, so its + /// presence means the row belongs to a delegation task. + #[tokio::test] + async fn list_all_root_filter_excludes_rows_carrying_a_delegation_call_id() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-root-filter-callid").await; + let keep = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("keep".into()), + None, + ) + .await + .expect("keep"); + let stamped = create( + &db.conn, + folder, + AgentType::Codex, + Some("stamped".into()), + None, + ) + .await + .expect("stamped"); + let mut active: conversation::ActiveModel = stamped.clone().into(); + active.delegation_call_id = Set(Some("call-orphan".into())); + active.update(&db.conn).await.expect("stamp call id"); + + let rows = list_all(&db.conn, None, None, None, None, None, false) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!(ids.contains(&keep.id), "regular row stays: {ids:?}"); + assert!( + !ids.contains(&stamped.id), + "row carrying a delegation_call_id must stay out of the root list: {ids:?}" + ); + } + + /// `include_children = true` is the sub-session-aware path (the child fetch + /// behind an expanded parent), so none of the three markers may filter there. + #[tokio::test] + async fn list_all_include_children_keeps_delegation_rows() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-root-filter-include").await; + let (parent, child) = seed_parent_with_child(&db.conn, folder).await; + + let rows = list_all(&db.conn, None, None, None, None, None, true) + .await + .expect("list"); + let ids: Vec = rows.iter().map(|r| r.id).collect(); + assert!( + ids.contains(&parent) && ids.contains(&child), + "include_children must not apply the root markers filter: {ids:?}" + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e22edf34..306e807ac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,20 +57,18 @@ mod tauri_app { use crate::acp::manager::ConnectionManager; use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ - acp as acp_commands, app_update as app_update_commands, - automation as automation_commands, background as background_commands, backup, - chat_channel as chat_channel_commands, conversations, - custom_skills as custom_skills_commands, delegation as delegation_commands, + acp as acp_commands, app_update as app_update_commands, automation as automation_commands, + background as background_commands, backup, chat_channel as chat_channel_commands, + conversations, custom_skills as custom_skills_commands, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - office_tools as office_tools_commands, folders, logging as logging_commands, mcp as mcp_commands, - model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, + model_provider as model_provider_commands, notification, + office_tools as office_tools_commands, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, - remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, science as science_commands, - session_info as session_info_commands, - system_settings, terminal as terminal_commands, - version_control, windows, workspace_state as workspace_state_commands, + remote_proxy as remote_proxy_commands, remote_workspace as remote_workspace_commands, + science as science_commands, session_info as session_info_commands, system_settings, + terminal as terminal_commands, version_control, windows, + workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; use crate::{db, git_credential, network, paths, process, web}; @@ -546,6 +544,10 @@ mod tauri_app { &broker_for_init, ) .await; + // Rebuild the continuable delegation-session index from + // persisted child rows (Requirement 7.1) before the + // listener starts accepting. + broker_for_init.rebuild_sessions_from_db().await; crate::commands::feedback::apply_persisted_feedback_config( &db_for_init, &feedback_for_init, @@ -1074,6 +1076,9 @@ mod tauri_app { logging_commands::open_logs_dir, delegation_commands::get_delegation_settings, delegation_commands::set_delegation_settings, + delegation_commands::continue_delegation, + delegation_commands::close_delegation_session, + delegation_commands::get_continuation_availability, feedback_commands::get_feedback_settings, feedback_commands::set_feedback_settings, feedback_commands::submit_session_feedback, diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index b0a88c0b8..a6ddc14c9 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -355,5 +355,12 @@ pub async fn delete_conversation( params.conversation_id, ) .await?; + // The conversation is gone for good — release every delegated child it + // owned (kept-alive processes freed, further continuation refused; + // Requirement 1.4a). A row with no children is a cheap no-op. + state + .delegation_broker + .release_children_of_parent_conversation(params.conversation_id) + .await; Ok(Json(())) } diff --git a/src-tauri/src/web/handlers/delegation.rs b/src-tauri/src/web/handlers/delegation.rs index f88945a30..d26eb03c7 100644 --- a/src-tauri/src/web/handlers/delegation.rs +++ b/src-tauri/src/web/handlers/delegation.rs @@ -10,9 +10,12 @@ use std::sync::Arc; use axum::{extract::Extension, Json}; use serde::Deserialize; +use crate::acp::delegation::broker::ContinuationAvailability; +use crate::acp::delegation::types::DelegationTaskReport; use crate::app_error::AppCommandError; use crate::app_state::AppState; use crate::commands::delegation::{ + close_delegation_session_core, continue_delegation_core, get_continuation_availability_core, load_delegation_settings, set_delegation_settings_core, DelegationSettings, }; @@ -36,3 +39,384 @@ pub async fn set_delegation_settings( .await?; Ok(Json(saved)) } + +// -------- User-side continuation entry (Task 5) ------------------------------ +// +// The web-mode mirrors of the `continue_delegation` / +// `close_delegation_session` / `get_continuation_availability` Tauri +// commands. All three address the child session by its CONVERSATION id (D5) +// and share the `_core` helpers, so target resolution, refusal shapes, and +// broker dispatch stay identical across transports. Rejections ride the +// `DelegationTaskReport` shape (stable `error_code`), never an HTTP error — +// only a real infrastructure fault (DB down) surfaces as one. + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContinueDelegationParams { + pub child_conversation_id: i32, + pub message: String, + /// Caller-minted idempotency key — the frontend generates one per + /// submission and REUSES it on retry (Requirement 2.13). + pub continuation_id: String, +} + +pub async fn continue_delegation( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let report = continue_delegation_core( + &state.db.conn, + &state.delegation_broker, + params.child_conversation_id, + params.message, + params.continuation_id, + ) + .await?; + Ok(Json(report)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseDelegationSessionParams { + pub child_conversation_id: i32, + pub continuation_id: String, +} + +pub async fn close_delegation_session( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let report = close_delegation_session_core( + &state.db.conn, + &state.delegation_broker, + params.child_conversation_id, + params.continuation_id, + ) + .await?; + Ok(Json(report)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetContinuationAvailabilityParams { + pub child_conversation_id: i32, +} + +pub async fn get_continuation_availability( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let availability = get_continuation_availability_core( + &state.db.conn, + &state.delegation_broker, + params.child_conversation_id, + ) + .await?; + Ok(Json(availability)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::delegation::broker::ContinuationAvailability; + use crate::acp::delegation::types::TaskStatus; + use crate::db::service::{conversation_service, folder_service}; + use crate::models::AgentType; + + async fn state_for_test() -> (Arc, tempfile::TempDir) { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let dir = tempfile::tempdir().expect("tempdir"); + let state = crate::app_state::AppState::new_for_test(db, dir.path().to_path_buf()); + (Arc::new(state), dir) + } + + /// Seed a REGULAR root conversation row (`parent_id` null, kind + /// `Regular`) — the shape the user-side entry must refuse to drive + /// through the delegation broker. + async fn seed_root_conversation(state: &crate::app_state::AppState) -> i32 { + let folder = folder_service::add_folder(&state.db.conn, "/w6-root") + .await + .expect("folder"); + conversation_service::create( + &state.db.conn, + folder.id, + AgentType::ClaudeCode, + Some("plain root".into()), + None, + ) + .await + .expect("conversation") + .id + } + + /// 5.1 red · Requirement 4.6 / design acceptance negative ①: continuing a + /// child conversation id that does not exist must answer status `Unknown` + /// — the same verdict a foreign parent's task id gets — so the endpoint + /// never discloses whether a conversation row exists. + #[tokio::test] + async fn continue_on_unknown_child_conversation_reports_unknown() { + let (state, _dir) = state_for_test().await; + let Json(report) = continue_delegation( + Extension(state), + Json(ContinueDelegationParams { + child_conversation_id: 424_242, + message: "hello again".into(), + continuation_id: "cid-unknown-1".into(), + }), + ) + .await + .expect("handler must not surface an HTTP error for an unknown id"); + assert_eq!(report.status, TaskStatus::Unknown); + assert!( + report.error_code.is_none(), + "unknown ids answer with the Unknown status shape, not an error code" + ); + } + + /// 5.1 red · design acceptance negative ②: a regular root conversation + /// (`parent_id` null) is not a delegation subsession — continuing it must + /// be refused with the stable `not_continuable` code. + #[tokio::test] + async fn continue_on_root_conversation_is_rejected() { + let (state, _dir) = state_for_test().await; + let root_id = seed_root_conversation(&state).await; + let Json(report) = continue_delegation( + Extension(state), + Json(ContinueDelegationParams { + child_conversation_id: root_id, + message: "you are not a subagent".into(), + continuation_id: "cid-root-1".into(), + }), + ) + .await + .expect("rejection rides the report shape, not an HTTP error"); + assert_eq!(report.error_code.as_deref(), Some("not_continuable")); + assert_eq!(report.status, TaskStatus::Failed); + } + + /// 5.1 red: closing an unknown child conversation id gets the same + /// non-disclosing `Unknown` verdict as continue. + #[tokio::test] + async fn close_on_unknown_child_conversation_reports_unknown() { + let (state, _dir) = state_for_test().await; + let Json(report) = close_delegation_session( + Extension(state), + Json(CloseDelegationSessionParams { + child_conversation_id: 424_242, + continuation_id: "cid-close-1".into(), + }), + ) + .await + .expect("handler must not surface an HTTP error for an unknown id"); + assert_eq!(report.status, TaskStatus::Unknown); + } + + /// 5.1 red: availability for an unknown id folds into `NotContinuable` — + /// one verdict for "does not exist" and "cannot continue", so the query + /// leaks nothing about row existence. + #[tokio::test] + async fn availability_on_unknown_child_conversation_is_not_continuable() { + let (state, _dir) = state_for_test().await; + let Json(availability) = get_continuation_availability( + Extension(state), + Json(GetContinuationAvailabilityParams { + child_conversation_id: 424_242, + }), + ) + .await + .expect("availability queries never surface an HTTP error for bad ids"); + assert_eq!(availability, ContinuationAvailability::NotContinuable); + } + + /// 5.1 red: a regular root conversation has no continuation availability. + #[tokio::test] + async fn availability_on_root_conversation_is_not_continuable() { + let (state, _dir) = state_for_test().await; + let root_id = seed_root_conversation(&state).await; + let Json(availability) = get_continuation_availability( + Extension(state), + Json(GetContinuationAvailabilityParams { + child_conversation_id: root_id, + }), + ) + .await + .expect("availability queries never surface an HTTP error"); + assert_eq!(availability, ContinuationAvailability::NotContinuable); + } +} + +/// Happy-path coverage for the HTTP handler seam — split from the refusal +/// tests above because it wires a full (mock-spawner) broker + real DB rows. +#[cfg(test)] +mod happy_path_tests { + use std::sync::Arc; + + use super::*; + use crate::acp::delegation::broker::{ + DbChildStatusLookup, DbDepthLookup, DelegationBroker, DelegationConfig, + }; + use crate::acp::delegation::spawner::{mock::MockSpawner, ConnectionSpawner, DelegationLink}; + use crate::acp::delegation::types::{ + DelegationOutcome, DelegationRequest, DelegationSuccess, TaskStatus, + }; + use crate::db::service::{conversation_service, folder_service}; + use crate::models::AgentType; + use sea_orm::{ActiveModelTrait, Set}; + + /// Happy path through the HTTP handler seam: a LIVE delegated child + /// (settled task, kept-alive connection) is continued via the endpoint; + /// the follow-up really reaches the (mock) agent connection, and the JSON + /// response's field names + wire enum casing match the frontend TS mirror + /// (`src/lib/api.ts` `interface DelegationTaskReport`) — the + /// serialization seam broker-level tests never cross. + #[tokio::test] + async fn continue_on_live_session_happy_path_matches_ts_mirror_shape() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let conn = db.conn.clone(); + let db_arc = Arc::new(crate::db::AppDatabase { conn: conn.clone() }); + + // Real DB rows: folder + root parent + delegate child. The child's + // `delegation_call_id` is bound to the broker-minted task id below. + let folder = folder_service::add_folder(&conn, "/w6-happy") + .await + .expect("folder"); + let parent_id = conversation_service::create( + &conn, + folder.id, + AgentType::ClaudeCode, + Some("parent".into()), + None, + ) + .await + .expect("parent") + .id; + let child = conversation_service::create_with_delegation( + &conn, + folder.id, + AgentType::ClaudeCode, + Some("delegated child".into()), + None, + Some(DelegationLink { + parent_conversation_id: parent_id, + parent_tool_use_id: "pt-happy".into(), + delegation_call_id: "pending-task-id".into(), + }), + ) + .await + .expect("child"); + let child_id = child.id; + + // Broker over a mock spawner + the REAL DB-backed lookups (the same + // wiring shape production uses). + let mock = Arc::new(MockSpawner::new()); + let broker = Arc::new( + DelegationBroker::new( + mock.clone() as Arc, + Arc::new(DbDepthLookup { db: db_arc.clone() }), + ) + .with_status_lookup(Arc::new(DbChildStatusLookup { db: db_arc })), + ); + broker + .set_config(DelegationConfig { + enabled: true, + ..DelegationConfig::default() + }) + .await; + + // Settle one full delegation → a live kept-alive child connection. + mock.queue_spawn(Ok("child-conn-live".into())).await; + mock.queue_send(Ok(child_id)).await; + let ack = broker + .start_delegation(DelegationRequest { + parent_connection_id: "parent-conn".into(), + parent_conversation_id: parent_id, + parent_tool_use_id: "pt-happy".into(), + agent_type: AgentType::ClaudeCode, + task: "do x".into(), + working_dir: None, + requested_working_dir: None, + external_handle: None, + }) + .await; + let task_id = ack.task_id.expect("running task carries an id"); + broker + .complete_call( + &task_id, + DelegationOutcome::Ok(DelegationSuccess { + text: "done".into(), + child_conversation_id: child_id, + child_agent_type: AgentType::ClaudeCode, + turn_count: 1, + duration_ms: 5, + token_usage: None, + }), + ) + .await; + // Bind the child row to the broker-minted task id (the D5 join key + // `resolve_delegation_target` reads). + let mut row: crate::db::entities::conversation::ActiveModel = child.into(); + row.delegation_call_id = Set(Some(task_id.clone())); + row.update(&conn).await.expect("bind the call id"); + + // AppState whose broker is the seeded one. + let dir = tempfile::tempdir().expect("tempdir"); + let mut state = crate::app_state::AppState::new_for_test(db, dir.path().to_path_buf()); + state.delegation_broker = broker; + let state = Arc::new(state); + + mock.queue_followup(Ok(())).await; + let Json(report) = continue_delegation( + Extension(state), + Json(ContinueDelegationParams { + child_conversation_id: child_id, + message: "next step".into(), + continuation_id: "cid-happy-1".into(), + }), + ) + .await + .expect("the happy path never surfaces an HTTP error"); + + assert_eq!(report.status, TaskStatus::Running); + assert_eq!(report.task_id.as_deref(), Some(task_id.as_str())); + assert_eq!(report.child_conversation_id, Some(child_id)); + // The follow-up really reached the live agent connection with the + // adopted child row + folder. + let followups = mock.followups.lock().await; + assert_eq!(followups.len(), 1, "exactly one follow-up dispatched"); + assert_eq!(followups[0].conn_id, "child-conn-live"); + assert_eq!(followups[0].message, "next step"); + assert_eq!(followups[0].conversation_id, child_id); + assert_eq!(followups[0].folder_id, folder.id); + drop(followups); + + // Serialization seam: every emitted key must exist in the TS mirror + // (`src/lib/api.ts` interface DelegationTaskReport), and enums ride + // the wire snake_case the mirror declares. + let value = serde_json::to_value(&report).expect("serialize the report"); + let obj = value.as_object().expect("a JSON object"); + const TS_MIRROR_FIELDS: [&str; 8] = [ + "task_id", + "status", + "child_conversation_id", + "agent_type", + "text", + "error_code", + "message", + "duration_ms", + ]; + for key in obj.keys() { + assert!( + TS_MIRROR_FIELDS.contains(&key.as_str()), + "serialized field `{key}` is missing from the src/lib/api.ts \ + DelegationTaskReport mirror" + ); + } + assert_eq!( + obj.get("status").and_then(|v| v.as_str()), + Some("running"), + "TaskStatus must serialize as the snake_case wire string" + ); + assert!(obj.get("task_id").is_some_and(|v| v.is_string())); + assert!(obj.get("child_conversation_id").is_some_and(|v| v.is_i64())); + } +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 0494a5671..d55fde505 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -65,6 +65,18 @@ pub fn build_router( "/set_delegation_settings", post(handlers::delegation::set_delegation_settings), ) + .route( + "/continue_delegation", + post(handlers::delegation::continue_delegation), + ) + .route( + "/close_delegation_session", + post(handlers::delegation::close_delegation_session), + ) + .route( + "/get_continuation_availability", + post(handlers::delegation::get_continuation_availability), + ) .route( "/get_feedback_settings", post(handlers::feedback::get_feedback_settings), @@ -614,10 +626,7 @@ pub fn build_router( "/acp_set_config_option", post(handlers::acp::acp_set_config_option), ) - .route( - "/acp_goal_control", - post(handlers::acp::acp_goal_control), - ) + .route("/acp_goal_control", post(handlers::acp::acp_goal_control)) .route( "/acp_describe_agent_options", post(handlers::acp::acp_describe_agent_options), @@ -1127,7 +1136,10 @@ pub fn build_router( "/automation_list", post(handlers::automation::automation_list), ) - .route("/automation_get", post(handlers::automation::automation_get)) + .route( + "/automation_get", + post(handlers::automation::automation_get), + ) .route( "/automation_runs", post(handlers::automation::automation_runs), diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 60891d511..892fbb433 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -13,6 +13,7 @@ import { CheckCircle2, Info, ChevronRight, + GitBranch, } from "lucide-react" import { useTranslations } from "next-intl" import type { DbConversationSummary, ConversationStatus } from "@/lib/types" @@ -51,6 +52,7 @@ import { Input } from "@/components/ui/input" import { ConversationStatusDot } from "./conversation-status-dot" import { SessionDetailsDialog } from "./session-details-dialog" import { AgentIcon } from "@/components/agent-icon" +import { isDelegationSubsession } from "@/lib/conversation-sidebar" /** * Horizontal indent added per delegation-nesting level. Chosen so a child's @@ -117,6 +119,12 @@ interface SidebarConversationCardProps { /** True when `child_count > 0`: the conversation has delegation children, so * the expand chevron is shown. */ hasChildren?: boolean + /** + * Number of delegation children to advertise on a PARENT row while nested + * rows are hidden by the funnel filter, so the nested work stays + * discoverable. Omit when the subtree is visible — the chevron covers it. + */ + childCountHint?: number /** Whether this conversation's sub-session subtree is currently expanded. */ expanded?: boolean /** Toggle this conversation's sub-session subtree (lazily loads on expand). */ @@ -137,6 +145,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ onTogglePin, depth = 0, hasChildren = false, + childCountHint, expanded = false, onToggleExpand, }: SidebarConversationCardProps) { @@ -207,7 +216,9 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ // hover quick actions: pinning a sub-agent run to the root Pinned section or // hand-toggling its status doesn't fit — its lifecycle is the sub-agent's. The // time / running badge then stays visible on hover (nothing swaps in for it). - const isSubsession = conversation.parent_id != null + // Decided by the shared DB-marker predicate, never by `depth`: worktree layout + // indents ordinary roots too (see `isDelegationSubsession`). + const isSubsession = isDelegationSubsession(conversation) return ( <> @@ -216,6 +227,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
+ {/* Delegated sub-session marker: without it a nested row is + indistinguishable from a worktree-indented ordinary session + (both sit at depth ≥ 1). */} + {isSubsession && ( + + + {t("subsessionBadge")} + + )} + {/* Parent row while the subtree is filtered out: advertise that + nested work exists, and how to reveal it. */} + {childCountHint != null && childCountHint > 0 && ( + + + {t("subsessionCountLabel", { count: childCountHint })} + + )} {/* Expand/collapse affordance for delegation children. It overlays diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 6a2200fb7..893b5bf5f 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -107,6 +107,7 @@ import { type SidebarRow, } from "./sidebar-conversation-grouping" import { useSubsessionSync } from "@/hooks/use-subsession-sync" +import { isSidebarRootConversation } from "@/lib/conversation-sidebar" import { SidebarSectionHeader } from "./sidebar-section-header" import { ConversationManageDialog } from "./conversation-manage-dialog" import { CloneDialog } from "@/components/layout/clone-dialog" @@ -163,6 +164,16 @@ const EMPTY_CHILD_TO_PARENT: ReadonlyMap = new Map() const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = new Map() +// Same trick for the sub-session filter: while "Show delegated sub-sessions" is +// off, buildRows is fed these stable empties instead of the live expansion set / +// child cache, so no nested row can be emitted and the memo stays reference- +// stable across renders. +const EMPTY_EXPANDED_IDS: ReadonlySet = new Set() +const EMPTY_CHILDREN_BY_PARENT: ReadonlyMap< + number, + readonly DbConversationSummary[] +> = new Map() + const FolderHeader = memo(function FolderHeader({ folderId, folderName, @@ -677,6 +688,13 @@ export interface SidebarConversationListHandle { export interface SidebarConversationListProps { showCompleted?: boolean + /** + * When false (the funnel-menu default), delegation sub-session subtrees stay + * collapsed out of the list entirely: no expand chevrons, no nested rows, no + * child prefetch. Parent rows still advertise their child count so the nested + * work remains discoverable. + */ + showSubsessions?: boolean sortMode?: SidebarSortMode sectionOrder?: SidebarSectionOrder /** When on, each repo's worktree child folders render as indented sub-groups @@ -687,6 +705,7 @@ export interface SidebarConversationListProps { export function SidebarConversationList({ ref, showCompleted = true, + showSubsessions = false, sortMode = "created", sectionOrder = "folders-first", showWorktrees = false, @@ -995,9 +1014,13 @@ export function SidebarConversationList({ // Folder grouping source: pinned conversations are surfaced in the dedicated // Pinned section, and folderless chat conversations in the dedicated Chat // section, so exclude both here; then apply the completed filter as before. + // Delegation children are dropped too (the backend already excludes them from + // the root list) so a leaked child can never render as a folder peer — it only + // ever appears nested under its parent. const folderConversations = useMemo(() => { const base = conversations.filter( - (c) => c.pinned_at == null && c.kind !== "chat" + (c) => + c.pinned_at == null && c.kind !== "chat" && isSidebarRootConversation(c) ) if (showCompleted) return base return base.filter((c) => c.status !== "completed") @@ -1149,6 +1172,23 @@ export function SidebarConversationList({ const darkMode = resolvedTheme === "dark" + // With sub-sessions filtered out, force an empty expansion set AND an empty + // children cache so `buildRows` cannot emit nested rows even when a previous + // expand already loaded children. The persisted expanded ids are left intact, + // so turning the filter back on restores exactly what the user had open. + const effectiveConversationExpanded = useMemo( + () => (showSubsessions ? conversationExpanded : EMPTY_EXPANDED_IDS), + [showSubsessions, conversationExpanded] + ) + const effectiveChildrenByParent = useMemo( + () => (showSubsessions ? childrenByParent : EMPTY_CHILDREN_BY_PARENT), + [showSubsessions, childrenByParent] + ) + const effectiveChildrenLoading = useMemo( + () => (showSubsessions ? childrenLoading : EMPTY_EXPANDED_IDS), + [showSubsessions, childrenLoading] + ) + // Flat row model for windowing — the pinned section, the folders section, and // every conversation live in this ONE array fed to the single Virtualizer (no // separate, un-virtualized pinned list). Deliberately excludes `now` (see @@ -1169,9 +1209,9 @@ export function SidebarConversationList({ chatConversations, chatsExpanded, sectionOrder, - conversationExpanded, - childrenByParent, - childrenLoading, + conversationExpanded: effectiveConversationExpanded, + childrenByParent: effectiveChildrenByParent, + childrenLoading: effectiveChildrenLoading, containerChildren, rootGroupCollapsed, }), @@ -1186,9 +1226,9 @@ export function SidebarConversationList({ chatConversations, chatsExpanded, sectionOrder, - conversationExpanded, - childrenByParent, - childrenLoading, + effectiveConversationExpanded, + effectiveChildrenByParent, + effectiveChildrenLoading, containerChildren, rootGroupCollapsed, ] @@ -1492,7 +1532,7 @@ export function SidebarConversationList({ // a render-time microtask) keeps the side effect out of render; the // cache/in-flight dedupe makes the repeated passes cheap and StrictMode-safe. useEffect(() => { - if (loading) return + if (loading || !showSubsessions) return for (const row of rows) { if (row.kind !== "conversation") continue const c = row.conversation @@ -1505,7 +1545,13 @@ export function SidebarConversationList({ void ensureChildrenLoaded(c.id) } } - }, [rows, conversationExpanded, loading, ensureChildrenLoaded]) + }, [ + rows, + conversationExpanded, + loading, + ensureChildrenLoaded, + showSubsessions, + ]) // ── Sticky folder header overlay ────────────────────────────────────────── // Resolve the folder currently scrolled through and the iOS handoff offset @@ -2291,9 +2337,16 @@ export function SidebarConversationList({ onNewConversation={handleNewConversationForFolder} onTogglePin={handleTogglePin} depth={row.depth} - hasChildren={conv.child_count > 0} - expanded={conversationExpanded.has(conv.id)} - onToggleExpand={toggleConversation} + hasChildren={showSubsessions && conv.child_count > 0} + // Subtree hidden: keep the nested work discoverable via a count badge + // (the chevron owns that job when the subtree is visible). + childCountHint={ + !showSubsessions && conv.child_count > 0 + ? conv.child_count + : undefined + } + expanded={showSubsessions ? conversationExpanded.has(conv.id) : false} + onToggleExpand={showSubsessions ? toggleConversation : undefined} /> ) } diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index ed47d2533..26c34e76a 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -45,10 +45,12 @@ import { leftChromeReserve } from "@/lib/window-chrome" import { loadShowCompleted, loadShowWorktrees, + loadShowSubsessions, loadSortMode, loadSectionOrder, saveShowCompleted, saveShowWorktrees, + saveShowSubsessions, saveSortMode, saveSectionOrder, type SidebarSortMode, @@ -132,12 +134,14 @@ export function Sidebar() { // rem-sized overlay buttons. Mobile has no overlay (the sidebar is a Sheet). const leftReserve = leftChromeReserve(platformIsMac && isDesktop(), zoomLevel) - // `showCompleted` defaults OFF and `showWorktrees` defaults ON (the mount - // effect below reconciles a persisted override). Each initial value matches - // its own default so the pre-hydration render doesn't flash as the stored - // preference is applied. + // `showCompleted` defaults OFF, `showWorktrees` defaults ON, and + // `showSubsessions` defaults OFF (delegation subtrees only render once the + // user opts in). The mount effect below reconciles a persisted override. Each + // initial value matches its own default so the pre-hydration render doesn't + // flash as the stored preference is applied. const [showCompleted, setShowCompleted] = useState(false) const [showWorktrees, setShowWorktrees] = useState(true) + const [showSubsessions, setShowSubsessions] = useState(false) const [sortMode, setSortMode] = useState("created") const [sectionOrder, setSectionOrder] = useState("folders-first") @@ -163,6 +167,7 @@ export function Sidebar() { // eslint-disable-next-line react-hooks/set-state-in-effect setShowCompleted(loadShowCompleted()) setShowWorktrees(loadShowWorktrees()) + setShowSubsessions(loadShowSubsessions()) setSortMode(loadSortMode()) setSectionOrder(loadSectionOrder()) }, []) @@ -177,6 +182,11 @@ export function Sidebar() { saveShowWorktrees(value) }, []) + const handleSetShowSubsessions = useCallback((value: boolean) => { + setShowSubsessions(value) + saveShowSubsessions(value) + }, []) + const handleSetSortMode = useCallback((value: string) => { const mode: SidebarSortMode = value === "updated" ? "updated" : "created" setSortMode(mode) @@ -343,6 +353,12 @@ export function Sidebar() { > {t("showWorktrees")} + + {t("showSubsessions")} + {t("sortBy")} diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index a52f7addb..6a8dc4583 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2394,6 +2394,30 @@ const ToolCallPart = memo(function ToolCallPart({ /> ) } + // Continue / close (v2 keep-alive contract): compact single-row cards in + // the same family. `close_session` renders with RELEASE wording (R2-B4). + if (toolNameLower === "continue_with_session") { + return ( + + ) + } + if (toolNameLower === "close_session") { + return ( + + ) + } // codeg-mcp ask_user_question: render the asked question(s) and the user's // selection as a dedicated read-only card instead of the generic tool shell. diff --git a/src/components/message/delegation-status-card.test.tsx b/src/components/message/delegation-status-card.test.tsx index 4eec94468..52da098cb 100644 --- a/src/components/message/delegation-status-card.test.tsx +++ b/src/components/message/delegation-status-card.test.tsx @@ -68,6 +68,44 @@ describe("DelegationStatusCard", () => { expect(screen.getByText("Canceling task #abc12345")).toBeInTheDocument() }) + it("uses the continue label for continue_with_session (Task 5.4)", () => { + renderWithIntl( + + ) + expect(screen.getByText("Continuing task #abc12345")).toBeInTheDocument() + }) + + it("uses the RELEASE wording for close_session (Task 5.4 · R2-B4)", () => { + renderWithIntl( + + ) + expect(screen.getByText("Releasing task #abc12345")).toBeInTheDocument() + }) + + it("treats a canceled report as the SUCCESS outcome for close (releasing a running task cancels it)", () => { + renderWithIntl( + + ) + expect(screen.getByText("done")).toBeInTheDocument() + }) + it("falls back to a task-less label when the task_id can't be parsed", () => { // A settled poll that returned a note but no resolvable id still shows the // task-less label (there is content to surface). diff --git a/src/components/message/delegation-status-card.tsx b/src/components/message/delegation-status-card.tsx index 9894ed3c9..4354a41cd 100644 --- a/src/components/message/delegation-status-card.tsx +++ b/src/components/message/delegation-status-card.tsx @@ -33,9 +33,10 @@ import { DelegationStatusGroupCard } from "@/components/message/delegation-statu interface Props { /** Which companion tool this card represents — selects the label + icon. */ - kind: "status" | "cancel" + kind: "status" | "cancel" | "continue" | "close" /** Raw JSON arguments sent to the tool — status: `{ task_ids, wait_ms? }` (or - * a legacy `{ task_id }` in historical transcripts); cancel: `{ task_id }`. */ + * a legacy `{ task_id }` in historical transcripts); cancel/close: + * `{ task_id }`; continue: `{ task_id, message, continuation_id }`. */ input?: string | null output?: string | null errorText?: string | null @@ -69,18 +70,18 @@ export function DelegationStatusCard({ [input, output, errorText, state] ) - // Cancel: always a single task — keep the single-report path. - const cancelReport = useMemo( + // Cancel / continue / close: always a single task — the single-report path. + const singleReport = useMemo( () => parseStatusReport(output, errorText), [output, errorText] ) - const cancelTaskId = useMemo( - () => parseTaskId(input) ?? cancelReport.taskId, - [input, cancelReport] + const singleTaskId = useMemo( + () => parseTaskId(input) ?? singleReport.taskId, + [input, singleReport] ) - const cancelBadge = useMemo( - () => deriveBadge("cancel", cancelReport, state, !!errorText), - [cancelReport, state, errorText] + const singleBadge = useMemo( + () => deriveBadge(kind, singleReport, state, !!errorText), + [kind, singleReport, state, errorText] ) if (kind === "status") { @@ -92,7 +93,7 @@ export function DelegationStatusCard({ ) } - const isError = cancelBadge.status === "err" + const isError = singleBadge.status === "err" return (
) diff --git a/src/components/message/delegation-status-row.tsx b/src/components/message/delegation-status-row.tsx index 428aa549f..0d66a72d9 100644 --- a/src/components/message/delegation-status-row.tsx +++ b/src/components/message/delegation-status-row.tsx @@ -24,6 +24,8 @@ import { ChevronDown, ChevronLeft, ChevronRight, + MessageSquarePlus, + Unplug, } from "lucide-react" import { useTranslations } from "next-intl" @@ -40,7 +42,7 @@ import { StatusBadge } from "@/components/message/delegation-status-badge" interface DelegationStatusRowProps { /** Which companion tool this row represents — selects the label + icon. */ - kind: "status" | "cancel" + kind: "status" | "cancel" | "continue" | "close" taskId: string | null report: StatusReport badge: ResolvedBadge @@ -104,11 +106,26 @@ export function DelegationStatusRow({ ? shortId ? t("cancelTask", { task: `#${shortId}` }) : t("cancelTaskNoTask") - : shortId - ? t("waitForResult", { task: `#${shortId}` }) - : t("waitForResultNoTask") + : kind === "continue" + ? shortId + ? t("continueTask", { task: `#${shortId}` }) + : t("continueTaskNoTask") + : kind === "close" + ? shortId + ? t("closeTask", { task: `#${shortId}` }) + : t("closeTaskNoTask") + : shortId + ? t("waitForResult", { task: `#${shortId}` }) + : t("waitForResultNoTask") - const Icon = kind === "cancel" ? Ban : Activity + const Icon = + kind === "cancel" + ? Ban + : kind === "continue" + ? MessageSquarePlus + : kind === "close" + ? Unplug + : Activity const row = ( <> diff --git a/src/components/message/sub-agent-session-dialog.test.tsx b/src/components/message/sub-agent-session-dialog.test.tsx index ebb3d26d7..ebef908f0 100644 --- a/src/components/message/sub-agent-session-dialog.test.tsx +++ b/src/components/message/sub-agent-session-dialog.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" @@ -18,11 +18,35 @@ const mockGetSession = vi.fn() const mockGetTimelineTurns = vi.fn(() => []) const mockRespondPermission = vi.fn() const mockAnswerQuestion = vi.fn() +const mockOpenTab = vi.fn() // syncTurnMetadata returns a cancel function; hand back a spy so tests can // assert both that the backfill is kicked off and that it's cancelled on close. const mockSyncCancel = vi.fn() const mockSyncTurnMetadata = vi.fn(() => mockSyncCancel) +// User-side continuation entry (Task 5.3): the composer queries the five-tier +// availability and submits through the broker-backed `continueDelegation`. +const mockGetContinuationAvailability = vi.fn() +const mockContinueDelegation = vi.fn() +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api") + return { + ...actual, + // Tests that don't stage a verdict (the pre-existing bridge suite) get a + // benign resolved default so the composer's mount query never rejects the + // whole render tree. + getContinuationAvailability: (...args: unknown[]) => + mockGetContinuationAvailability(...args) ?? + Promise.resolve("not_continuable"), + continueDelegation: (...args: unknown[]) => mockContinueDelegation(...args), + } +}) + +// `useAcpEvent` needs the AcpConnectionsProvider; the dialog tests render +// without one, so capture the handlers instead — a test fires them to +// simulate a delegation event reaching the composer's availability refresh. +let acpEventHandlers: Array<(envelope: unknown) => void> = [] + vi.mock("@/stores/conversation-runtime-store", async () => { const actual = await vi.importActual< typeof import("@/stores/conversation-runtime-store") @@ -92,6 +116,11 @@ vi.mock("@/contexts/acp-connections-context", async () => { respondPermission: mockRespondPermission, answerQuestion: mockAnswerQuestion, }), + useAcpEvent: (handler: (envelope: unknown) => void) => { + // The hook runs every render; the composer's handler is useCallback- + // stable, so dedupe by identity (the real hook stores one ref). + if (!acpEventHandlers.includes(handler)) acpEventHandlers.push(handler) + }, } }) @@ -142,9 +171,11 @@ vi.mock("@/components/chat/ask-question-card", () => ({ // useConversationDetail drives the persisted-detail fetch. We don't need // to exercise the real fetch — just expose a controlled `loading` flag so -// tests can step through the detail-load lifecycle. +// tests can step through the detail-load lifecycle. `detail.summary` also +// carries the child's folder id / agent type, which the "Open in tab" handoff +// needs, so tests can swap in a summary to enable that button. let mockDetailState: { - detail: null + detail: { summary: { folder_id: number; agent_type: string } } | null loading: boolean error: string | null acpLoadError: string | null @@ -158,6 +189,19 @@ vi.mock("@/hooks/use-conversation-detail", () => ({ useConversationDetail: () => mockDetailState, })) +// Tab store — the "Open in tab" control hands the child off through `openTab`. +// Record the call so we can assert it targets the child's OWN folder/id/agent. +vi.mock("@/stores/tab-store", async () => { + const actual = + await vi.importActual( + "@/stores/tab-store" + ) + return { + ...actual, + useTabActions: () => ({ openTab: mockOpenTab }), + } +}) + // MessageListView pulls in the full runtime provider + virtualization // stack. Stub it to a sentinel that records the props we care about, // so the read-only-mode test can assert that no `onReload`/`onNewSession`/ @@ -239,6 +283,7 @@ describe("SubAgentSessionDialog", () => { mockGetTimelineTurns.mockClear() mockRespondPermission.mockReset() mockAnswerQuestion.mockReset() + mockOpenTab.mockReset() mockSyncCancel.mockReset() mockSyncTurnMetadata.mockClear() mockSyncTurnMetadata.mockReturnValue(mockSyncCancel) @@ -788,4 +833,336 @@ describe("SubAgentSessionDialog", () => { fireEvent.click(closeButton) expect(onOpenChange).toHaveBeenCalledWith(false) }) + + // M1 · Requirement 4.5 + the §M1 capability boundary (R1-A7). The dialog is + // read-only; the full tab is where the child can be driven — but sends from + // there bypass the broker, so the limitation must be stated in the UI, not + // left for the user to discover. + it("hands the child off to its own workspace tab once the summary lands", () => { + mockDetailState = { + detail: { summary: { folder_id: 7, agent_type: "codex" } }, + loading: false, + error: null, + acpLoadError: null, + } + const onOpenChange = vi.fn() + renderWithIntl( + + ) + const openInTab = screen.getByRole("button", { name: /open in tab/i }) + expect(openInTab).not.toBeDisabled() + fireEvent.click(openInTab) + // Targets the CHILD's own folder / conversation / agent — openTab keys its + // dedupe on that triple, so a wrong folder id would fork a duplicate tab. + expect(mockOpenTab).toHaveBeenCalledWith(7, 99, "codex") + // And the (now redundant) read-only dialog steps aside. + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it("keeps the tab handoff disabled while the child's folder id is unknown", () => { + // No persisted summary yet ⇒ no folder id ⇒ openTab would be unroutable. + renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + const openInTab = screen.getByRole("button", { name: /open in tab/i }) + expect(openInTab).toBeDisabled() + fireEvent.click(openInTab) + expect(mockOpenTab).not.toHaveBeenCalled() + }) + + it("states the M1 boundary: messages sent from the full tab are not synced to the main AI", () => { + renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + expect( + screen.getByText( + enMessages.Folder.chat.delegation.openInTabNotSyncedNotice + ) + ).toBeInTheDocument() + }) +}) + +/** + * Multi-round continuation (Requirement 4.7 · design.md Property 5). A + * delegation child is no longer one-shot: after the first reply settles the + * session can be continued, so every "promote the retained reply" path must be + * re-entrant per ROUND. A mount-wide latch would silently drop round 2+. + */ +describe("SubAgentSessionDialog multi-round continuation", () => { + beforeEach(() => { + mockSetLiveMessage.mockReset() + mockCompleteTurn.mockReset() + mockRemoveConversation.mockReset() + mockRefetchDetail.mockReset() + mockSetLiveOwnsActiveTurn.mockReset() + mockSyncCancel.mockReset() + mockSyncTurnMetadata.mockClear() + mockSyncTurnMetadata.mockReturnValue(mockSyncCancel) + mockChildConnection = undefined + storeCallbacks = [] + mockDetailState = { + detail: null, + loading: false, + error: null, + acpLoadError: null, + } + }) + + const reply = (id: string) => ({ + id, + role: "assistant" as const, + content: [], + startedAt: Date.now(), + }) + + it("adopts a SECOND retained reply after already adopting the first (settled → settled rounds)", () => { + const first = reply("live-round-1") + mockChildConnection = makeConnState({ + status: "connected", + liveMessage: first, + }) + renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + expect(mockCompleteTurn).toHaveBeenCalledWith(99, first) + + // A continuation runs and settles; the connection now retains round 2's + // reply. The dialog never saw "prompting" for it (events can coalesce), so + // only the adopt path can promote it. + const second = reply("live-round-2") + mockChildConnection = makeConnState({ + status: "connected", + liveMessage: second, + }) + act(() => { + notifyStore() + }) + expect(mockCompleteTurn).toHaveBeenCalledWith(99, second) + expect(mockSyncTurnMetadata).toHaveBeenCalledTimes(2) + }) + + it("adopts a later round's retained reply even after streaming was observed earlier", () => { + const first = reply("live-round-1") + mockChildConnection = makeConnState({ + status: "prompting", + liveMessage: first, + }) + renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + // Round 1 settles through the streaming → settled edge. + mockChildConnection = makeConnState({ + status: "connected", + liveMessage: first, + }) + act(() => { + notifyStore() + }) + expect(mockCompleteTurn).toHaveBeenCalledTimes(1) + expect(mockCompleteTurn).toHaveBeenCalledWith(99, first) + + // Round 2's reply appears already settled — the mount-wide + // "ever streamed" latch must not disqualify it. + const second = reply("live-round-2") + mockChildConnection = makeConnState({ + status: "connected", + liveMessage: second, + }) + act(() => { + notifyStore() + }) + expect(mockCompleteTurn).toHaveBeenCalledWith(99, second) + }) + + it("promotes each reply exactly once — the settle edge and the adopt path never double-count the same round", () => { + const first = reply("live-round-1") + mockChildConnection = makeConnState({ + status: "prompting", + liveMessage: first, + }) + renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + mockChildConnection = makeConnState({ + status: "connected", + liveMessage: first, + }) + act(() => { + notifyStore() + }) + // Idle re-notify with the SAME retained reply (grace window ticks). + act(() => { + notifyStore() + }) + expect( + mockCompleteTurn.mock.calls.filter((c) => c[1] === first) + ).toHaveLength(1) + }) +}) + +describe("SubAgentSessionDialog continuation composer (Task 5.3)", () => { + beforeEach(() => { + mockGetContinuationAvailability.mockReset() + mockContinueDelegation.mockReset() + acpEventHandlers = [] + mockChildConnection = undefined + storeCallbacks = [] + mockDetailState = { + detail: null, + loading: false, + error: null, + acpLoadError: null, + } + }) + + function renderDialog() { + return renderWithIntl( + {}} + childConversationId={99} + childConnectionId="c1" + agentType="codex" + /> + ) + } + + it("queries availability on mount; continuable_live enables the input and submit routes through continueDelegation", async () => { + mockGetContinuationAvailability.mockResolvedValue("continuable_live") + mockContinueDelegation.mockResolvedValue({ + task_id: "t-1", + status: "running", + }) + renderDialog() + expect(mockGetContinuationAvailability).toHaveBeenCalledWith(99) + + const input = await screen.findByTestId("continuation-input") + await waitFor(() => expect(input).not.toBeDisabled()) + fireEvent.change(input, { target: { value: "one more thing" } }) + fireEvent.click(screen.getByTestId("continuation-send")) + + await waitFor(() => expect(mockContinueDelegation).toHaveBeenCalledTimes(1)) + const [convId, message, continuationId] = + mockContinueDelegation.mock.calls[0] + expect(convId).toBe(99) + expect(message).toBe("one more thing") + expect(typeof continuationId).toBe("string") + expect((continuationId as string).length).toBeGreaterThan(0) + // Accepted → the input clears and the composer reflects the running turn. + await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe("")) + }) + + it("released availability disables the input and shows the release notice", async () => { + mockGetContinuationAvailability.mockResolvedValue("released") + renderDialog() + const input = await screen.findByTestId("continuation-input") + await waitFor(() => expect(input).toBeDisabled()) + expect( + screen.getByText( + enMessages.Folder.chat.delegation.continueUnavailableReleased + ) + ).toBeInTheDocument() + }) + + it("a rejected send surfaces the error code and a retry of the SAME text reuses the continuation id", async () => { + mockGetContinuationAvailability.mockResolvedValue("continuable_live") + mockContinueDelegation.mockResolvedValue({ + task_id: "t-1", + status: "failed", + error_code: "session_still_running", + }) + renderDialog() + const input = await screen.findByTestId("continuation-input") + await waitFor(() => expect(input).not.toBeDisabled()) + fireEvent.change(input, { target: { value: "retry me" } }) + fireEvent.click(screen.getByTestId("continuation-send")) + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "session_still_running" + ) + ) + // The draft survives a rejection so the user can retry. + expect((input as HTMLTextAreaElement).value).toBe("retry me") + + fireEvent.click(screen.getByTestId("continuation-send")) + await waitFor(() => expect(mockContinueDelegation).toHaveBeenCalledTimes(2)) + const idFirst = mockContinueDelegation.mock.calls[0][2] + const idRetry = mockContinueDelegation.mock.calls[1][2] + expect(idRetry).toBe(idFirst) + }) + + it("a delegation event for THIS child re-queries availability", async () => { + mockGetContinuationAvailability.mockResolvedValue("running") + renderDialog() + await waitFor(() => + expect(mockGetContinuationAvailability).toHaveBeenCalledTimes(1) + ) + act(() => { + for (const h of acpEventHandlers) { + h({ + type: "delegation_session_update", + parent_connection_id: "p1", + child_conversation_id: 99, + task_id: "t-1", + turn_id: "turn-2", + turn_version: 2, + origin: "user", + }) + } + }) + await waitFor(() => + expect(mockGetContinuationAvailability).toHaveBeenCalledTimes(2) + ) + // An event for a DIFFERENT child must not trigger a refresh. + act(() => { + for (const h of acpEventHandlers) { + h({ + type: "delegation_session_update", + parent_connection_id: "p1", + child_conversation_id: 12345, + task_id: "t-other", + turn_id: "turn-9", + turn_version: 3, + origin: "parent_agent", + }) + } + }) + expect(mockGetContinuationAvailability).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/components/message/sub-agent-session-dialog.tsx b/src/components/message/sub-agent-session-dialog.tsx index 366ba78dc..0f3d949d6 100644 --- a/src/components/message/sub-agent-session-dialog.tsx +++ b/src/components/message/sub-agent-session-dialog.tsx @@ -26,9 +26,22 @@ * own DB writes, surfaced via `useConversationDetail`. */ -import { useCallback, useEffect, useRef, useSyncExternalStore } from "react" +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react" +import { ExternalLink, Info, SendHorizontal } from "lucide-react" import { useTranslations } from "next-intl" +import { + continueDelegation, + getContinuationAvailability, + type ContinuationAvailability, +} from "@/lib/api" + import { AgentIcon } from "@/components/agent-icon" import { MessageListView } from "@/components/message/message-list-view" import { @@ -38,9 +51,11 @@ import { DialogTitle, } from "@/components/ui/dialog" import { useConversationDetail } from "@/hooks/use-conversation-detail" +import { useTabActions } from "@/stores/tab-store" import { useConversationRuntimeActions } from "@/stores/conversation-runtime-store" import { useAcpActions, + useAcpEvent, useConnectionStore, type ConnectionState, } from "@/contexts/acp-connections-context" @@ -155,17 +170,28 @@ function useChildLiveBridge( // and promotes the live reply. This stays correct across the transition // because the mirror-live effect's cleanup gates on `connStatusRef` (which // still reads "prompting" at cleanup time, since React updates it only in a - // later setup pass) rather than on effect declaration order. We also latch - // whether we ever observed streaming this mount, so the adopt-settled-reply - // effect below can tell a fresh "reopened after the child already finished" - // mount from a normal streaming→settled handoff. + // later setup pass) rather than on effect declaration order. + // + // Promotion bookkeeping is PER ROUND, keyed by the reply's `liveMessage.id` + // (minted fresh per prompt cycle), because a delegation child is continuable + // (Requirement 4.7): a mount-wide latch would make every path below dead + // after round one. `streamedReplyIds` records the replies we saw stream, so + // the adopt effect can tell them from a round that was already settled when + // we first observed it; `promotedReplyIds` records what has been promoted, so + // the two paths never double-count one round. Both are bounded by the number + // of rounds taken while the dialog stays open. const prevStatusRef = useRef(connStatus) - const everPromptingRef = useRef(connStatus === "prompting") + const streamedReplyIdsRef = useRef(new Set()) + const promotedReplyIdsRef = useRef(new Set()) useEffect(() => { const wasPrompting = prevStatusRef.current === "prompting" prevStatusRef.current = connStatus - if (connStatus === "prompting") everPromptingRef.current = true - if (!wasPrompting || connStatus === "prompting") return + if (connStatus === "prompting") { + if (liveMessage != null) streamedReplyIdsRef.current.add(liveMessage.id) + return + } + if (!wasPrompting) return + if (liveMessage != null) promotedReplyIdsRef.current.add(liveMessage.id) completeTurn(childConversationId, liveMessage) startMetadataSync() }, [ @@ -191,26 +217,29 @@ function useChildLiveBridge( } }, [liveMessage, connStatus, childConversationId, setLiveMessage]) - // Adopt-settled-reply: handle reopening the dialog onto a child that ALREADY - // finished but whose connection still carries its final liveMessage (kept for - // CHILD_DETACH_GRACE_MS after completion to bridge DB lag). For such a mount - // the streaming→settled completeTurn edge never fires (we never saw - // "prompting"), and the non-live mirror above is rejected by the - // SET_LIVE_MESSAGE guard while the mount fetch is loading — so without this - // the final reply would vanish whenever the persisted transcript still lags - // (empty / user-only / partial detail). Adopt the retained reply directly: - // bridge it as live (a one-shot child's liveMessage is unambiguously its own - // reply, never a stale reconnect replay) then promote it to a COMPLETED local - // turn (no streaming affordance), where the `liveOwnsActiveTurn` projection - // keeps it and dedupes the persisted copy once the DB catches up. Runs at most - // once, and never when streaming was observed (that path promotes via the - // settled edge). - const adoptedRef = useRef(false) + // Adopt-settled-reply: handle observing a child reply that is ALREADY settled + // but whose connection still carries it (kept for CHILD_DETACH_GRACE_MS after + // completion to bridge DB lag) — either because the dialog was reopened onto a + // finished child, or because a continued round's streaming→settled edge never + // reached us (events coalesced). For such a reply the completeTurn edge above + // never fires and the non-live mirror is rejected by the SET_LIVE_MESSAGE + // guard while the mount fetch is loading — so without this the reply would + // vanish whenever the persisted transcript still lags (empty / user-only / + // partial detail). Adopt it directly: bridge it as live (the child's retained + // liveMessage is unambiguously its own latest reply, never a stale reconnect + // replay) then promote it to a COMPLETED local turn (no streaming affordance), + // where the `liveOwnsActiveTurn` projection keeps it and dedupes the persisted + // copy once the DB catches up. + // + // Per-round, not per-mount (Requirement 4.7): a reply is adopted at most once + // (`promotedReplyIds`), and never when we watched it stream (that path + // promotes via the settled edge), but a LATER round is still eligible. useEffect(() => { - if (adoptedRef.current || everPromptingRef.current) return if (connStatus == null || connStatus === "prompting") return if (liveMessage == null) return - adoptedRef.current = true + if (promotedReplyIdsRef.current.has(liveMessage.id)) return + if (streamedReplyIdsRef.current.has(liveMessage.id)) return + promotedReplyIdsRef.current.add(liveMessage.id) setLiveMessage(childConversationId, liveMessage, true) completeTurn(childConversationId, liveMessage) startMetadataSync() @@ -261,6 +290,7 @@ export function SubAgentSessionDialog({ childConnectionId={childConnectionId} agentType={agentType} kickoffTask={kickoffTask} + onCloseRequest={() => onOpenChange(false)} /> ) : null} @@ -273,11 +303,14 @@ function SubAgentSessionBody({ childConnectionId, agentType, kickoffTask, + onCloseRequest, }: { childConversationId: number childConnectionId: string | null agentType: AgentType | null kickoffTask?: string | null + /** Dismiss the dialog once the child has been handed off to a full tab. */ + onCloseRequest: () => void }) { const t = useTranslations("Folder.chat.delegation") @@ -309,12 +342,32 @@ function SubAgentSessionBody({ }, [childConversationId, refetchDetail]) // Reader only — its built-in auto-fetch is disabled; the effect above is - // the sole fetch path. - const { loading, error, acpLoadError } = useConversationDetail( + // the sole fetch path. `detail.summary` is also the only place the child's + // folder id is available (the card knows the conversation id, not the folder), + // so "Open in tab" stays disabled until the persisted summary lands. + const { detail, loading, error, acpLoadError } = useConversationDetail( childConversationId, { enabled: false } ) + const { openTab } = useTabActions() + const childFolderId = detail?.summary.folder_id ?? null + const tabAgentType = detail?.summary.agent_type ?? agentType + const handleOpenInTab = useCallback(() => { + if (childFolderId == null || tabAgentType == null) return + // Same path the sidebar uses for a sub-session row (`handleSelect` → + // `openTab`); openTab itself brings the conversation pane forward via its + // `activateConversationPane` side effect, so no route call is needed here. + openTab(childFolderId, childConversationId, tabAgentType) + onCloseRequest() + }, [ + childFolderId, + tabAgentType, + childConversationId, + openTab, + onCloseRequest, + ]) + // While streaming, mask loading as false: the live bridge owns the reply and // the synthesized kickoff covers the user turn, so we don't want a skeleton // over the live stream. Passed to MessageListView only. @@ -379,6 +432,28 @@ function SubAgentSessionBody({ {agentType ? AGENT_LABELS[agentType] : t("unknownAgent")} + {/* Hand off to the child's own workspace tab, where it is a fully + interactive panel. Disabled until the persisted summary lands — + that is the only source of the child's folder id, and openTab keys + its dedupe on (folderId, conversationId, agentType). */} + +
+ {/* M1 capability boundary (design.md §M1 · R1-A7): the full tab sends + straight to the child's ACP connection, bypassing the delegation + broker — so the parent AI never learns about those turns. State it up + front instead of letting the user discover it. */} +
+ + {t("openInTabNotSyncedNotice")}
{childPendingPermission && (
@@ -420,6 +495,170 @@ function SubAgentSessionBody({ showMessageNav={false} />
+ + + ) +} + +/** + * User-side continuation input (Task 5.3 · Requirement 4.1/4.6). + * + * Unlike the full tab (whose sends bypass the broker — see the notice bar + * above), everything submitted here goes through `continueDelegation`, so the + * turn lands under the same `task_id` the parent AI tracks and shows up in + * its next `get_delegation_status`. + * + * Availability drives the input per design §D4's five tiers; the verdict is + * re-queried on mount, whenever a delegation event for THIS child arrives + * (`delegation_completed` / `delegation_session_update`), and after a failed + * send. The frontend never derives the tier itself (no parallel truth). + * + * Idempotency: one `continuation_id` is minted per submission and REUSED when + * retrying the same text (a broker replay then returns the first report + * instead of double-dispatching). Editing the text is a new submission and + * gets a fresh id — reusing the old one would be a `continuation_conflict`. + */ +function SubAgentContinuationComposer({ + childConversationId, +}: { + childConversationId: number +}) { + const t = useTranslations("Folder.chat.delegation") + const [availability, setAvailability] = + useState(null) + const [text, setText] = useState("") + const [sending, setSending] = useState(false) + const [errorCode, setErrorCode] = useState(null) + const pendingOpRef = useRef<{ id: string; message: string } | null>(null) + + // Latest-wins guard: a slow older query must not clobber a newer verdict. + const querySeqRef = useRef(0) + const refreshAvailability = useCallback(() => { + const seq = ++querySeqRef.current + getContinuationAvailability(childConversationId) + .then((verdict) => { + if (querySeqRef.current === seq) setAvailability(verdict) + }) + .catch(() => { + // Query failure: keep the last known verdict rather than flashing the + // input off; the next refresh trigger re-tries. + }) + }, [childConversationId]) + + useEffect(() => { + refreshAvailability() + }, [refreshAvailability]) + + useAcpEvent( + useCallback( + (envelope) => { + if ( + (envelope.type === "delegation_completed" || + envelope.type === "delegation_session_update") && + envelope.child_conversation_id === childConversationId + ) { + refreshAvailability() + } + }, + [childConversationId, refreshAvailability] + ) + ) + + const handleSend = useCallback(async () => { + const message = text.trim() + if (!message || sending) return + const pending = pendingOpRef.current + const continuationId = + pending && pending.message === message ? pending.id : crypto.randomUUID() + pendingOpRef.current = { id: continuationId, message } + setSending(true) + setErrorCode(null) + try { + const report = await continueDelegation( + childConversationId, + message, + continuationId + ) + if (report.error_code) { + // Refusal (session_still_running / released / not_continuable / …): + // surface the stable code, keep the draft + continuation id so a + // retry of the SAME submission stays idempotent, and re-sync the + // availability verdict. + setErrorCode(report.error_code) + refreshAvailability() + return + } + // Accepted — the turn is running under the parent's task id. + pendingOpRef.current = null + setText("") + setAvailability("running") + } catch { + // Transport-level failure (backend unreachable). Keep draft + id for an + // idempotent retry. + setErrorCode("transport_error") + refreshAvailability() + } finally { + setSending(false) + } + }, [text, sending, childConversationId, refreshAvailability]) + + const inputEnabled = + availability === "continuable_live" || availability === "continuable_resume" + const notice = + availability === "running" + ? t("continueUnavailableRunning") + : availability === "released" + ? t("continueUnavailableReleased") + : availability === "not_continuable" + ? t("continueUnavailableNotContinuable") + : availability === "continuable_resume" + ? t("continueResumeHint") + : null + + return ( +
+ {notice &&

{notice}

} + {errorCode && ( +

+ {t("continueRejected", { code: errorCode })} +

+ )} +
{ + e.preventDefault() + void handleSend() + }} + > +