From 6c1dd1ad3f92eb7ee7b49ded3e3930fb915b25a0 Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 21:57:03 +0800 Subject: [PATCH 1/2] feat(delegation): continue sub-sessions and hide them in sidebar by default Add continue_with_session/close_session MCP tools so parent agents can resume a failed or completed child without cold re-delegation. Keep completed/failed children alive for resume, re-spawn via external_id when needed, and fix the sub-agent dialog timeline to preserve multi-turn history during continue. Sidebar: filter out delegation children from root lists (parent_id/kind/delegation_call_id), add a funnel toggle for showing sub-session trees (default off), and label sub-session rows when expanded. --- src-tauri/src/acp/connection.rs | 3 + src-tauri/src/acp/delegation/broker.rs | 807 +++++++++++++++++- src-tauri/src/acp/delegation/companion.rs | 100 ++- src-tauri/src/acp/delegation/listener.rs | 81 ++ src-tauri/src/acp/delegation/mod.rs | 12 +- src-tauri/src/acp/delegation/spawner.rs | 117 ++- src-tauri/src/acp/delegation/tool_schema.json | 38 +- src-tauri/src/acp/delegation/transport.rs | 39 + src-tauri/src/acp/delegation/types.rs | 24 + src-tauri/src/acp/manager.rs | 92 +- .../src/db/service/conversation_service.rs | 9 +- .../sidebar-conversation-card.tsx | 59 +- .../sidebar-conversation-grouping.ts | 8 + .../sidebar-conversation-list.tsx | 68 +- src/components/layout/sidebar.tsx | 22 +- .../message/content-parts-renderer.tsx | 22 + .../message/delegation-status-card.tsx | 34 +- .../message/delegation-status-row.tsx | 27 +- .../conversation-runtime-context.test.tsx | 44 +- src/i18n/messages/ar.json | 20 +- src/i18n/messages/de.json | 20 +- src/i18n/messages/en.json | 16 +- 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 | 16 +- src/i18n/messages/zh-TW.json | 20 +- src/lib/adapters/tool-kind-classifier.test.ts | 7 +- src/lib/adapters/tool-kind-classifier.ts | 6 + src/lib/conversation-sidebar.test.ts | 54 ++ src/lib/conversation-sidebar.ts | 22 + src/lib/sidebar-view-mode-storage.ts | 26 + src/lib/tool-call-normalization.ts | 9 + src/stores/app-workspace-store.ts | 12 +- src/stores/conversation-runtime-store.ts | 65 +- 37 files changed, 1846 insertions(+), 153 deletions(-) create mode 100644 src/lib/conversation-sidebar.test.ts create mode 100644 src/lib/conversation-sidebar.ts diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 5a52fda29..b3d5751c4 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -5518,6 +5518,9 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static if text.starts_with("Delegation successful. task_id=") { return Some(crate::acp::delegation::DELEGATE_TOOL_REWRITE_TITLE); } + if text.starts_with("Continue successful. task_id=") { + return Some(crate::acp::delegation::CONTINUE_TOOL_REWRITE_TITLE); + } // Cheap guards before the full JSON parse: the status report is a JSON // object whose first key is `tasks`. if !text.starts_with('{') || !text.contains("\"tasks\"") { diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index bae005df1..539e2c861 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -34,7 +34,11 @@ //! [`DelegationBroker::cancel_by_parent_turn`]), or the LLM's own //! [`DelegationBroker::cancel_task_by_id`]. //! -//! v1 is explicitly one-shot — no session reuse. +//! After a child turn settles (completed / failed), the child process is kept +//! alive so [`DelegationBroker::continue_delegation`] can send follow-ups in the +//! same session. [`DelegationBroker::close_delegation_session`], cancel paths, +//! and parent teardown disconnect the child. If the process dies while the +//! conversation remains, continue re-spawns with the child's agent session id. //! //! Result durability: child output is NOT stored in codeg's DB, so the broker //! caches the completed text in `completed` (parent-scoped, FIFO-capped). Once @@ -118,6 +122,15 @@ 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 to adopt the row when + /// `continue_with_session` re-spawns a dead process. + pub folder_id: i32, + /// Agent-side session id (`conversation.external_id`) for `session/load`. + pub external_id: Option, + /// Original parent tool_use_id (for meta rewrites on continue). + pub parent_tool_use_id: Option, + /// Workspace path for resume spawn (folder path when available). + pub working_dir: Option, } /// DB fallback for `get_delegation_status` / `cancel_delegation` once a task's @@ -214,6 +227,9 @@ struct RunningTask { struct CompletedTask { parent_connection_id: String, child_conversation_id: i32, + /// Last known child ACP connection. `Some` while the process was kept + /// alive after completed/failed; `None` after cancel/close/disconnect. + child_connection_id: Option, agent_type: AgentType, status: TaskStatus, /// Result text for `Completed` (capped at [`COMPLETED_TEXT_CAP`]). `None` @@ -222,6 +238,21 @@ struct CompletedTask { error_code: Option, message: Option, duration_ms: u64, + /// Parent `delegate_to_agent` tool_use_id — used to re-write meta on + /// continue and to re-emit started events. + parent_tool_use_id: String, + /// Last task/follow-up preview shown on the parent card (kept for meta + /// continuity if a later path re-writes without a new preview). + #[allow(dead_code)] + task_preview: String, + /// Permanently retired via `close_session` (or parent connection teardown). + /// `continue_with_session` refuses closed tasks. + closed: bool, + /// Snapshotted at settle time so continue can re-spawn without a DB hit + /// when the process is already dead. May be incomplete in pure unit tests. + folder_id: Option, + external_id: Option, + working_dir: Option, } #[derive(Default)] @@ -622,23 +653,51 @@ fn terminal_fields( } /// Build a [`CompletedTask`] from a resolved outcome for the completed-cache. +#[allow(clippy::too_many_arguments)] fn build_completed( parent_connection_id: &str, child_conversation_id: i32, agent_type: AgentType, duration_ms: u64, outcome: &DelegationOutcome, + child_connection_id: Option, + parent_tool_use_id: String, + task_preview: String, + closed: bool, + folder_id: Option, + external_id: Option, + working_dir: Option, ) -> CompletedTask { let (status, text, error_code, message) = terminal_fields(outcome); + // Nudge the parent LLM toward continue_with_session on failed/completed. + let message = match (&status, message) { + (TaskStatus::Failed, Some(m)) => Some(format!( + "{m} Use continue_with_session(task_id, message) to resume this \ + sub-agent with its prior context instead of re-delegating." + )), + (TaskStatus::Completed, None) => Some( + "Completed. Use continue_with_session(task_id, message) for \ + follow-ups in the same sub-agent session, or close_session when done." + .to_string(), + ), + (_, other) => other, + }; CompletedTask { parent_connection_id: parent_connection_id.to_string(), child_conversation_id, + child_connection_id, agent_type, status, text, error_code, message, duration_ms, + parent_tool_use_id, + task_preview, + closed, + folder_id, + external_id, + working_dir, } } @@ -673,6 +732,8 @@ fn drain_and_record_canceled( let task = inner.running.remove(&k).expect("key just observed"); let outcome = canceled_outcome(task.child_conversation_id, reason); let duration_ms = task.started_at.elapsed().as_millis() as u64; + // Cancel tears the process down — clear connection id so continue must + // re-spawn (if allowed) rather than talking to a dead conn. inner.insert_completed( &k, build_completed( @@ -681,6 +742,13 @@ fn drain_and_record_canceled( task.agent_type, duration_ms, &outcome, + None, + task.parent_tool_use_id.clone(), + task.task_preview.clone(), + false, + None, + None, + None, ), ); out.push((task, duration_ms)); @@ -754,7 +822,9 @@ fn running_ack( let message = format!( "Delegation successful. task_id={call_id}. Call get_delegation_status \ with this id in the task_ids array (optionally wait_ms) to collect the \ - result, or cancel_delegation to stop it." + result, continue_with_session to send a follow-up in the same sub-agent \ + session after it finishes, cancel_delegation to stop it, or \ + close_session when you are done with the child." ); DelegationTaskReport { task_id: Some(call_id), @@ -2421,6 +2491,13 @@ impl DelegationBroker { // neither running nor completed. The `Running` arm inserts the live // task instead of parking a `oneshot` — the caller returns the ack. let record = |inner: &mut PendingInner, outcome: &DelegationOutcome| { + // Setup-window terminal: process is about to be torn down on + // cancel, or kept only briefly — keep_connection only for + // non-cancel outcomes (child finished during setup). + let keep = !matches!( + outcome, + DelegationOutcome::Err { code, .. } if code == "canceled" + ); inner.insert_completed( &call_id, build_completed( @@ -2429,6 +2506,13 @@ impl DelegationBroker { req.agent_type, setup_duration_ms, outcome, + keep.then(|| child_connection_id.clone()), + req.parent_tool_use_id.clone(), + task_preview.clone(), + false, + None, + None, + None, ), ); }; @@ -2584,6 +2668,13 @@ impl DelegationBroker { req.agent_type, duration_ms, &outcome, + None, + req.parent_tool_use_id.clone(), + task_preview.clone(), + false, + None, + None, + None, ), ); Some(duration_ms) @@ -2640,8 +2731,8 @@ impl DelegationBroker { /// (success path) or by error mappers (failure path). /// /// Migrates the task from `running` into `completed` (atomically, under one - /// lock) and then finalizes (terminal meta + `DelegationCompleted` event + - /// child teardown) and wakes any `get_delegation_status` long-poll. + /// lock) and then finalizes (terminal meta + `DelegationCompleted` event; + /// disconnect only on cancel) and wakes any `get_delegation_status` long-poll. /// /// If no entry is in `running` under `call_id`, the outcome is buffered for /// a racing `start_delegation` to drain at registration — but ONLY while the @@ -2653,6 +2744,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) { @@ -2660,6 +2755,8 @@ impl DelegationBroker { // Atomic running → completed so a concurrent status query // never sees the task as neither running nor completed. let duration_ms = task.started_at.elapsed().as_millis() as u64; + // Keep the process for continue_with_session unless canceled. + let keep_conn = (!is_canceled).then(|| task.child_connection_id.clone()); inner.insert_completed( call_id, build_completed( @@ -2668,6 +2765,13 @@ impl DelegationBroker { task.agent_type, duration_ms, &outcome, + keep_conn, + task.parent_tool_use_id.clone(), + task.task_preview.clone(), + false, + None, + None, + None, ), ); Some((task, duration_ms)) @@ -2694,18 +2798,39 @@ impl DelegationBroker { &task.task_id, ) .await; + // Best-effort: stamp resume metadata (folder / external_id / cwd) + // onto the completed-cache entry so continue_with_session does not + // depend solely on a later DB round-trip after process death. + self.enrich_completed_resume_meta(call_id).await; self.result_notify.notify_waiters(); } } - /// Write the terminal meta, emit `DelegationCompleted`, and tear down the - /// child for a resolved delegation. Shared by `complete_call` and - /// `start_delegation`'s early-terminal pickup. Mirrors the resolution onto - /// the parent's `delegate_to_agent` ToolCallState meta (including a bounded - /// `text_preview` on the completed path so a post-refresh snapshot renders - /// the result inline) so snapshot recovery shows the final state without the - /// live `delegation_completed` event. Does not touch the pending maps — the - /// caller owns the `running` → `completed` migration. + /// Fill `folder_id` / `external_id` / `working_dir` on a just-settled task + /// from the DB (or leave untouched when the lookup fails). + async fn enrich_completed_resume_meta(&self, call_id: &str) { + let Some(rec) = self.status_lookup.find_by_call_id(call_id).await else { + return; + }; + let mut inner = self.pending.inner.lock().await; + let Some(c) = inner.completed.get_mut(call_id) else { + return; + }; + if c.folder_id.is_none() { + c.folder_id = Some(rec.folder_id); + } + if c.external_id.is_none() { + c.external_id = rec.external_id; + } + if c.working_dir.is_none() { + c.working_dir = rec.working_dir; + } + } + + /// Write the terminal meta and emit `DelegationCompleted`. Shared by + /// `complete_call` and `start_delegation`'s early-terminal pickup. + /// Disconnects the child **only** when the outcome is canceled; completed / + /// failed keep the process for `continue_with_session`. /// /// `duration_ms` is the broker-measured elapsed time (from `started_at`), /// carried onto the event summary so the parent UI shows a real duration. @@ -2755,8 +2880,15 @@ impl DelegationBroker { outcome_to_summary(outcome, duration_ms), ) .await; - // v1 one-shot: always tear down the child. - let _ = self.spawner.disconnect(child_connection_id).await; + // Keep completed/failed children alive for continue_with_session. + // Cancel still tears down (user / parent no longer wants the child). + 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 @@ -2931,12 +3063,41 @@ impl DelegationBroker { /// `consumed`) since the connection is going away. Runs fully inline — the /// connection is already exiting, so there is no next prompt to unblock. pub async fn cancel_by_parent(&self, parent_connection_id: &str) { + // Disconnect any kept-alive settled children before dropping the cache. + let settled_conns = self + .take_settled_child_connections(parent_connection_id) + .await; + for cid in settled_conns { + let _ = self.spawner.disconnect(&cid).await; + } let drained = self .drain_for_parent_cancel(parent_connection_id, false) .await; self.finalize_parent_cancel(drained).await; } + /// Collect and clear `child_connection_id`s for this parent's settled + /// (completed/failed) tasks so parent teardown can free kept-alive children. + async fn take_settled_child_connections(&self, parent_connection_id: &str) -> Vec { + let mut inner = self.pending.inner.lock().await; + let mut out = Vec::new(); + let ids: Vec = inner + .completed + .iter() + .filter(|(_, c)| c.parent_connection_id == parent_connection_id) + .map(|(k, _)| k.clone()) + .collect(); + for id in ids { + if let Some(c) = inner.completed.get_mut(&id) { + if let Some(cid) = c.child_connection_id.take() { + c.closed = true; + out.push(cid); + } + } + } + out + } + /// Cascade-cancel every pending delegation owned by `parent_connection_id` /// for a **turn/prompt cancel** where the parent connection STAYS ALIVE /// (a non-`end_turn` turn end, or a user Cancel between/within prompts). @@ -2956,6 +3117,9 @@ impl DelegationBroker { /// mis-bind the next same-key delegation on this live connection — see /// `drop_tool_calls_for_parent`. pub async fn cancel_by_parent_turn(&self, parent_connection_id: &str) { + // Only running children are canceled. Settled (completed/failed) + // children stay alive so the parent can still `continue_with_session` + // on them after the turn ends. let drained = self .drain_for_parent_cancel(parent_connection_id, true) .await; @@ -3317,6 +3481,382 @@ impl DelegationBroker { } } + /// Backs `continue_with_session`: send a follow-up into an existing child + /// session (same `task_id`) so the sub-agent keeps prior context. + /// + /// Prefers a still-live child connection; otherwise re-spawns with the + /// agent's `external_id` and adopts the existing conversation row. Same + /// `task_id` is re-registered as `Running` and collected via + /// `get_delegation_status`. + pub async fn continue_delegation( + &self, + parent_connection_id: &str, + parent_conversation_id: Option, + task_id: &str, + message: String, + ) -> DelegationTaskReport { + let message = message.trim().to_string(); + if message.is_empty() { + return report_err( + AgentType::ClaudeCode, + DelegationError::NotContinuable("message is empty".into()), + None, + ) + .with_task_id(task_id); + } + + // Snapshot settled state under the lock, then do I/O outside. + let settled = { + let mut inner = self.pending.inner.lock().await; + if let Some(r) = inner.running.get(task_id) { + if r.parent_connection_id != parent_connection_id { + return unknown_report(task_id); + } + return report_err( + r.agent_type, + DelegationError::SessionStillRunning, + Some(r.child_conversation_id), + ) + .with_task_id(task_id); + } + match inner.completed.get(task_id) { + Some(c) if c.parent_connection_id == parent_connection_id => { + if c.closed { + return report_err( + c.agent_type, + DelegationError::SessionClosed, + Some(c.child_conversation_id), + ) + .with_task_id(task_id); + } + // Remove so a concurrent continue cannot double-start; we + // re-insert as running after the follow-up is sent (or put + // back on failure). + inner.completed.remove(task_id) + } + Some(_) => return unknown_report(task_id), + None => None, + } + }; + + // DB fallback when the completed-cache entry was evicted. + let mut settled = match settled { + Some(c) => c, + None => match self + .load_settled_from_db(parent_connection_id, parent_conversation_id, task_id) + .await + { + Ok(c) => c, + Err(report) => return report, + }, + }; + + // Resolve a live child connection, or resume the agent session. + let child_connection_id = match settled.child_connection_id.as_deref() { + Some(cid) if self.spawner.is_alive(cid).await => cid.to_string(), + _ => { + let (folder_id, external_id, working_dir) = self + .resolve_resume_meta(parent_conversation_id, task_id, &settled) + .await; + let Some(folder_id) = folder_id.or(settled.folder_id) else { + let agent_type = settled.agent_type; + let child_conversation_id = settled.child_conversation_id; + self.reinsert_completed(task_id, settled).await; + return report_err( + agent_type, + DelegationError::NotContinuable( + "missing folder_id for child conversation".into(), + ), + Some(child_conversation_id), + ) + .with_task_id(task_id); + }; + settled.folder_id = Some(folder_id); + let external_id = external_id.or(settled.external_id.clone()); + settled.external_id = external_id.clone(); + let working_dir = working_dir.or(settled.working_dir.clone()); + settled.working_dir = working_dir.clone(); + + let cfg = self.config_snapshot().await; + let (preferred_mode_id, preferred_config_values) = cfg + .agent_defaults + .get(&settled.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, + settled.agent_type, + working_dir, + external_id, + preferred_mode_id, + preferred_config_values, + ) + .await + { + Ok(id) => id, + Err(e) => { + let agent_type = settled.agent_type; + let child_conversation_id = settled.child_conversation_id; + self.reinsert_completed(task_id, settled).await; + return report_err( + agent_type, + DelegationError::SpawnFailed(e.to_string()), + Some(child_conversation_id), + ) + .with_task_id(task_id); + } + } + } + }; + + let folder_id = match settled.folder_id { + Some(id) => id, + None => { + let (folder_id, _, _) = self + .resolve_resume_meta(parent_conversation_id, task_id, &settled) + .await; + match folder_id { + Some(id) => { + settled.folder_id = Some(id); + id + } + None => { + let agent_type = settled.agent_type; + let child_conversation_id = settled.child_conversation_id; + self.reinsert_completed(task_id, settled).await; + return report_err( + agent_type, + DelegationError::NotContinuable( + "missing folder_id for child conversation".into(), + ), + Some(child_conversation_id), + ) + .with_task_id(task_id); + } + } + } + }; + + if let Err(e) = self + .spawner + .send_followup_prompt( + &child_connection_id, + message.clone(), + settled.child_conversation_id, + folder_id, + ) + .await + { + let agent_type = settled.agent_type; + let child_conversation_id = settled.child_conversation_id; + self.reinsert_completed(task_id, settled).await; + return report_err( + agent_type, + DelegationError::SubagentRuntimeError(e.to_string()), + Some(child_conversation_id), + ) + .with_task_id(task_id); + } + + // Register as running under the SAME task_id so status/cancel keep working. + let task_preview = truncate_on_char_boundary(&message, TASK_PREVIEW_CAP); + let running = RunningTask { + child_connection_id: child_connection_id.clone(), + child_conversation_id: settled.child_conversation_id, + parent_connection_id: parent_connection_id.to_string(), + parent_tool_use_id: settled.parent_tool_use_id.clone(), + agent_type: settled.agent_type, + task_preview: task_preview.clone(), + task_id: task_id.to_string(), + external_handle: None, + started_at: Instant::now(), + }; + + { + let mut inner = self.pending.inner.lock().await; + // Drop any stale completed entry (we already removed it) and park. + inner.running.insert(task_id.to_string(), running); + } + + // Meta + started event so the original parent card reflects "running" again. + self.write_meta_if_real( + parent_connection_id, + &settled.parent_tool_use_id, + build_delegation_meta( + "running", + Some(&child_connection_id), + Some(settled.child_conversation_id), + None, + None, + None, + Some(&task_preview), + Some(task_id), + ), + ) + .await; + self.emit_started_if_real( + parent_connection_id, + &settled.parent_tool_use_id, + &child_connection_id, + settled.child_conversation_id, + settled.agent_type, + &task_preview, + task_id, + ) + .await; + + let mut ack = running_ack( + task_id.to_string(), + settled.child_conversation_id, + settled.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." + )); + ack + } + + /// Backs `close_session`: permanently retire a delegated child. + /// Cancels if still running, disconnects any kept-alive process, and marks + /// the completed entry `closed` so `continue_with_session` refuses it. + pub async fn close_delegation_session( + &self, + parent_connection_id: &str, + parent_conversation_id: Option, + task_id: &str, + ) -> DelegationTaskReport { + // Running → cancel + disconnect first (reuse cancel_task_by_id path). + { + let inner = self.pending.inner.lock().await; + if let Some(r) = inner.running.get(task_id) { + if r.parent_connection_id != parent_connection_id { + return unknown_report(task_id); + } + drop(inner); + let _ = self + .cancel_task_by_id(parent_connection_id, parent_conversation_id, task_id) + .await; + } + } + + // Mark completed closed + disconnect any lingering connection. + let (report, conn_to_drop) = { + let mut inner = self.pending.inner.lock().await; + match inner.completed.get_mut(task_id) { + Some(c) if c.parent_connection_id == parent_connection_id => { + c.closed = true; + let conn = c.child_connection_id.take(); + c.message = Some( + "Session closed. Start a new delegate_to_agent for further work." + .to_string(), + ); + (completed_report(task_id, c), conn) + } + Some(_) => return unknown_report(task_id), + None => { + drop(inner); + // Not in memory — try DB and still return status. + return self.status_from_db(parent_conversation_id, task_id).await; + } + } + }; + if let Some(cid) = conn_to_drop { + let _ = self.spawner.disconnect(&cid).await; + } + report + } + + /// Put a settled entry back after a failed continue attempt. + async fn reinsert_completed(&self, task_id: &str, settled: CompletedTask) { + let mut inner = self.pending.inner.lock().await; + inner.insert_completed(task_id, settled); + } + + /// Build a [`CompletedTask`] from the DB when the in-memory cache was + /// evicted. Errors map to ready-to-return task reports. + async fn load_settled_from_db( + &self, + parent_connection_id: &str, + parent_conversation_id: Option, + task_id: &str, + ) -> Result { + let Some(rec) = self.status_lookup.find_by_call_id(task_id).await else { + return Err(unknown_report(task_id)); + }; + if parent_conversation_id.is_none() || rec.parent_id != parent_conversation_id { + return Err(unknown_report(task_id)); + } + if matches!(rec.status, TaskStatus::Running) { + return Err(report_err( + rec.agent_type, + DelegationError::SessionStillRunning, + Some(rec.child_conversation_id), + ) + .with_task_id(task_id)); + } + // Synthetic completed shell for continue; parent_connection_id is the + // live parent (cache was parent-scoped; DB only has conversation ids). + Ok(CompletedTask { + parent_connection_id: parent_connection_id.to_string(), + child_conversation_id: rec.child_conversation_id, + child_connection_id: None, + agent_type: rec.agent_type, + status: rec.status, + text: None, + error_code: None, + message: None, + duration_ms: 0, + parent_tool_use_id: rec + .parent_tool_use_id + .clone() + .unwrap_or_else(|| format!("delegation-{task_id}")), + task_preview: String::new(), + closed: false, + folder_id: Some(rec.folder_id), + external_id: rec.external_id.clone(), + working_dir: rec.working_dir.clone(), + }) + } + + /// Fill resume metadata from DB when the settled cache entry is incomplete. + async fn resolve_resume_meta( + &self, + parent_conversation_id: Option, + task_id: &str, + settled: &CompletedTask, + ) -> (Option, Option, Option) { + if settled.folder_id.is_some() + && (settled.external_id.is_some() || settled.working_dir.is_some()) + { + return ( + settled.folder_id, + settled.external_id.clone(), + settled.working_dir.clone(), + ); + } + 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 => + { + ( + Some(rec.folder_id), + rec.external_id, + rec.working_dir, + ) + } + _ => ( + settled.folder_id, + settled.external_id.clone(), + settled.working_dir.clone(), + ), + } + } + /// 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`. @@ -3483,11 +4023,24 @@ impl ChildStatusLookup for DbChildStatusLookup { "cancelled" => TaskStatus::Canceled, _ => TaskStatus::Unknown, }; + // Resolve workspace path for resume spawn (folder path). + 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, + parent_tool_use_id: summary.parent_tool_use_id, + working_dir, }) } } @@ -3637,8 +4190,219 @@ mod tests { other => panic!("expected Ok, got {other:?}"), } assert_eq!(broker.pending_count().await, 0); - // complete_call disconnects the child once. + // completed/failed keep the child process for continue_with_session. + assert!( + mock.disconnects.lock().await.is_empty(), + "complete_call must not disconnect on success" + ); + } + + /// After a completed turn, continue_with_session reuses the same child + /// connection and re-registers the same task_id as Running. + #[tokio::test] + async fn continue_reuses_live_child_connection() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + mock.queue_followup(Ok(())).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 }) + }; + 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: "first turn".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 10, + token_usage: None, + }), + ) + .await; + let _ = driver.await.unwrap(); + + // Enrich settled entry with folder_id so continue doesn't need DB. + { + let mut inner = broker.pending.inner.lock().await; + if let Some(c) = inner.completed.get_mut(&call_id) { + c.folder_id = Some(7); + c.child_connection_id = Some("child-conn-1".into()); + } + } + + let cont = broker + .continue_delegation("parent-conn", Some(1), &call_id, "do the next step".into()) + .await; + assert_eq!(cont.status, TaskStatus::Running); + assert_eq!(cont.task_id.as_deref(), Some(call_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, "child-conn-1"); + assert_eq!(followups[0].message, "do the next step"); + assert_eq!(followups[0].conversation_id, 42); + assert_eq!(followups[0].folder_id, 7); + drop(followups); + + // Still running under the same id. + assert_eq!(broker.pending_count().await, 1); + + // Second complete settles again without disconnect. + broker + .complete_call( + &call_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; + assert!(mock.disconnects.lock().await.is_empty()); + + let status = broker + .get_task_status( + "parent-conn", + Some(1), + &call_id, + StatusWait::Immediate, + ) + .await; + assert_eq!(status.status, TaskStatus::Completed); + assert_eq!(status.text.as_deref(), Some("second turn")); + } + + /// close_session disconnects a kept-alive child and blocks further continue. + #[tokio::test] + async fn close_session_blocks_continue() { + 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 }) + }; + 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: "done".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 10, + token_usage: None, + }), + ) + .await; + let _ = driver.await.unwrap(); + + { + let mut inner = broker.pending.inner.lock().await; + if let Some(c) = inner.completed.get_mut(&call_id) { + c.folder_id = Some(7); + c.child_connection_id = Some("child-conn-1".into()); + } + } + + let closed = broker + .close_delegation_session("parent-conn", Some(1), &call_id) + .await; + assert_eq!(closed.status, TaskStatus::Completed); assert_eq!(mock.disconnects.lock().await.as_slice(), &["child-conn-1"]); + + let cont = broker + .continue_delegation("parent-conn", Some(1), &call_id, "more".into()) + .await; + assert_eq!(cont.status, TaskStatus::Failed); + assert_eq!(cont.error_code.as_deref(), Some("session_closed")); + } + + /// Dead connection path: continue re-spawns via spawn_for_resume. + #[tokio::test] + async fn continue_respawns_when_child_dead() { + let mock = Arc::new(MockSpawner::new()); + mock.queue_spawn(Ok("child-conn-1".into())).await; + mock.queue_send(Ok(42)).await; + mock.queue_spawn(Ok("child-conn-2".into())).await; // resume spawn + mock.queue_followup(Ok(())).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 }) + }; + 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: "first".into(), + child_conversation_id: 42, + child_agent_type: AgentType::Codex, + turn_count: 1, + duration_ms: 10, + token_usage: None, + }), + ) + .await; + let _ = driver.await.unwrap(); + + mock.mark_dead("child-conn-1").await; + { + let mut inner = broker.pending.inner.lock().await; + if let Some(c) = inner.completed.get_mut(&call_id) { + c.folder_id = Some(7); + c.external_id = Some("sess-xyz".into()); + c.working_dir = Some("/work".into()); + c.child_connection_id = Some("child-conn-1".into()); + } + } + + let cont = broker + .continue_delegation("parent-conn", Some(1), &call_id, "resume please".into()) + .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-xyz")); + drop(resumes); + let followups = mock.followups.lock().await; + assert_eq!(followups[0].conn_id, "child-conn-2"); } /// `StatusWait::Infinite` (the explicit `wait_ms = 0` escape hatch) must @@ -6262,7 +7026,11 @@ 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"]); + // Ok early-complete keeps the child for continue_with_session. + 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; @@ -7910,12 +8678,19 @@ mod tests { CompletedTask { parent_connection_id: parent.to_string(), child_conversation_id: 1, + child_connection_id: None, agent_type: AgentType::ClaudeCode, status: TaskStatus::Completed, text: Some("x".repeat(text_len)), error_code: None, message: None, duration_ms: 0, + parent_tool_use_id: "tool-1".into(), + task_preview: String::new(), + closed: false, + folder_id: Some(1), + external_id: None, + working_dir: None, } } diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 561fc9397..4c8ce4576 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -5,16 +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), +//! up 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 -//! delivery commit, so a cancelled note stays pending. +//! `continue_with_session` (follow-up in the same child session), `close_session` +//! (permanently retire a child), `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 //! that `notifications/initialized` etc. are fire-and-forget. @@ -42,11 +44,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 +183,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 +487,62 @@ 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", + )); + } + }; + 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, + }; + 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 req = BrokerCloseSessionRequest { + token: ctx.token.clone(), + task_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(), @@ -1202,16 +1266,18 @@ 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")); // delegate_to_agent schema still enumerates all 12 agent types. let delegate = tools .iter() @@ -1694,7 +1760,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 +1769,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] diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 439298018..6295fe6ec 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -212,6 +212,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 @@ -419,6 +423,83 @@ impl DelegationListener { .await } + /// Validate the token and continue a settled child session. Backs + /// `continue_with_session`. + 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). + 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()), + ); + 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, + ) + .await + } + + /// Validate the token and permanently close a child session. 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()), + ); + 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, + ) + .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. diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index 43b25450d..3f2601565 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -25,10 +25,12 @@ //! 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 +//! teardown) permanently retires the child. 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 +57,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..a2eabd7ac 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -51,7 +51,7 @@ pub enum SpawnerError { #[async_trait] pub trait ConnectionSpawner: Send + Sync { /// Spawn a fresh child ACP connection of `agent_type` in `working_dir`. - /// Delegation children are always brand-new sessions (no resume), but the + /// First-turn delegations pass no agent session id (cold start). The /// broker may inject per-agent defaults configured in /// `DelegationConfig::agent_defaults`: /// * `preferred_mode_id` — applied via `session/set_mode` @@ -78,6 +78,21 @@ pub trait ConnectionSpawner: Send + Sync { preferred_config_values: BTreeMap, ) -> Result; + /// Spawn (or reuse) a connection for a *resumed* child session. + /// `session_id` is the agent-side id (`conversation.external_id`); when + /// `Some`, `ConnectionManager::spawn_agent` reuses a live process for that + /// session or reloads it via `session/load`. When `None`, behaves like a + /// cold `spawn` (last-resort continue when no external id was recorded). + 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 the delegation task as the child's first prompt. The /// `DelegationLink` is persisted onto the new conversation row so the /// lifecycle subscriber can later notify the broker on `TurnComplete`. @@ -90,13 +105,27 @@ pub trait ConnectionSpawner: Send + Sync { link: DelegationLink, ) -> Result; + /// Send a follow-up prompt on an already-created child conversation + /// (`continue_with_session`). Adopts `conversation_id` when the connection + /// is not yet linked (post-resume), otherwise reuses the live link. + async fn send_followup_prompt( + &self, + conn_id: &str, + message: String, + conversation_id: i32, + folder_id: i32, + ) -> Result<(), SpawnerError>; + + /// Whether `conn_id` still refers to a live (non-terminal) ACP connection. + async fn is_alive(&self, conn_id: &str) -> bool; + /// Cancel any in-flight prompt on the child connection. Idempotent: /// calling on a connection with nothing in flight is a no-op success. async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError>; - /// Tear down the child connection. Always called after the broker has - /// resolved (or failed) the pending call, to enforce v1's one-shot - /// semantics. + /// Tear down the child connection. Used by cancel / close_session / parent + /// teardown — not by a normal completed/failed turn (those keep the + /// process for `continue_with_session`). async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError>; } @@ -117,9 +146,14 @@ pub mod mock { pub struct MockSpawner { pub spawn_results: Mutex>>, pub send_results: Mutex>>, + pub followup_results: Mutex>>, pub cancels: Mutex>, pub disconnects: Mutex>, pub spawn_args: Mutex>, + pub resume_args: Mutex>, + pub followups: Mutex>, + /// Connection ids reported as dead by [`ConnectionSpawner::is_alive`]. + pub dead_connections: 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 +177,24 @@ pub mod mock { pub preferred_config_values: BTreeMap, } + #[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, + } + + #[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 +208,14 @@ pub mod mock { self.send_results.lock().await.push_back(r); } + pub async fn queue_followup(&self, r: Result<(), SpawnerError>) { + self.followup_results.lock().await.push_back(r); + } + + pub async fn mark_dead(&self, conn_id: impl Into) { + self.dead_connections.lock().await.insert(conn_id.into()); + } + /// 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 @@ -208,6 +268,30 @@ pub mod mock { .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued spawn result".into()))) } + 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, + }); + self.spawn_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| Err(SpawnerError::Spawn("no queued spawn result".into()))) + } + async fn send_prompt_linked_for_delegation( &self, _conn_id: &str, @@ -228,6 +312,30 @@ pub mod mock { .unwrap_or_else(|| Err(SpawnerError::Send("no queued send 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(Ok(())) + } + + async fn is_alive(&self, conn_id: &str) -> bool { + !self.dead_connections.lock().await.contains(conn_id) + } + async fn cancel(&self, conn_id: &str) -> Result<(), SpawnerError> { self.cancels.lock().await.push(conn_id.to_string()); Ok(()) @@ -235,6 +343,7 @@ pub mod mock { async fn disconnect(&self, conn_id: &str) -> Result<(), SpawnerError> { self.disconnects.lock().await.push(conn_id.to_string()); + self.dead_connections.lock().await.insert(conn_id.to_string()); Ok(()) } } diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 1bfe5485c..5d9e12b55 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) 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) 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) 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,39 @@ }, { "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); prefer close_session only when you want to permanently retire the child.", + "inputSchema": { + "type": "object", + "required": ["task_id"], + "properties": { + "task_id": { + "type": "string", + "description": "The task_id returned by delegate_to_agent." + } + } + } + }, + { + "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 permanently closed 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"], + "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)." + } + } + } + }, + { + "name": "close_session", + "description": "Permanently retire a delegated sub-agent session by task_id: cancel if still running, disconnect the child process, and mark the task closed so continue_with_session will refuse it. Use when you no longer need that child (fan-out cleanup, abandoned branch). Prefer leave sessions open if you may continue them. If already finished and closed, returns the last known status.", "inputSchema": { "type": "object", "required": ["task_id"], diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 41b81728a..712a568d2 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -183,6 +183,27 @@ 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). +#[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, +} + +/// Permanently close a delegated child session. Backs `close_session`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerCloseSessionRequest { + pub token: String, + pub task_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 +214,8 @@ pub enum BrokerMessage { Cancel(BrokerCancelRequest), Status(BrokerStatusRequest), CancelTask(BrokerCancelTaskRequest), + Continue(BrokerContinueRequest), + CloseSession(BrokerCloseSessionRequest), Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), @@ -302,6 +325,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). diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index 416351fa8..6f8f4c6b5 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -125,6 +125,16 @@ pub enum DelegationError { Canceled { reason: String }, #[error("parent session is gone")] ParentSessionGone, + /// `continue_with_session` was called while the task is still running. + #[error("subagent session is still running; wait or cancel first")] + SessionStillRunning, + /// `close_session` already tore the session down (or parent teardown did). + #[error("subagent session was closed; start a new delegation")] + SessionClosed, + /// Task exists but cannot be continued (unknown, no child row, no + /// resumable agent session id after process death, etc.). + #[error("subagent session cannot be continued: {0}")] + NotContinuable(String), } /// The single value the broker hands back to the listener / MCP companion. @@ -204,6 +214,17 @@ 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). + 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 +243,9 @@ impl DelegationOutcome { DelegationError::ChildUnknown(_) => "child_unknown", DelegationError::Canceled { .. } => "canceled", DelegationError::ParentSessionGone => "canceled", + DelegationError::SessionStillRunning => "session_still_running", + DelegationError::SessionClosed => "session_closed", + DelegationError::NotContinuable(_) => "not_continuable", }; DelegationOutcome::Err { code: code.to_string(), diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 86e866cd9..9c5fb282a 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -2321,13 +2321,16 @@ 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 spawn path for first-turn cold starts and continue-time resume. + /// `session_id` is the agent-side id (`conversation.external_id`); `None` + /// always creates a brand-new agent session. + 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 { @@ -2374,7 +2377,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa .spawn_agent( agent_type, effective_working_dir, - None, + session_id, runtime_env, owner_window, emitter, @@ -2384,6 +2387,48 @@ 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 { + 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, @@ -2435,6 +2480,45 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa }) } + 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; + // No delegation link: the child row already has parent_id / + // delegation_call_id from the first turn. Pass conversation_id so a + // freshly resumed connection adopts the existing row (Branch A). + 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 { + use crate::acp::types::ConnectionStatus; + let Some(state) = self.manager.get_state(conn_id).await else { + return false; + }; + let s = state.read().await; + // Match discovery's "live" contract: Disconnected / Error are not + // attachable and must not receive follow-ups. + !matches!( + s.status, + ConnectionStatus::Disconnected | ConnectionStatus::Error + ) + } + async fn cancel( &self, conn_id: &str, diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index e77f7a75d..44c1ba2d3 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -469,7 +469,14 @@ 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 parent_id, never kind=delegate, and no broker task id. + // Invariant is parent_id set ⟺ delegate ⟺ delegation_call_id set; + // all three filters keep a half-migrated / partially-backfilled row out + // of the workspace list (children only surface via list_children). + query = query + .filter(conversation::Column::ParentId.is_null()) + .filter(conversation::Column::Kind.ne(ConversationKind::Delegate)) + .filter(conversation::Column::DelegationCallId.is_null()); } match folder_ids { diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 60891d511..daece6fd5 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" @@ -117,6 +118,12 @@ interface SidebarConversationCardProps { /** True when `child_count > 0`: the conversation has delegation children, so * the expand chevron is shown. */ hasChildren?: boolean + /** + * When sub-session trees are hidden via the funnel filter, show this count + * on a parent root so nested work is still discoverable. Omit when nested + * rows are visible (chevron handles that). + */ + 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 +144,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ onTogglePin, depth = 0, hasChildren = false, + childCountHint, expanded = false, onToggleExpand, }: SidebarConversationCardProps) { @@ -207,7 +215,7 @@ 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 + const isSubsession = conversation.parent_id != null || depth > 0 return ( <> @@ -216,6 +224,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
{/* Expand/collapse affordance for delegation children. It overlays diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 58b1770de..bafe9d25e 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -226,6 +226,14 @@ export function selectPinnedWithReuse( ): DbConversationSummary[] { const next: DbConversationSummary[] = [] for (const conv of conversations) { + // Pinned section is still root-only — never pin-surface a delegation child. + if ( + conv.parent_id != null || + conv.kind === "delegate" || + (conv.delegation_call_id != null && conv.delegation_call_id !== "") + ) { + continue + } if (conv.pinned_at != null) next.push(conv) } next.sort(compareByPinnedAtDesc) diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 6a2200fb7..85d1ca63d 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" @@ -677,6 +678,12 @@ export interface SidebarConversationListHandle { export interface SidebarConversationListProps { showCompleted?: boolean + /** + * When false (default from the funnel menu), hide delegation sub-session + * trees: no expand chevrons, no nested rows. Parent rows can still show a + * child-count hint so users know work is nested under them. + */ + showSubsessions?: boolean sortMode?: SidebarSortMode sectionOrder?: SidebarSectionOrder /** When on, each repo's worktree child folders render as indented sub-groups @@ -687,6 +694,7 @@ export interface SidebarConversationListProps { export function SidebarConversationList({ ref, showCompleted = true, + showSubsessions = false, sortMode = "created", sectionOrder = "folders-first", showWorktrees = false, @@ -995,9 +1003,12 @@ 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. + // Also drop any leaked delegation children (parent_id / kind=delegate) so + // sub-sessions never render as peer roots under a folder. 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 +1160,26 @@ export function SidebarConversationList({ const darkMode = resolvedTheme === "dark" + // When sub-sessions are hidden (view option off), force an empty expansion set + // AND empty children cache so buildRows cannot emit nested rows even if a + // prior expand left children loaded. Persisted expanded ids stay intact for + // when the user turns the filter back on. + const effectiveConversationExpanded = useMemo( + () => (showSubsessions ? conversationExpanded : new Set()), + [showSubsessions, conversationExpanded] + ) + const effectiveChildrenByParent = useMemo( + () => + showSubsessions + ? childrenByParent + : new Map(), + [showSubsessions, childrenByParent] + ) + const effectiveChildrenLoading = useMemo( + () => (showSubsessions ? childrenLoading : new Set()), + [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 +1200,9 @@ export function SidebarConversationList({ chatConversations, chatsExpanded, sectionOrder, - conversationExpanded, - childrenByParent, - childrenLoading, + conversationExpanded: effectiveConversationExpanded, + childrenByParent: effectiveChildrenByParent, + childrenLoading: effectiveChildrenLoading, containerChildren, rootGroupCollapsed, }), @@ -1186,9 +1217,9 @@ export function SidebarConversationList({ chatConversations, chatsExpanded, sectionOrder, - conversationExpanded, - childrenByParent, - childrenLoading, + effectiveConversationExpanded, + effectiveChildrenByParent, + effectiveChildrenLoading, containerChildren, rootGroupCollapsed, ] @@ -1492,7 +1523,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 +1536,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 +2328,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} + // When nested rows are hidden, still surface the child count so users + // know a parent owns sub-sessions (enable "Show sub-sessions" to open). + 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 52aa8edff..658c8055f 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,11 +134,12 @@ export function Sidebar() { // rem-sized overlay buttons. Mobile has no overlay (the sidebar is a Sheet). const leftReserve = leftChromeReserve(platformIsMac && isDesktop(), zoomLevel) - // Both default ON (the mount effect below reconciles a persisted "false"). - // The initial value matches that default so the pre-hydration render doesn't - // flash from off → on. + // showCompleted / showWorktrees default ON (mount effect reconciles a + // persisted "false"). showSubsessions defaults OFF — delegation children + // only expand when the user opts in via the funnel menu. const [showCompleted, setShowCompleted] = useState(true) const [showWorktrees, setShowWorktrees] = useState(true) + const [showSubsessions, setShowSubsessions] = useState(false) const [sortMode, setSortMode] = useState("created") const [sectionOrder, setSectionOrder] = useState("folders-first") @@ -162,6 +165,7 @@ export function Sidebar() { // eslint-disable-next-line react-hooks/set-state-in-effect setShowCompleted(loadShowCompleted()) setShowWorktrees(loadShowWorktrees()) + setShowSubsessions(loadShowSubsessions()) setSortMode(loadSortMode()) setSectionOrder(loadSectionOrder()) }, []) @@ -176,6 +180,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) @@ -342,6 +351,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 941d5edc3..5dceb1299 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2382,6 +2382,28 @@ const ToolCallPart = memo(function ToolCallPart({ /> ) } + 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.tsx b/src/components/message/delegation-status-card.tsx index 9894ed3c9..feb829a30 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/continue/close: + * `{ task_id }` (+ `message` for continue). */ input?: string | null output?: string | null errorText?: string | null @@ -69,18 +70,21 @@ 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 — keep 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] + // continue uses status-like badge semantics; close/cancel treat terminal + // cancel/closed outcomes as success for the tool intent. + const badgeKind = kind === "continue" ? "status" : "cancel" + const singleBadge = useMemo( + () => deriveBadge(badgeKind, singleReport, state, !!errorText), + [badgeKind, singleReport, state, errorText] ) if (kind === "status") { @@ -92,7 +96,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..317fc2765 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, + Play, + XCircle, } 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" + ? Play + : kind === "close" + ? XCircle + : Activity const row = ( <> diff --git a/src/contexts/conversation-runtime-context.test.tsx b/src/contexts/conversation-runtime-context.test.tsx index 2b273feb0..342d7cbc6 100644 --- a/src/contexts/conversation-runtime-context.test.tsx +++ b/src/contexts/conversation-runtime-context.test.tsx @@ -641,8 +641,9 @@ describe("ConversationRuntimeProvider removeOptimisticTurn (bounce rollback)", ( /** * Delegation-child viewer projection in `getTimelineTurns`. When the sub-agent * dialog marks a session `liveOwnsActiveTurn` and supplies the kickoff task: - * - the persisted copy of the reply is stripped while a live/local reply - * owns the turn (no partial-plus-stream duplicate), and + * - only the CURRENT reply's persisted partial is stripped while live/local + * owns the turn (prior multi-turn history from continue_with_session stays; + * no partial-plus-stream duplicate), and * - the kickoff USER turn is synthesized from the known task text while the * async JSONL transcript still lags — then automatically replaced by the * real persisted user turn once it lands (no duplicate, no cleanup). @@ -830,6 +831,45 @@ describe("ConversationRuntimeProvider delegation kickoff projection", () => { expect(timeline.some((t) => t.key === "kickoff-99")).toBe(false) expect(timeline.some((t) => t.turn.role === "assistant")).toBe(true) }) + + it("keeps prior history during continue_with_session (multi-turn live)", async () => { + // First-turn history + continue user prompt already persisted; live owns + // the follow-up reply. Prior assistant turns must remain visible. + mockGetFolderConversation.mockResolvedValueOnce( + detailWithTurns([ + userTurn("u1"), + assistantTurn("a1"), + assistantTurn("a2"), + userTurn("u2"), + ]) + ) + renderProvider() + const api = () => runtimeHolder.current! + + act(() => { + api().setLiveOwnsActiveTurn(99, true, "original kickoff") + }) + act(() => { + api().setLiveMessage(99, LIVE_MSG, true) + }) + await act(async () => { + api().refetchDetail(99, { preserveLive: true }) + await Promise.resolve() + }) + + const timeline = api().getTimelineTurns(99) + const users = timeline.filter((t) => t.turn.role === "user") + const persistedAssistants = timeline.filter( + (t) => t.phase === "persisted" && t.turn.role === "assistant" + ) + expect(users.map((t) => t.turn.id)).toEqual(["u1", "u2"]) + expect(persistedAssistants.map((t) => t.turn.id)).toEqual(["a1", "a2"]) + // Live stream may render as streaming phase or as empty when the live + // message has no content yet — the history-preservation assertion above + // is the regression guard for continue_with_session. + expect(timeline.some((t) => t.turn.id === "u1")).toBe(true) + expect(timeline.some((t) => t.turn.id === "a1")).toBe(true) + }) }) /** diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 414dac485..c53cfe9b1 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1377,7 +1377,8 @@ "noFolders": "لا توجد مجلدات مفتوحة", "newChatAction": "محادثة جديدة", "automations": "الأتمتة", - "loadingSubsessions": "جارٍ تحميل المحادثات الفرعية…" + "loadingSubsessions": "جارٍ تحميل المحادثات الفرعية…", + "showSubsessions": "إظهار الجلسات الفرعية" }, "conversation": { "reloadFailed": "فشل إعادة تحميل المحادثة: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "وضع علامة كمكتمل", "reopen": "إعادة فتح", "expandSubsessions": "توسيع المحادثات الفرعية", - "collapseSubsessions": "طي المحادثات الفرعية" + "collapseSubsessions": "طي المحادثات الفرعية", + "subsessionBadge": "فرعي", + "subsessionBadgeTitle": "جلسة فرعية مفوضة", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "بحث", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "الوكيل الفرعي: حد الطلبات", "child_empty": "الوكيل الفرعي بدون استجابة", "child_unknown": "الوكيل الفرعي: خطأ", - "unknown": "مهمة غير معروفة" + "unknown": "مهمة غير معروفة", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "يتم عرض نهاية المخرجات أثناء البث لتحسين الأداء.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ad467e1f6..20874479a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1377,7 +1377,8 @@ "noFolders": "Keine Ordner geöffnet", "newChatAction": "Neuer Chat", "automations": "Automatisierungen", - "loadingSubsessions": "Unterkonversationen werden geladen…" + "loadingSubsessions": "Unterkonversationen werden geladen…", + "showSubsessions": "Untersitzungen anzeigen" }, "conversation": { "reloadFailed": "Konversation konnte nicht neu geladen werden: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "Als abgeschlossen markieren", "reopen": "Erneut öffnen", "expandSubsessions": "Unterkonversationen einblenden", - "collapseSubsessions": "Unterkonversationen ausblenden" + "collapseSubsessions": "Unterkonversationen ausblenden", + "subsessionBadge": "Sub", + "subsessionBadgeTitle": "Delegierte Untersitzung", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "Suchen", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "Subagent: Anfragelimit", "child_empty": "Subagent ohne Ausgabe", "child_unknown": "Subagent: Fehler", - "unknown": "unbekannte Aufgabe" + "unknown": "unbekannte Aufgabe", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "Zur besseren Performance wird während des Streamings nur die Endausgabe angezeigt.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bdfd937f5..96ce41e1e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1319,6 +1319,7 @@ "viewOptions": "View options", "showCompleted": "Show completed conversations", "showWorktrees": "Show worktree folders", + "showSubsessions": "Show delegated sub-sessions", "moreOptions": "More options", "sortBy": "Sort by", "sortByCreatedAt": "Created time", @@ -1470,7 +1471,11 @@ "markCompleted": "Mark as completed", "reopen": "Reopen", "expandSubsessions": "Expand sub-conversations", - "collapseSubsessions": "Collapse sub-conversations" + "collapseSubsessions": "Collapse sub-conversations", + "subsessionBadge": "Sub", + "subsessionBadgeTitle": "Delegated sub-session from a parent conversation", + "subsessionCountLabel": "{count} sub", + "subsessionCountHint": "{count, plural, one {Has # sub-session. Open the top-right filter and enable Show sub-sessions to expand.} other {Has # sub-sessions. Open the top-right filter and enable Show sub-sessions to expand.}}" }, "search": { "dialogTitle": "Search", @@ -2538,6 +2543,10 @@ "waitForResultNoTask": "Waiting for task result", "cancelTask": "Canceling task {task}", "cancelTaskNoTask": "Canceling task", + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session", "resultPageOf": "{current} / {total}", "prevResult": "Previous result", "nextResult": "Next result", @@ -2562,7 +2571,10 @@ "child_max_turn_requests": "subagent request limit", "child_empty": "subagent no output", "child_unknown": "subagent unknown error", - "unknown": "unknown task" + "unknown": "unknown task", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } } }, diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 26f871e11..502b0e321 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1377,7 +1377,8 @@ "noFolders": "No hay carpetas abiertas", "newChatAction": "Nuevo chat", "automations": "Automatizaciones", - "loadingSubsessions": "Cargando subconversaciones…" + "loadingSubsessions": "Cargando subconversaciones…", + "showSubsessions": "Mostrar subsesiones" }, "conversation": { "reloadFailed": "No se pudo recargar la conversación: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "Marcar como completada", "reopen": "Reabrir", "expandSubsessions": "Expandir subconversaciones", - "collapseSubsessions": "Contraer subconversaciones" + "collapseSubsessions": "Contraer subconversaciones", + "subsessionBadge": "Sub", + "subsessionBadgeTitle": "Subsesión delegada", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "Buscar", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "subagente: límite de solicitudes", "child_empty": "subagente sin salida", "child_unknown": "subagente: error", - "unknown": "tarea desconocida" + "unknown": "tarea desconocida", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "Mostrando la salida final durante el streaming para mejorar el rendimiento.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8b615adab..38ec1b99b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1377,7 +1377,8 @@ "noFolders": "Aucun dossier ouvert", "newChatAction": "Nouvelle discussion", "automations": "Automatisations", - "loadingSubsessions": "Chargement des sous-conversations…" + "loadingSubsessions": "Chargement des sous-conversations…", + "showSubsessions": "Afficher les sous-sessions" }, "conversation": { "reloadFailed": "Échec du rechargement de la conversation : {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "Marquer comme terminé", "reopen": "Rouvrir", "expandSubsessions": "Développer les sous-conversations", - "collapseSubsessions": "Réduire les sous-conversations" + "collapseSubsessions": "Réduire les sous-conversations", + "subsessionBadge": "Sous", + "subsessionBadgeTitle": "Sous-session déléguée", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "Rechercher", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "sous-agent : limite de requêtes", "child_empty": "sous-agent sans sortie", "child_unknown": "sous-agent : erreur", - "unknown": "tâche inconnue" + "unknown": "tâche inconnue", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "Affichage de la fin de la sortie pendant le streaming pour de meilleures performances.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a4ff328ae..d7729861d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1377,7 +1377,8 @@ "noFolders": "開いているフォルダがありません", "newChatAction": "新しいチャット", "automations": "オートメーション", - "loadingSubsessions": "サブ会話を読み込み中…" + "loadingSubsessions": "サブ会話を読み込み中…", + "showSubsessions": "サブセッションを表示" }, "conversation": { "reloadFailed": "会話の再読み込みに失敗しました: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "完了にする", "reopen": "再開する", "expandSubsessions": "サブ会話を展開", - "collapseSubsessions": "サブ会話を折りたたむ" + "collapseSubsessions": "サブ会話を折りたたむ", + "subsessionBadge": "子", + "subsessionBadgeTitle": "委譲サブセッション", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "検索", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "サブエージェント: 要求上限", "child_empty": "サブエージェント応答なし", "child_unknown": "サブエージェント: エラー", - "unknown": "不明なタスク" + "unknown": "不明なタスク", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "パフォーマンスのため、ストリーミング中は末尾出力を表示しています。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b99b77753..c171baefc 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1377,7 +1377,8 @@ "noFolders": "열린 폴더 없음", "newChatAction": "새 채팅", "automations": "자동화", - "loadingSubsessions": "하위 대화 로드 중…" + "loadingSubsessions": "하위 대화 로드 중…", + "showSubsessions": "하위 세션 표시" }, "conversation": { "reloadFailed": "대화 다시 불러오기 실패: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "완료로 표시", "reopen": "다시 열기", "expandSubsessions": "하위 대화 펼치기", - "collapseSubsessions": "하위 대화 접기" + "collapseSubsessions": "하위 대화 접기", + "subsessionBadge": "하위", + "subsessionBadgeTitle": "위임 하위 세션", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "검색", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "서브에이전트: 요청 한도", "child_empty": "서브에이전트 응답 없음", "child_unknown": "서브에이전트: 오류", - "unknown": "알 수 없는 작업" + "unknown": "알 수 없는 작업", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "성능을 위해 스트리밍 중에는 출력의 끝부분만 표시합니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 572880228..b157bea06 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1377,7 +1377,8 @@ "noFolders": "Nenhuma pasta aberta", "newChatAction": "Novo chat", "automations": "Automações", - "loadingSubsessions": "Carregando subconversas…" + "loadingSubsessions": "Carregando subconversas…", + "showSubsessions": "Mostrar sub-sessões" }, "conversation": { "reloadFailed": "Falha ao recarregar conversa: {message}", @@ -1470,7 +1471,11 @@ "markCompleted": "Marcar como concluída", "reopen": "Reabrir", "expandSubsessions": "Expandir subconversas", - "collapseSubsessions": "Recolher subconversas" + "collapseSubsessions": "Recolher subconversas", + "subsessionBadge": "Sub", + "subsessionBadgeTitle": "Sub-sessão delegada", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "Buscar", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "subagente: limite de solicitações", "child_empty": "subagente sem saída", "child_unknown": "subagente: erro", - "unknown": "tarefa desconhecida" + "unknown": "tarefa desconhecida", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "Mostrando a saída final durante o streaming para melhor desempenho.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f96243f57..f8e862c7c 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1319,6 +1319,7 @@ "viewOptions": "显示选项", "showCompleted": "显示已完成会话", "showWorktrees": "显示工作树文件夹", + "showSubsessions": "显示委托子会话", "moreOptions": "更多选项", "sortBy": "排序方式", "sortByCreatedAt": "按创建时间排序", @@ -1470,7 +1471,11 @@ "markCompleted": "标记为已完成", "reopen": "重新打开", "expandSubsessions": "展开子会话", - "collapseSubsessions": "折叠子会话" + "collapseSubsessions": "折叠子会话", + "subsessionBadge": "子会话", + "subsessionBadgeTitle": "由主会话 delegate 产生的子会话", + "subsessionCountLabel": "{count} 子", + "subsessionCountHint": "含 {count} 个子会话。打开右上角筛选,勾选「显示子会话」后可展开查看。" }, "search": { "dialogTitle": "搜索", @@ -2538,6 +2543,10 @@ "waitForResultNoTask": "等待任务执行结果", "cancelTask": "取消 {task} 任务", "cancelTaskNoTask": "取消任务", + "continueTask": "续跑 {task} 任务", + "continueTaskNoTask": "续跑子会话", + "closeTask": "关闭 {task} 任务", + "closeTaskNoTask": "关闭子会话", "resultPageOf": "{current} / {total}", "prevResult": "上一个结果", "nextResult": "下一个结果", @@ -2562,7 +2571,10 @@ "child_max_turn_requests": "子代理超出请求上限", "child_empty": "子代理无响应", "child_unknown": "子代理未知错误", - "unknown": "未知任务" + "unknown": "未知任务", + "session_still_running": "仍在运行", + "session_closed": "会话已关闭", + "not_continuable": "无法续跑" } } }, diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 16b45e45e..dc4d51159 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1377,7 +1377,8 @@ "noFolders": "沒有開啟的資料夾", "newChatAction": "新增聊天", "automations": "自動化", - "loadingSubsessions": "正在載入子會話…" + "loadingSubsessions": "正在載入子會話…", + "showSubsessions": "顯示子會話" }, "conversation": { "reloadFailed": "會話重新載入失敗:{message}", @@ -1470,7 +1471,11 @@ "markCompleted": "標記為已完成", "reopen": "重新開啟", "expandSubsessions": "展開子會話", - "collapseSubsessions": "摺疊子會話" + "collapseSubsessions": "摺疊子會話", + "subsessionBadge": "子", + "subsessionBadgeTitle": "委託子會話", + "subsessionCountLabel": "+{count}", + "subsessionCountHint": "{count} nested sub-sessions (enable Show sub-sessions)" }, "search": { "dialogTitle": "搜尋", @@ -2562,9 +2567,16 @@ "child_max_turn_requests": "子代理超出請求上限", "child_empty": "子代理無回應", "child_unknown": "子代理未知錯誤", - "unknown": "未知任務" + "unknown": "未知任務", + "session_still_running": "still running", + "session_closed": "session closed", + "not_continuable": "not continuable" } - } + }, + "continueTask": "Continuing task {task}", + "continueTaskNoTask": "Continuing sub-session", + "closeTask": "Closing task {task}", + "closeTaskNoTask": "Closing sub-session" }, "contentParts": { "showingTailOutput": "為確保效能,串流輸出時僅顯示尾端內容。", diff --git a/src/lib/adapters/tool-kind-classifier.test.ts b/src/lib/adapters/tool-kind-classifier.test.ts index fdb8f93a8..5f8c5efec 100644 --- a/src/lib/adapters/tool-kind-classifier.test.ts +++ b/src/lib/adapters/tool-kind-classifier.test.ts @@ -67,7 +67,12 @@ describe("isAgentLikeToolName", () => { }) it("matches the delegation companion tools across host naming conventions", () => { - for (const tool of ["get_delegation_status", "cancel_delegation"]) { + for (const tool of [ + "get_delegation_status", + "cancel_delegation", + "continue_with_session", + "close_session", + ]) { // Bare canonical form (live-streaming path, post-inferLiveToolName) expect(isAgentLikeToolName(tool)).toBe(true) // Claude Code style (current + legacy server names) diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index 964d62551..70b8142f3 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -41,6 +41,8 @@ export const TOOL_KIND_ORDER: ToolKindLabel[] = [ const DELEGATE_TO_AGENT_SUFFIX_RE = /[^a-z0-9]delegate_to_agent$/ const GET_DELEGATION_STATUS_SUFFIX_RE = /[^a-z0-9]get_delegation_status$/ const CANCEL_DELEGATION_SUFFIX_RE = /[^a-z0-9]cancel_delegation$/ +const CONTINUE_WITH_SESSION_SUFFIX_RE = /[^a-z0-9]continue_with_session$/ +const CLOSE_SESSION_SUFFIX_RE = /[^a-z0-9]close_session$/ const CREATE_GOAL_SUFFIX_RE = /[^a-z0-9]create_goal$/ const UPDATE_GOAL_SUFFIX_RE = /[^a-z0-9]update_goal$/ const ASK_USER_QUESTION_SUFFIX_RE = /[^a-z0-9]ask_user_question$/ @@ -57,6 +59,8 @@ export function isAgentLikeToolName(toolName: string): boolean { name === "delegate_to_agent" || name === "get_delegation_status" || name === "cancel_delegation" || + name === "continue_with_session" || + name === "close_session" || name === "create_goal" || name === "update_goal" || // codeg-mcp ask_user_question — owns the AskQuestionResultCard, so it must @@ -76,6 +80,8 @@ export function isAgentLikeToolName(toolName: string): boolean { if (DELEGATE_TO_AGENT_SUFFIX_RE.test(name)) return true if (GET_DELEGATION_STATUS_SUFFIX_RE.test(name)) return true if (CANCEL_DELEGATION_SUFFIX_RE.test(name)) return true + if (CONTINUE_WITH_SESSION_SUFFIX_RE.test(name)) return true + if (CLOSE_SESSION_SUFFIX_RE.test(name)) return true if (CREATE_GOAL_SUFFIX_RE.test(name)) return true if (UPDATE_GOAL_SUFFIX_RE.test(name)) return true if (ASK_USER_QUESTION_SUFFIX_RE.test(name)) return true diff --git a/src/lib/conversation-sidebar.test.ts b/src/lib/conversation-sidebar.test.ts new file mode 100644 index 000000000..054ee4be2 --- /dev/null +++ b/src/lib/conversation-sidebar.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { isSidebarRootConversation } from "./conversation-sidebar" + +describe("isSidebarRootConversation", () => { + it("accepts a regular root", () => { + expect( + isSidebarRootConversation({ + parent_id: null, + kind: "regular", + delegation_call_id: null, + }) + ).toBe(true) + }) + + it("rejects parent_id set", () => { + expect( + isSidebarRootConversation({ + parent_id: 42, + kind: "regular", + delegation_call_id: null, + }) + ).toBe(false) + }) + + it("rejects kind=delegate even without parent_id", () => { + expect( + isSidebarRootConversation({ + parent_id: null, + kind: "delegate", + delegation_call_id: null, + }) + ).toBe(false) + }) + + it("rejects delegation_call_id set", () => { + expect( + isSidebarRootConversation({ + parent_id: null, + kind: "regular", + delegation_call_id: "task-uuid", + }) + ).toBe(false) + }) + + it("rejects loop kind", () => { + expect( + isSidebarRootConversation({ + parent_id: null, + kind: "loop", + delegation_call_id: null, + }) + ).toBe(false) + }) +}) diff --git a/src/lib/conversation-sidebar.ts b/src/lib/conversation-sidebar.ts new file mode 100644 index 000000000..a24bebf3a --- /dev/null +++ b/src/lib/conversation-sidebar.ts @@ -0,0 +1,22 @@ +import type { DbConversationSummary } from "@/lib/types" + +/** + * Whether a conversation belongs in the workspace sidebar as a top-level row + * (folder group / Chat / Pinned). + * + * Delegation children nest under their parent when "Show delegated sub-sessions" + * is on — they must never appear as peer roots under a folder. Match any of: + * - `parent_id` set + * - `kind === "delegate"` + * - `delegation_call_id` set (broker task id stamped at spawn) + * Loop rows belong to the loops workbench. + */ +export function isSidebarRootConversation( + c: Pick +): boolean { + if (c.parent_id != null) return false + if (c.kind === "delegate") return false + if (c.kind === "loop") return false + if (c.delegation_call_id != null && c.delegation_call_id !== "") return false + return true +} diff --git a/src/lib/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts index 40195110a..3210dc36d 100644 --- a/src/lib/sidebar-view-mode-storage.ts +++ b/src/lib/sidebar-view-mode-storage.ts @@ -3,6 +3,7 @@ const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded" const SHOW_COMPLETED_KEY = "workspace:sidebar-show-completed" const SHOW_WORKTREES_KEY = "workspace:sidebar-show-worktrees" +const SHOW_SUBSESSIONS_KEY = "workspace:sidebar-show-subsessions" const SORT_MODE_KEY = "workspace:sidebar-sort-mode" const SECTION_ORDER_KEY = "workspace:sidebar-section-order" const SECTION_COLLAPSED_KEY = "workspace:sidebar-section-collapsed" @@ -130,6 +131,31 @@ export function saveShowWorktrees(value: boolean): void { } } +/** + * Whether the sidebar renders delegation sub-session trees under parents. + * Default **false**: only root conversations show; expand chevrons / nested + * rows stay hidden until the user enables this in view options. + */ +export function loadShowSubsessions(): boolean { + if (typeof window === "undefined") return false + try { + const raw = localStorage.getItem(SHOW_SUBSESSIONS_KEY) + if (raw === "true") return true + } catch { + /* ignore */ + } + return false +} + +export function saveShowSubsessions(value: boolean): void { + if (typeof window === "undefined") return + try { + localStorage.setItem(SHOW_SUBSESSIONS_KEY, String(value)) + } catch { + /* ignore */ + } +} + export function loadSortMode(): SidebarSortMode { if (typeof window === "undefined") return "created" try { diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index de1a47d98..474d65d52 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -79,6 +79,10 @@ const EXACT_TOOL_NAME_ALIASES: Record = { mcp__codeg__delegate_to_agent: "delegate_to_agent", get_delegation_status: "get_delegation_status", cancel_delegation: "cancel_delegation", + continue_with_session: "continue_with_session", + "mcp__codeg-mcp__continue_with_session": "continue_with_session", + close_session: "close_session", + "mcp__codeg-mcp__close_session": "close_session", // codeg-mcp live-feedback poll (server prefix varies by host; the suffix rule // in `normalizeToolName` covers the other separators). Codex persists it under // the bare `check_user_feedback` name, dropping the `mcp__codeg_mcp` namespace. @@ -375,6 +379,9 @@ export function normalizeToolName(toolName: string): string { if (/[^a-z0-9]get_delegation_status$/.test(canonical)) return "get_delegation_status" if (/[^a-z0-9]cancel_delegation$/.test(canonical)) return "cancel_delegation" + if (/[^a-z0-9]continue_with_session$/.test(canonical)) + return "continue_with_session" + if (/[^a-z0-9]close_session$/.test(canonical)) return "close_session" if (/[^a-z0-9]create_goal$/.test(canonical)) return "create_goal" if (/[^a-z0-9]update_goal$/.test(canonical)) return "update_goal" @@ -410,6 +417,8 @@ const DELEGATION_COMPANION_TOOLS: ReadonlySet = new Set([ "delegate_to_agent", "get_delegation_status", "cancel_delegation", + "continue_with_session", + "close_session", ]) export function inferLiveToolName(params: { diff --git a/src/stores/app-workspace-store.ts b/src/stores/app-workspace-store.ts index 00b919ad3..33f556fe6 100644 --- a/src/stores/app-workspace-store.ts +++ b/src/stores/app-workspace-store.ts @@ -11,6 +11,7 @@ import { removeFolderFromWorkspace as apiRemoveFolderFromWorkspace, reorderFolders as apiReorderFolders, } from "@/lib/api" +import { isSidebarRootConversation } from "@/lib/conversation-sidebar" import { toErrorMessage } from "@/lib/app-error" import type { AgentStats, @@ -183,7 +184,10 @@ export const useAppWorkspaceStore = create()( set({ conversationsLoading: true }) try { const list = await listAllConversations() - set({ ...withConversations(list), conversationsError: null }) + // Defense-in-depth: backend already drops delegation children, but never + // surface a child (parent_id set / kind=delegate) as a root sidebar row. + const roots = list.filter(isSidebarRootConversation) + set({ ...withConversations(roots), conversationsError: null }) } catch (err) { set({ conversationsError: toErrorMessage(err) }) } finally { @@ -230,10 +234,10 @@ export const useAppWorkspaceStore = create()( }, // Insert-or-replace a conversation by id (create + field updates). Root-only: - // delegation children (parent_id set) are not sidebar rows. New rows prepend - // (most-recent-first); existing rows replace in place to keep their position. + // delegation children (parent_id / kind=delegate) are not sidebar rows. + // New rows prepend (most-recent-first); existing rows replace in place. applyConversationUpsert: (summary) => { - if (summary.parent_id != null) return + if (!isSidebarRootConversation(summary)) return if (deletedIds.has(summary.id)) return const prev = get().conversations const idx = prev.findIndex((c) => c.id === summary.id) diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index 87d3f2bd4..81a3a1bc9 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -157,12 +157,12 @@ export interface ConversationRuntimeSession { // promotion time. Consumed by `isPureViewerSession`. lastTurnOwned: boolean - // Read-only delegation-child viewer marker. When true, `getTimelineTurns` - // suppresses the persisted copy of the (single) reply turn while this - // session has a live or just-promoted reply — so the sub-agent dialog shows - // the kickoff + live/local reply exactly once, never a persisted partial - // beside the live stream. Off for normal panels (which never set it), so - // their multi-turn history is untouched. See `getTimelineTurns`. + // Read-only multi-turn delegation-child viewer marker. When true, + // `getTimelineTurns` suppresses only the CURRENT reply's persisted partial + // while this session has a live or just-promoted reply — so the sub-agent + // dialog keeps prior continue history and never shows a persisted partial + // beside the live stream. Off for normal panels (which never set it). + // See `getTimelineTurns`. liveOwnsActiveTurn: boolean // Known kickoff prompt text for a delegation-child viewer (the parent's @@ -2380,28 +2380,47 @@ function computeTimelinePrefix( // Phase 1: DB historical turns. // When liveOwnsActiveTurn is set (sub-agent dialog), the live/local reply - // is authoritative for the child's current (only) reply. Strip any - // persisted assistant turns while there's a live or just-promoted local - // reply in this session — only the kickoff prefix (everything before the - // first assistant turn) is shown from the DB. This eliminates the - // partial-plus-live duplicate for all timing scenarios, including a - // connection-id-null open where we can't read the live store during fetch. + // is authoritative for the child's CURRENT reply. Hide only the persisted + // partial of THAT reply so it does not double-render beside the stream — + // do NOT strip earlier completed turns. `continue_with_session` makes + // children multi-turn: history before the latest user prompt must stay + // visible while the follow-up streams. // - // Delegation children are SINGLE-REPLY (one-shot): stripping from the - // first assistant turn onward removes exactly the persisted copy of that - // one reply. (A hypothetical multi-turn child would have earlier replies - // hidden during the live/grace window — not a case the viewer supports.) + // Cut rule while live/local owns the reply: + // - Prefer `in_flight_user_turn_id` (backend stamp of the active prompt). + // - Else the last persisted user turn. + // - Keep everything through that user turn; drop assistant turns after it. + // - If no user turn has landed yet (transcript lag), hide all assistants + // and let the kickoff synthesizer + live stream cover the open turn. const rawPersistedTurns = session.detail?.turns ?? [] const hasLiveOrLocalReply = session.liveOwnsActiveTurn && (session.liveMessage !== null || session.localTurns.length > 0) - const firstAssistantIdx = hasLiveOrLocalReply - ? rawPersistedTurns.findIndex((t) => t.role === "assistant") - : -1 - const persistedTurns = - hasLiveOrLocalReply && firstAssistantIdx !== -1 - ? rawPersistedTurns.slice(0, firstAssistantIdx) - : rawPersistedTurns + let persistedTurns = rawPersistedTurns + if (hasLiveOrLocalReply) { + const inFlightId = session.detail?.in_flight_user_turn_id ?? null + let cutUserIdx = -1 + if (inFlightId != null) { + cutUserIdx = rawPersistedTurns.findIndex( + (t) => t.role === "user" && t.id === inFlightId + ) + } + if (cutUserIdx === -1) { + for (let i = rawPersistedTurns.length - 1; i >= 0; i--) { + if (rawPersistedTurns[i].role === "user") { + cutUserIdx = i + break + } + } + } + if (cutUserIdx === -1) { + persistedTurns = rawPersistedTurns.filter((t) => t.role !== "assistant") + } else { + persistedTurns = rawPersistedTurns.filter( + (t, i) => i <= cutUserIdx || t.role !== "assistant" + ) + } + } // Suppress the persisted PARTIAL in-flight reply for a non-delegation // cross-client viewer. While a reply is streaming, some agents (OpenCode, From 610884453e09d791c8018664a15ebedbf832904c Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 22:48:57 +0800 Subject: [PATCH 2/2] fix(sidebar):mark-Sub-badge-only-for-delegation-children --- .../sidebar-conversation-card.tsx | 11 ++--- src/lib/conversation-sidebar.test.ts | 41 ++++++++++++++++++- src/lib/conversation-sidebar.ts | 32 ++++++++++----- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index daece6fd5..ebc34704f 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -52,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 @@ -211,11 +212,11 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ const isCancelled = status === "cancelled" const isPinned = conversation.pinned_at != null const isCompleted = status === "completed" - // Delegation sub-sessions (a child of another conversation) don't get the - // 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 || depth > 0 + // Delegation sub-sessions only — must NOT use `depth > 0`. Worktree layout + // also indents ordinary root sessions at depth 1 (repo root-group / worktree + // buckets under "Show worktree folders"); those are still top-level + // conversations, not delegated children. + const isSubsession = isDelegationSubsession(conversation) return ( <> diff --git a/src/lib/conversation-sidebar.test.ts b/src/lib/conversation-sidebar.test.ts index 054ee4be2..52bca25dc 100644 --- a/src/lib/conversation-sidebar.test.ts +++ b/src/lib/conversation-sidebar.test.ts @@ -1,5 +1,44 @@ import { describe, expect, it } from "vitest" -import { isSidebarRootConversation } from "./conversation-sidebar" +import { + isDelegationSubsession, + isSidebarRootConversation, +} from "./conversation-sidebar" + +describe("isDelegationSubsession", () => { + it("is false for a normal root (even if UI depth would be > 0)", () => { + expect( + isDelegationSubsession({ + parent_id: null, + kind: "regular", + delegation_call_id: null, + }) + ).toBe(false) + }) + + it("is true for parent_id / kind=delegate / delegation_call_id", () => { + expect( + isDelegationSubsession({ + parent_id: 1, + kind: "regular", + delegation_call_id: null, + }) + ).toBe(true) + expect( + isDelegationSubsession({ + parent_id: null, + kind: "delegate", + delegation_call_id: null, + }) + ).toBe(true) + expect( + isDelegationSubsession({ + parent_id: null, + kind: "regular", + delegation_call_id: "task-1", + }) + ).toBe(true) + }) +}) describe("isSidebarRootConversation", () => { it("accepts a regular root", () => { diff --git a/src/lib/conversation-sidebar.ts b/src/lib/conversation-sidebar.ts index a24bebf3a..75508481f 100644 --- a/src/lib/conversation-sidebar.ts +++ b/src/lib/conversation-sidebar.ts @@ -1,22 +1,32 @@ import type { DbConversationSummary } from "@/lib/types" +type ConversationIdentity = Pick< + DbConversationSummary, + "parent_id" | "kind" | "delegation_call_id" +> + +/** + * True for multi-agent delegation children (DB markers only — not UI tree + * depth). Worktree layout also indents ordinary sessions at depth ≥ 1; those + * must NOT count as sub-sessions. + */ +export function isDelegationSubsession(c: ConversationIdentity): boolean { + if (c.parent_id != null) return true + if (c.kind === "delegate") return true + if (c.delegation_call_id != null && c.delegation_call_id !== "") return true + return false +} + /** * Whether a conversation belongs in the workspace sidebar as a top-level row * (folder group / Chat / Pinned). * * Delegation children nest under their parent when "Show delegated sub-sessions" - * is on — they must never appear as peer roots under a folder. Match any of: - * - `parent_id` set - * - `kind === "delegate"` - * - `delegation_call_id` set (broker task id stamped at spawn) - * Loop rows belong to the loops workbench. + * is on — they must never appear as peer roots under a folder. Loop rows belong + * to the loops workbench. */ -export function isSidebarRootConversation( - c: Pick -): boolean { - if (c.parent_id != null) return false - if (c.kind === "delegate") return false +export function isSidebarRootConversation(c: ConversationIdentity): boolean { + if (isDelegationSubsession(c)) return false if (c.kind === "loop") return false - if (c.delegation_call_id != null && c.delegation_call_id !== "") return false return true }