diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d1e40c3ab1..b193b56798 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -53,7 +53,7 @@ use crate::acp::file_system_runtime::{ }; use crate::acp::host_tools_policy::{HostToolsPolicy, HOST_TOOLS_ENV}; use crate::acp::registry::{self, AgentDistribution}; -use crate::acp::session_state::SessionState; +use crate::acp::session_state::{SessionLoadFailure, SessionState}; use crate::acp::stderr_tail::{summarize_parser_error, StderrTail, TailScope}; use crate::acp::terminal_runtime::{ TerminalRuntime, TerminalRuntimeError, TerminalShellRuntimeConfig, @@ -2565,6 +2565,34 @@ const ACP_CONNECTION_STACK_SIZE: usize = 8 * 1024 * 1024; /// automatically removes the entry from `connections` once `run_connection` /// exits (timeout, error, or clean disconnect), so the manager never /// leaks stale entries after a connection tears down. +#[allow(clippy::too_many_arguments)] +/// How the connection establishes an existing agent session. +/// +/// The default is and stays [`Self::FallbackToNew`]: a user opening a +/// historical conversation must still get a usable session when the agent +/// forgot it. [`Self::StrictDelegationContinue`] is the delegation-only +/// exception — a `continue_delegation` runs a NEW round on an EXISTING +/// session, so silently opening an empty one would lose the whole point of +/// continuing it. The policy is passed per launch, never made a global +/// default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionLoadPolicy { + /// Classify the failure for the UI, then open a fresh `session/new` and + /// link the histories (long-standing behavior for every ordinary open). + FallbackToNew, + /// Fail closed: report the classified failure and never send + /// `session/new`. + StrictDelegationContinue, +} + +/// What `spawn_agent_connection` hands its caller: the session-start signal +/// (as before) plus the state it created, so a strict launch can read the +/// classified load failure recorded there after that signal aborts. +pub struct SpawnDriver { + pub session_started: tokio::sync::oneshot::Receiver<()>, + pub state: Arc>, +} + #[allow(clippy::too_many_arguments)] pub async fn spawn_agent_connection( connection_id: String, @@ -2580,7 +2608,8 @@ pub async fn spawn_agent_connection( delegation_injection: Option, terminal_shell_config: TerminalShellRuntimeConfig, cli_root: Option, -) -> Result, AcpError> { + session_load_policy: SessionLoadPolicy, +) -> Result { // Use the same cwd for launch settings, the subprocess and session/new. let launch_cwd = resolve_working_dir(working_dir.as_deref()); // Create the authoritative session state up front. Subsequent emit_with_state @@ -2829,6 +2858,7 @@ pub async fn spawn_agent_connection( host_tools, claude_projects_dir, stderr_tail, + session_load_policy, ) .await; @@ -2912,7 +2942,10 @@ pub async fn spawn_agent_connection( ))); } - Ok(session_started_rx) + Ok(SpawnDriver { + session_started: session_started_rx, + state: session_state, + }) } /// The agent request a permission card answers. `Acp` is a real ACP @@ -8007,6 +8040,8 @@ async fn run_connection( // callback installed by `build_agent`. Read only when a turn ends without // agent output, to attach evidence to the synthesized error. stderr_tail: Arc, + // Strict delegation-continue launches never fall back to `session/new`. + session_load_policy: SessionLoadPolicy, ) -> Result<(), AcpError> { let pending_perms: PendingPermissions = Arc::new(tokio::sync::Mutex::new(PermissionQueue::default())); @@ -9262,6 +9297,46 @@ async fn run_connection( .await } Err(e) => { + // Strict delegation-continue launches have exactly one + // way forward: load the source round's session. A + // failed load must never reach the `session/new` + // fallback below — that would answer the caller with a + // silently empty session wearing the requested id. + // Record the classified reason on the state so the + // manager can surface a typed error once the + // session-start wait unwinds. + if session_load_policy == SessionLoadPolicy::StrictDelegationContinue { + let code = classify_session_load_error(&e) + .unwrap_or("session_unavailable"); + let err_str = e.to_string(); + tracing::warn!( + "[ACP] strict session/load failed ({err_str}); refusing to open a \ + new session (code={code})" + ); + state.write().await.session_load_failure = Some(SessionLoadFailure { + code: code.to_string(), + message: err_str.clone(), + }); + emit_with_state( + &state, + &emitter_clone, + AcpEvent::SessionLoadFailed { + session_id: sid.clone(), + message: err_str, + code: code.to_string(), + }, + ) + .await; + emit_with_state( + &state, + &emitter_clone, + AcpEvent::StatusChanged { + status: ConnectionStatus::Error, + }, + ) + .await; + return Ok(()); + } // session/load failed. Classify it: an unrecoverable // historical session — the agent has no record of it // (ResourceNotFound, -32002) or the agent process/session @@ -31455,6 +31530,7 @@ mod tests { // Watched only for a Claude launch. cwd.join("claude-projects"), Arc::new(StderrTail::new()), + SessionLoadPolicy::FallbackToNew, )); let _ = ended_tx.send(ended); }) diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index f7274208a4..19f9d79cf1 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -59,20 +59,25 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use tokio::sync::{Mutex, Notify}; +use crate::acp::agent_mentions::render_assistant_brief; +use crate::acp::delegation::assistants::{AssistantLookup, AssistantRosterEntry, NoAssistants}; use crate::acp::delegation::event_emitter::{DelegationEventEmitter, NoopEventEmitter}; use crate::acp::delegation::live_reply::{ChildLiveReplyLookup, NoopChildLiveReplyLookup}; use crate::acp::delegation::meta_writer::{ - build_delegation_meta, is_synthetic_parent_tool_use_id, DelegationMetaWriter, NoopMetaWriter, + build_delegation_meta, build_delegation_meta_with_lineage, is_synthetic_parent_tool_use_id, + DelegationMetaWriter, NoopMetaWriter, }; use crate::acp::delegation::spawner::{ConnectionSpawner, DelegationLink}; -use crate::acp::agent_mentions::render_assistant_brief; -use crate::acp::delegation::assistants::{AssistantLookup, AssistantRosterEntry, NoAssistants}; use crate::acp::delegation::types::{ - AgentDelegationDefaults, AssistantRef, BlockedKind, BlockedOn, DelegationAssistant, - DelegationError, DelegationOutcome, DelegationProgress, DelegationRequest, - DelegationTaskReport, ResumeDelegationRequest, TaskStatus, + continue_error, AgentDelegationDefaults, AssistantRef, BlockedKind, BlockedOn, + ContinueDelegationRequest, DelegationAssistant, DelegationError, DelegationOutcome, + DelegationProgress, DelegationRequest, DelegationTaskLedger, DelegationTaskReport, + NoopDelegationTaskLedger, ResumeDelegationRequest, TaskStatus, }; use crate::acp::types::DelegationResultSummary; +use crate::db::service::delegation_task_service::{ + DelegationTaskRecord, NewDelegationTask, TerminalUpdate, +}; use crate::models::AgentType; /// Default per-parent byte budget for cached completed-task result text. The @@ -876,6 +881,9 @@ fn report_from_outcome( assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } } @@ -917,6 +925,9 @@ fn running_ack( assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } } @@ -1009,6 +1020,9 @@ fn resume_ack( assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } } @@ -1041,6 +1055,9 @@ fn not_resumable_report( assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } } @@ -1108,6 +1125,115 @@ fn attach_blocked(report: &mut DelegationTaskReport, blocked: BlockedOn) { report.blocked_on = Some(blocked); } +/// Longest chain `continue_delegation` will walk looking for the tail. A chain +/// grows one link per continue, so this only bounds a corrupt ledger. +const MAX_CHAIN_LEN: usize = 1024; + +/// The `continue_delegation` request fingerprint: SHA-256 of the task text +/// EXACTLY as it arrived. Nothing is normalized, prefixed or rewritten — the +/// fingerprint's whole job is to answer "is this the same request I already +/// ran?" for an idempotent replay, so any transformation would make two +/// different requests look alike or the same one look different. +fn task_request_hash(task: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(task.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// A refused continue: the source id in `source_task_id` (as the caller asked), +/// `task_id` deliberately EMPTY — no round was created, and echoing the source +/// id back in `task_id` would hand the LLM an id for work that does not exist. +fn continue_refusal(source_task_id: &str, error_code: &str, message: &str) -> DelegationTaskReport { + continue_refusal_with_detail(source_task_id, error_code, None, message) +} + +/// [`continue_refusal`] carrying a machine-readable `detail` (the classified +/// `session/load` reason) beside the human message. +fn continue_refusal_with_detail( + source_task_id: &str, + error_code: &str, + detail: Option<&str>, + message: &str, +) -> DelegationTaskReport { + let message = match detail { + Some(detail) => format!("Not continued ({detail}): {message}"), + None => format!("Not continued: {message}"), + }; + DelegationTaskReport { + task_id: None, + status: TaskStatus::Failed, + child_conversation_id: None, + agent_type: None, + text: None, + error_code: Some(error_code.to_string()), + message: Some(message), + duration_ms: None, + blocked_on: None, + assistant_id: None, + assistant_name: None, + progress: None, + source_task_id: Some(source_task_id.to_string()), + successor_task_id: None, + } +} + +/// A replay of an already-answered continue: the caller gets the existing +/// successor back — its real task id, its real status — without a second round +/// being spawned. This is the idempotency answer, so it must be shaped like the +/// original ack, not like a refusal. +fn continue_replay_report(existing: &DelegationTaskRecord) -> DelegationTaskReport { + DelegationTaskReport { + task_id: Some(existing.task_id.clone()), + status: existing.status, + child_conversation_id: Some(existing.child_conversation_id), + agent_type: Some(existing.agent_type), + text: existing.result_preview.clone(), + error_code: existing.error_code.clone(), + message: Some(format!( + "Already continued. task_id={}. This request was already answered by \ + that round; call get_delegation_status with it to collect the result.", + existing.task_id + )), + duration_ms: existing.duration_ms, + blocked_on: None, + assistant_id: existing.assistant_ref.as_ref().map(|a| a.id), + assistant_name: existing.assistant_ref.as_ref().map(|a| a.name.clone()), + progress: None, + source_task_id: existing.source_task_id.clone(), + successor_task_id: None, + } +} + +/// A continue refused because the source already has a successor built from +/// DIFFERENT task text. Reports both ids: the source the caller named and the +/// round that already continues it. +fn continue_conflict_report( + source: &DelegationTaskRecord, + existing: &DelegationTaskRecord, +) -> DelegationTaskReport { + DelegationTaskReport { + task_id: None, + status: TaskStatus::Failed, + child_conversation_id: Some(existing.child_conversation_id), + agent_type: Some(source.agent_type), + text: None, + error_code: Some(continue_error::CONTINUATION_CONFLICT.to_string()), + message: Some(format!( + "Not continued: task {} already has a successor ({}). Poll that round, \ + or continue IT if you need different follow-up work.", + source.task_id, existing.task_id + )), + duration_ms: None, + blocked_on: None, + assistant_id: existing.assistant_ref.as_ref().map(|a| a.id), + assistant_name: existing.assistant_ref.as_ref().map(|a| a.name.clone()), + progress: None, + source_task_id: Some(source.task_id.clone()), + successor_task_id: Some(existing.task_id.clone()), + } +} + /// Status report for a still-running task. fn running_report(task_id: &str, task: &RunningTask) -> DelegationTaskReport { DelegationTaskReport { @@ -1125,6 +1251,9 @@ fn running_report(task_id: &str, task: &RunningTask) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } .with_assistant(task.assistant.as_ref()) } @@ -1144,6 +1273,9 @@ fn completed_report(task_id: &str, c: &CompletedTask) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: c.progress.clone(), + + source_task_id: None, + successor_task_id: None, } .with_assistant(c.assistant.as_ref()) } @@ -1168,6 +1300,9 @@ fn unknown_report(task_id: &str) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } } @@ -1190,6 +1325,9 @@ fn db_report(task_id: &str, rec: &ChildStatusRecord) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, + + source_task_id: None, + successor_task_id: None, } .with_assistant(rec.assistant.as_ref()) } @@ -1529,6 +1667,11 @@ pub struct DelegationBroker { /// assistants"; production wires `DbAssistantLookup` via /// `with_assistants`. assistants: Arc, + /// Append-only round ledger. Every delegation round — the original + /// `delegate_to_agent` and each `continue_delegation` successor — is a row + /// here; the child conversation is the session they share. Defaults to a + /// no-op so tests that predate the ledger keep their semantics. + ledger: Arc, pending: Arc, tool_calls: Arc, pre_canceled_handles: Arc, @@ -1591,6 +1734,7 @@ impl DelegationBroker { status_lookup: Arc::new(NoopChildStatusLookup), live_reply_lookup: Arc::new(NoopChildLiveReplyLookup), assistants: Arc::new(NoAssistants), + ledger: Arc::new(NoopDelegationTaskLedger), pending: Arc::new(PendingCalls::default()), tool_calls: Arc::new(ToolCallTracker::default()), pre_canceled_handles: Arc::new(PreCanceledHandles::default()), @@ -1628,6 +1772,14 @@ impl DelegationBroker { self } + /// Replace the round ledger (default: no-op). Production wires the + /// SeaORM-backed `DbDelegationTaskLedger`; tests opt in with an in-memory + /// double. + pub fn with_ledger(mut self, ledger: Arc) -> Self { + self.ledger = ledger; + self + } + /// The assistant a `delegate_to_agent` call may run as — with the agent /// it runs on — read now, never frozen at session launch. pub async fn resolve_assistant(&self, id: i32) -> Option<(AgentType, DelegationAssistant)> { @@ -2745,13 +2897,12 @@ impl DelegationBroker { parent_conversation_id: req.parent_conversation_id, parent_tool_use_id: req.parent_tool_use_id.clone(), delegation_call_id: call_id.clone(), - assistant: req - .assistant - .as_ref() - .map(|a| crate::acp::delegation::spawner::AssistantLink { + assistant: req.assistant.as_ref().map(|a| { + crate::acp::delegation::spawner::AssistantLink { id: a.id, snapshot: a.run_snapshot(), - }), + } + }), }; // The assistant's standing instructions ride the child's first prompt // as a trailing internal frame (see `render_assistant_brief`). @@ -2805,6 +2956,28 @@ impl DelegationBroker { } }; + // The prompt has created the child conversation; persist its immutable + // task round now. SessionStarted may not have supplied external_id yet. + if let Err(e) = self + .ledger + .insert(NewDelegationTask { + task_id: call_id.clone(), + source_task_id: None, + request_hash: None, + parent_conversation_id: req.parent_conversation_id, + parent_tool_use_id: Some(req.parent_tool_use_id.clone()), + child_conversation_id, + agent_type: req.agent_type, + external_session_id: None, + working_dir: req.working_dir.clone(), + assistant: req.assistant.clone(), + task_preview: Some(task_preview.clone()), + }) + .await + { + tracing::error!(task_id = %call_id, error = %e, "failed to insert delegation task ledger row"); + } + // The child is now running. Stamp the start so terminal paths can // report a real `duration_ms`. let started_at = Instant::now(); @@ -2923,7 +3096,650 @@ impl DelegationBroker { ); }; match (child_terminal, parent_canceled_at) { - // Both raced in the setup window: the earlier arrival stamp wins. + // Both raced in the setup window: the earlier arrival stamp wins. + (Some((child_stamp, outcome)), Some(cancel_stamp)) => { + inner.deregister_inflight(inflight_id); + if child_stamp < cancel_stamp { + record(&mut inner, &outcome); + Disposition::ChildTerminal(outcome) + } else { + record( + &mut inner, + &canceled_outcome(child_conversation_id, "parent canceled"), + ); + Disposition::ParentCanceled + } + } + // Only a child terminal fired. + (Some((_, outcome)), None) => { + inner.deregister_inflight(inflight_id); + record(&mut inner, &outcome); + Disposition::ChildTerminal(outcome) + } + // Only a parent cancel fired. + (None, Some(_)) => { + inner.deregister_inflight(inflight_id); + record( + &mut inner, + &canceled_outcome(child_conversation_id, "parent canceled"), + ); + Disposition::ParentCanceled + } + // Nothing beat us — register the running task for a future + // resolver, deregistering the in-flight record adjacent to the + // insert with no `.await` between (so a parent cancel serialized + // AFTER us finds it in `running` and drains it). + (None, None) => { + inner.running.insert( + call_id.clone(), + RunningTask { + child_connection_id: child_connection_id.clone(), + child_conversation_id, + parent_connection_id: req.parent_connection_id.clone(), + parent_tool_use_id: req.parent_tool_use_id.clone(), + agent_type: req.agent_type, + assistant: identity.clone(), + task_preview: task_preview.clone(), + task_id: call_id.clone(), + external_handle: req.external_handle.clone(), + started_at, + last_surfaced_block: None, + }, + ); + inner.deregister_inflight(inflight_id); + Disposition::Running + } + } + }; + + match disposition { + // A child terminal beat registration. Finalize (terminal meta + + // DelegationCompleted event + child teardown) and return the + // terminal report directly. The completed entry was recorded under + // the disposition lock above; wake any long-poll waiter. + Disposition::ChildTerminal(outcome) => { + self.finalize_delegation( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + child_conversation_id, + req.agent_type, + identity.as_ref(), + setup_duration_ms, + &outcome, + &task_preview, + &call_id, + ) + .await; + self.result_notify.notify_waiters(); + return report_from_outcome( + Some(call_id), + Some(req.agent_type), + &outcome, + Some(setup_duration_ms), + ); + } + // A parent cancel reached this delegation mid-setup — after the + // prompt was sent, before we registered. Tear the child down + // ourselves (cancel + disconnect, since a turn is in flight) and + // return a canceled report. The canceled result was recorded above. + Disposition::ParentCanceled => { + self.record_ledger_terminal( + &call_id, + setup_duration_ms, + &canceled_outcome(child_conversation_id, "parent canceled"), + ) + .await; + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("canceled"), + None, + Some(setup_duration_ms), + Some(&task_preview), + Some(&call_id), + identity.as_ref(), + ), + ) + .await; + self.emit_completed_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + child_conversation_id, + req.agent_type, + identity.as_ref(), + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + let _ = self.spawner.cancel(&child_connection_id).await; + let _ = self.spawner.disconnect(&child_connection_id).await; + self.result_notify.notify_waiters(); + return report_from_outcome( + Some(call_id), + Some(req.agent_type), + &canceled_outcome(child_conversation_id, "parent canceled"), + Some(setup_duration_ms), + ); + } + // Registered in `running` — fall through to the second pre-cancel + // check, then return the ack. + Disposition::Running => {} + } + + // Second pre-cancel check: a `notifications/cancelled` may have landed + // between the entry-side check and the `running` registration above. If + // so, drain the task ourselves (so a racing `cancel_by_external_handle` + // doesn't double-finalize), record the canceled result, and return a + // canceled report instead of the Running ack. + if let Some(handle) = req.external_handle.as_deref() { + if self.take_pre_canceled_handle(handle).await { + // Capture the elapsed ONCE at terminalization (under the lock, + // when the running task is removed) so the completed-cache, the + // parent-card meta, and the returned report all report the same + // duration. `None` when nothing was drained. + let canceled_duration_ms = { + let mut inner = self.pending.inner.lock().await; + if inner.running.remove(&call_id).is_some() { + let outcome = + canceled_outcome(child_conversation_id, "canceled before await"); + let duration_ms = started_at.elapsed().as_millis() as u64; + inner.insert_completed( + &call_id, + build_completed( + &req.parent_connection_id, + child_conversation_id, + req.agent_type, + identity.as_ref(), + duration_ms, + &outcome, + ), + ); + Some(duration_ms) + } else { + None + } + }; + if let Some(duration_ms) = canceled_duration_ms { + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta( + "failed", + Some(&child_connection_id), + Some(child_conversation_id), + Some("canceled"), + None, + Some(duration_ms), + Some(&task_preview), + Some(&call_id), + identity.as_ref(), + ), + ) + .await; + self.emit_completed_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + child_conversation_id, + req.agent_type, + identity.as_ref(), + DelegationResultSummary::Err { + error_code: "canceled".to_string(), + }, + ) + .await; + let _ = self.spawner.cancel(&child_connection_id).await; + let _ = self.spawner.disconnect(&child_connection_id).await; + self.result_notify.notify_waiters(); + return report_from_outcome( + Some(call_id), + Some(req.agent_type), + &canceled_outcome(child_conversation_id, "canceled before await"), + Some(duration_ms), + ); + } + } + } + + // Registered and running in the background — return the ack. The child + // resolves later via the lifecycle → `complete_call` (or a cancel path). + running_ack(call_id, child_conversation_id, req.agent_type) + } + + /// Backs the `continue_delegation` tool: open the NEXT round of work on a + /// delegated session. + /// + /// The model is one child conversation (a SESSION) plus many ledger rows (a + /// ROUND each). This call appends a successor row that shares the source + /// round's child conversation and ACP session, mints a NEW task id, and + /// sends the caller's full new task into that same session — never the + /// resume "continue the ORIGINAL task" frame, and never a fresh child. + /// + /// Everything about the run — agent, working directory, ACP session, + /// assistant snapshot — comes off the source ledger row. The tool takes no + /// `agent_type` / `working_dir` / `assistant_id` / session id, and no native + /// config fingerprint: a continuation resumes the session that exists. + /// + /// Idempotency: the request fingerprint is the SHA-256 of the task text + /// exactly as received (no rewriting). Replaying the same text against the + /// same source returns the existing successor without spawning; a different + /// text is a `continuation_conflict`. The unique index on + /// `source_task_id` is the single-successor authority, and only the latest + /// round of a chain may open a successor. + #[tracing::instrument( + name = "delegation_continue", + skip_all, + fields( + parent_connection_id = %req.parent_connection_id, + source_task_id = %req.task_id, + child_connection_id = tracing::field::Empty, + task_id = tracing::field::Empty, + ) + )] + pub async fn continue_delegation( + &self, + req: ContinueDelegationRequest, + ) -> DelegationTaskReport { + let mut req = req; + // Register the in-flight setup FIRST, exactly as start/resume do, so a + // parent cancel landing anywhere from here to park reaches this round. + let inflight_id = self + .pending + .inner + .lock() + .await + .register_inflight(&req.parent_connection_id); + // Pre-cancel short-circuit: the companion already saw + // `notifications/cancelled` before the round-trip reached us. + if let Some(handle) = req.external_handle.as_deref() { + if self.take_pre_canceled_handle(handle).await { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &req.task_id, + continue_error::UNKNOWN, + "canceled before continue", + ); + } + } + let cfg = self.config_snapshot().await; + if !cfg.enabled { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &req.task_id, + continue_error::UNKNOWN, + "delegation is disabled", + ); + } + // Claim the parent-side `continue_delegation` tool call before the + // multi-second load, so the live card is bound while the child starts. + // Identity-less hosts announce it as "MCP: tool"; the listener + // registered the candidate, and the completion-time sniff cannot cover + // this tool (its result text is a free-form report), so this is the + // only identity source there. + if req.parent_tool_use_id.is_empty() { + req.parent_tool_use_id = self + .claim_continue_tool_call(&req.parent_connection_id, &req.task_id, &req.task) + .await + .unwrap_or_else(|| format!("delegation-{}", uuid::Uuid::new_v4())); + } else { + self.consume_explicit_tool_call(&req.parent_connection_id, &req.parent_tool_use_id) + .await; + } + let report = self.continue_delegation_gated(req, inflight_id, cfg).await; + report + } + + /// Post-preflight body of [`Self::continue_delegation`]. Split out so the + /// in-flight record is deregistered on every exit. + async fn continue_delegation_gated( + &self, + req: ContinueDelegationRequest, + inflight_id: u64, + cfg: DelegationConfig, + ) -> DelegationTaskReport { + // --- 1. Source lookup + ownership ------------------------------------- + // A row owned by another parent answers exactly like an unknown id: the + // ledger must never confirm that someone else's task exists. + let source = match self.ledger.find(&req.task_id).await { + Some(row) if row.parent_conversation_id == req.parent_conversation_id => row, + _ => { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &req.task_id, + continue_error::UNKNOWN, + "unknown task id — it never existed, or it belongs to another session", + ); + } + }; + + // --- 2. Successor check (idempotency vs conflict) ---------------------- + let request_hash = task_request_hash(&req.task); + if let Some(existing) = self.ledger.successor_of(&source.task_id).await { + // The same request text replayed — an idempotent answer, no spawn. + // The 1-successor index is what makes "already answered" reliable; + // the hash distinguishes a replay from a genuine second request. + if existing.request_hash.as_deref() == Some(request_hash.as_str()) { + self.drop_inflight(inflight_id).await; + return continue_replay_report(&existing); + } + self.drop_inflight(inflight_id).await; + // A successor exists and answers different work: the source is + // spent. Report BOTH ids, never the new request's absence as if + // the task were unknown. + return continue_conflict_report(&source, &existing); + } + + // --- 3. Chain position ------------------------------------------------- + // Only the latest round may open a successor; an older round whose own + // continuation already happened answers with the conflict above, and + // one that was never continued but is not the chain's tail is a client + // bug the LLM must correct. + if let Some(latest) = self.latest_task_of_chain(&source).await { + if latest != source.task_id { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &source.task_id, + continue_error::TASK_NOT_LATEST, + "only the latest round of a delegated session can be continued", + ); + } + } + + // --- 4. Status gates --------------------------------------------------- + // A live `running` round is still doing its own work: hands off. A + // `running` row with no live connection is a crash leftover, which + // `resume_delegation` — not continue — exists to revive. + if source.status == TaskStatus::Running { + let live = self + .spawner + .has_live_connection_for_conversation(source.child_conversation_id) + .await; + self.drop_inflight(inflight_id).await; + return if live { + continue_refusal( + &source.task_id, + continue_error::TASK_RUNNING, + "the task is still running — poll it with get_delegation_status, \ + or stop it with cancel_delegation, then continue", + ) + } else { + continue_refusal( + &source.task_id, + continue_error::SOURCE_NOT_TERMINAL, + "the task is recorded as running but its session is not live — \ + resume_delegation it first, then continue", + ) + }; + } + if !source.is_terminal() { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &source.task_id, + continue_error::UNKNOWN, + "the task has no usable terminal state", + ); + } + + // --- 5. Recover the run from the ledger -------------------------------- + let Some(external_id) = source.external_session_id.clone() else { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &source.task_id, + continue_error::SESSION_ID_MISSING, + "no agent session id was recorded for this task, so there is no \ + session to continue. Start a new delegate_to_agent call instead.", + ); + }; + let Some(working_dir) = source.working_dir.clone() else { + self.drop_inflight(inflight_id).await; + return continue_refusal( + &source.task_id, + continue_error::SESSION_CWD_MISSING, + "the task's working directory can no longer be recovered. Start a \ + new delegate_to_agent call in a valid directory instead.", + ); + }; + let assistant = source.assistant.clone(); + let identity = source.assistant_ref.clone(); + let (preferred_mode_id, preferred_config_values) = launch_preferences( + assistant.as_ref(), + cfg.agent_defaults.get(&source.agent_type), + ); + let call_id = uuid::Uuid::new_v4().to_string(); + let task_preview = truncate_on_char_boundary(&req.task, TASK_PREVIEW_CAP); + tracing::Span::current().record("task_id", call_id.as_str()); + + // Checkpoint: a parent cancel during the gates — nothing spawned yet. + if self.take_inflight_cancel(inflight_id).await { + return continue_refusal( + &req.task_id, + continue_error::UNKNOWN, + "canceled before the round started", + ); + } + + // --- 6. Append the successor row --------------------------------------- + // Written BEFORE the load: the ledger's unique index is what reserves + // the single-successor slot, so two racing continues cannot both spawn + // a round. A round that never sends a prompt is discarded below, which + // releases the slot for a retry — see [`Self::discard_continue_round`]. + let new_task = NewDelegationTask { + task_id: call_id.clone(), + source_task_id: Some(source.task_id.clone()), + request_hash: Some(request_hash), + parent_conversation_id: req.parent_conversation_id, + parent_tool_use_id: Some(req.parent_tool_use_id.clone()), + child_conversation_id: source.child_conversation_id, + agent_type: source.agent_type, + external_session_id: Some(external_id.clone()), + working_dir: Some(working_dir.clone()), + assistant: assistant.clone(), + task_preview: Some(task_preview.clone()), + }; + if let Err(e) = self.ledger.insert(new_task).await { + self.drop_inflight(inflight_id).await; + // A racing continue won the slot: answer from the row it wrote. + if let Some(existing) = self.ledger.successor_of(&source.task_id).await { + return if existing.request_hash.as_deref() + == Some(task_request_hash(&req.task).as_str()) + { + continue_replay_report(&existing) + } else { + continue_conflict_report(&source, &existing) + }; + } + return continue_refusal( + &source.task_id, + continue_error::SPAWN_FAILED, + &format!("could not record the new round: {e}"), + ); + } + + // --- 7. Strict session/load -------------------------------------------- + let spawned = match self + .spawner + .spawn_for_continue( + &req.parent_connection_id, + source.agent_type, + Some(working_dir.clone()), + &external_id, + preferred_mode_id, + preferred_config_values, + ) + .await + { + Ok(spawned) => spawned, + Err(e) => { + self.drop_inflight(inflight_id).await; + self.discard_continue_round(&call_id).await; + let (code, detail) = match &e { + crate::acp::delegation::spawner::SpawnerError::SessionLoad { code, .. } => { + (continue_error::SESSION_LOAD_FAILED, Some(code.clone())) + } + _ => (continue_error::SPAWN_FAILED, None), + }; + return continue_refusal_with_detail( + &source.task_id, + code, + detail.as_deref(), + &e.to_string(), + ) + .with_assistant(identity.as_ref()); + } + }; + tracing::Span::current().record("child_connection_id", spawned.connection_id.as_str()); + + // --- 8. A reused connection is somebody else's -------------------------- + // The manager hands back a live connection already representing this + // session (the user has the child open in a tab). Nothing is sent + // through it and it is not ours to disconnect: the round is refused + // rather than preempted, and the user's connection is left untouched. + if spawned.reused { + self.drop_inflight(inflight_id).await; + self.discard_continue_round(&call_id).await; + return continue_refusal( + &source.task_id, + continue_error::SESSION_BUSY, + "the session is open in another live connection — close it (or \ + wait for it to finish) before continuing", + ) + .with_assistant(identity.as_ref()); + } + let child_connection_id = spawned.connection_id; + + // Checkpoint: a parent cancel during the load — the connection exists + // but no prompt went out. + if self.take_inflight_cancel(inflight_id).await { + let _ = self.spawner.disconnect(&child_connection_id).await; + self.discard_continue_round(&call_id).await; + return continue_refusal( + &source.task_id, + continue_error::UNKNOWN, + "canceled before the round started", + ) + .with_assistant(identity.as_ref()); + } + + // --- 9/10. Send the new task into the SAME child conversation ---------- + self.pending + .inner + .lock() + .await + .reserve(&call_id, &child_connection_id); + let brief = assistant + .as_ref() + .and_then(|a| render_assistant_brief(&a.name, &a.instructions)); + if let Err(e) = self + .spawner + .send_continue_prompt( + &child_connection_id, + req.task.clone(), + source.folder_id, + source.child_conversation_id, + brief, + ) + .await + { + { + let mut inner = self.pending.inner.lock().await; + inner.unreserve(&call_id, &child_connection_id); + inner.deregister_inflight(inflight_id); + } + let _ = self.spawner.disconnect(&child_connection_id).await; + self.discard_continue_round(&call_id).await; + return continue_refusal( + &source.task_id, + continue_error::SEND_FAILED, + &format!("could not send the new task: {e}"), + ) + .with_assistant(identity.as_ref()); + } + let started_at = Instant::now(); + + // --- 11. The new round's own card -------------------------------------- + // Meta and events use the CONTINUE call's parent tool id — never the + // source's — so the old card keeps whatever it already showed and this + // round gets its own. + self.write_meta_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + build_delegation_meta_with_lineage( + "running", + Some(&child_connection_id), + Some(source.child_conversation_id), + None, + None, + None, + Some(&task_preview), + Some(&call_id), + Some(&source.task_id), + None, + identity.as_ref(), + ), + ) + .await; + self.emit_started_if_real( + &req.parent_connection_id, + &req.parent_tool_use_id, + &child_connection_id, + source.child_conversation_id, + source.agent_type, + identity.as_ref(), + &task_preview, + &call_id, + ) + .await; + + // --- Register running, or resolve a terminal that beat us -------------- + enum Disposition { + ChildTerminal(DelegationOutcome), + ParentCanceled, + Running, + } + let setup_duration_ms = started_at.elapsed().as_millis() as u64; + let disposition = { + let mut inner = self.pending.inner.lock().await; + // Do NOT touch `completed`: the source's terminal entry belongs to + // the source and stays queryable under its own id. + let child_terminal: Option<(u64, DelegationOutcome)> = + if let Some((stamp, outcome)) = inner.take_early_complete(&call_id) { + Some((stamp, outcome)) + } else { + inner + .take_early_cancel(&child_connection_id) + .map(|(stamp, reason)| { + ( + stamp, + DelegationOutcome::from_err( + DelegationError::Canceled { reason }, + Some(source.child_conversation_id), + ), + ) + }) + }; + let parent_canceled_at = inner.inflight_canceled_at(inflight_id); + inner.unreserve(&call_id, &child_connection_id); + let record = |inner: &mut PendingInner, outcome: &DelegationOutcome| { + inner.insert_completed( + &call_id, + build_completed( + &req.parent_connection_id, + source.child_conversation_id, + source.agent_type, + identity.as_ref(), + setup_duration_ms, + outcome, + ), + ); + }; + match (child_terminal, parent_canceled_at) { (Some((child_stamp, outcome)), Some(cancel_stamp)) => { inner.deregister_inflight(inflight_id); if child_stamp < cancel_stamp { @@ -2932,39 +3748,33 @@ impl DelegationBroker { } else { record( &mut inner, - &canceled_outcome(child_conversation_id, "parent canceled"), + &canceled_outcome(source.child_conversation_id, "parent canceled"), ); Disposition::ParentCanceled } } - // Only a child terminal fired. (Some((_, outcome)), None) => { inner.deregister_inflight(inflight_id); record(&mut inner, &outcome); Disposition::ChildTerminal(outcome) } - // Only a parent cancel fired. (None, Some(_)) => { inner.deregister_inflight(inflight_id); record( &mut inner, - &canceled_outcome(child_conversation_id, "parent canceled"), + &canceled_outcome(source.child_conversation_id, "parent canceled"), ); Disposition::ParentCanceled } - // Nothing beat us — register the running task for a future - // resolver, deregistering the in-flight record adjacent to the - // insert with no `.await` between (so a parent cancel serialized - // AFTER us finds it in `running` and drains it). (None, None) => { inner.running.insert( call_id.clone(), RunningTask { child_connection_id: child_connection_id.clone(), - child_conversation_id, + child_conversation_id: source.child_conversation_id, parent_connection_id: req.parent_connection_id.clone(), parent_tool_use_id: req.parent_tool_use_id.clone(), - agent_type: req.agent_type, + agent_type: source.agent_type, assistant: identity.clone(), task_preview: task_preview.clone(), task_id: call_id.clone(), @@ -2980,17 +3790,12 @@ impl DelegationBroker { }; match disposition { - // A child terminal beat registration. Finalize (terminal meta + - // DelegationCompleted event + child teardown) and return the - // terminal report directly. The completed entry was recorded under - // the disposition lock above; wake any long-poll waiter. Disposition::ChildTerminal(outcome) => { - self.finalize_delegation( + self.finish_continue_round( &req.parent_connection_id, &req.parent_tool_use_id, &child_connection_id, - child_conversation_id, - req.agent_type, + &source, identity.as_ref(), setup_duration_ms, &outcome, @@ -2999,139 +3804,189 @@ impl DelegationBroker { ) .await; self.result_notify.notify_waiters(); - return report_from_outcome( + let mut report = report_from_outcome( Some(call_id), - Some(req.agent_type), + Some(source.agent_type), &outcome, Some(setup_duration_ms), ); + report.source_task_id = Some(source.task_id.clone()); + return report.with_assistant(identity.as_ref()); } - // A parent cancel reached this delegation mid-setup — after the - // prompt was sent, before we registered. Tear the child down - // ourselves (cancel + disconnect, since a turn is in flight) and - // return a canceled report. The canceled result was recorded above. Disposition::ParentCanceled => { - self.write_meta_if_real( - &req.parent_connection_id, - &req.parent_tool_use_id, - build_delegation_meta( - "failed", - Some(&child_connection_id), - Some(child_conversation_id), - Some("canceled"), - None, - Some(setup_duration_ms), - Some(&task_preview), - Some(&call_id), - identity.as_ref(), - ), - ) - .await; - self.emit_completed_if_real( + let outcome = canceled_outcome(source.child_conversation_id, "parent canceled"); + self.finish_continue_round( &req.parent_connection_id, &req.parent_tool_use_id, &child_connection_id, - child_conversation_id, - req.agent_type, + &source, identity.as_ref(), - DelegationResultSummary::Err { - error_code: "canceled".to_string(), - }, + setup_duration_ms, + &outcome, + &task_preview, + &call_id, ) .await; - let _ = self.spawner.cancel(&child_connection_id).await; - let _ = self.spawner.disconnect(&child_connection_id).await; self.result_notify.notify_waiters(); - return report_from_outcome( + let mut report = report_from_outcome( Some(call_id), - Some(req.agent_type), - &canceled_outcome(child_conversation_id, "parent canceled"), + Some(source.agent_type), + &outcome, Some(setup_duration_ms), ); + report.source_task_id = Some(source.task_id.clone()); + return report.with_assistant(identity.as_ref()); } - // Registered in `running` — fall through to the second pre-cancel - // check, then return the ack. Disposition::Running => {} } - // Second pre-cancel check: a `notifications/cancelled` may have landed - // between the entry-side check and the `running` registration above. If - // so, drain the task ourselves (so a racing `cancel_by_external_handle` - // doesn't double-finalize), record the canceled result, and return a - // canceled report instead of the Running ack. + // Second pre-cancel check — same contract as start/resume. if let Some(handle) = req.external_handle.as_deref() { if self.take_pre_canceled_handle(handle).await { - // Capture the elapsed ONCE at terminalization (under the lock, - // when the running task is removed) so the completed-cache, the - // parent-card meta, and the returned report all report the same - // duration. `None` when nothing was drained. let canceled_duration_ms = { let mut inner = self.pending.inner.lock().await; if inner.running.remove(&call_id).is_some() { let outcome = - canceled_outcome(child_conversation_id, "canceled before await"); + canceled_outcome(source.child_conversation_id, "canceled before await"); let duration_ms = started_at.elapsed().as_millis() as u64; inner.insert_completed( &call_id, build_completed( &req.parent_connection_id, - child_conversation_id, - req.agent_type, + source.child_conversation_id, + source.agent_type, identity.as_ref(), duration_ms, &outcome, ), ); - Some(duration_ms) + Some((duration_ms, outcome)) } else { None } }; - if let Some(duration_ms) = canceled_duration_ms { - self.write_meta_if_real( - &req.parent_connection_id, - &req.parent_tool_use_id, - build_delegation_meta( - "failed", - Some(&child_connection_id), - Some(child_conversation_id), - Some("canceled"), - None, - Some(duration_ms), - Some(&task_preview), - Some(&call_id), - identity.as_ref(), - ), - ) - .await; - self.emit_completed_if_real( + if let Some((duration_ms, outcome)) = canceled_duration_ms { + self.finish_continue_round( &req.parent_connection_id, &req.parent_tool_use_id, &child_connection_id, - child_conversation_id, - req.agent_type, + &source, identity.as_ref(), - DelegationResultSummary::Err { - error_code: "canceled".to_string(), - }, + duration_ms, + &outcome, + &task_preview, + &call_id, ) .await; - let _ = self.spawner.cancel(&child_connection_id).await; - let _ = self.spawner.disconnect(&child_connection_id).await; self.result_notify.notify_waiters(); - return report_from_outcome( + let mut report = report_from_outcome( Some(call_id), - Some(req.agent_type), - &canceled_outcome(child_conversation_id, "canceled before await"), + Some(source.agent_type), + &outcome, Some(duration_ms), ); + report.source_task_id = Some(source.task_id.clone()); + return report.with_assistant(identity.as_ref()); } } } - // Registered and running in the background — return the ack. The child - // resolves later via the lifecycle → `complete_call` (or a cancel path). - running_ack(call_id, child_conversation_id, req.agent_type) + let mut ack = running_ack(call_id, source.child_conversation_id, source.agent_type); + ack.source_task_id = Some(source.task_id.clone()); + ack.with_assistant(identity.as_ref()) + } + + /// The chain's tail: the task reached by following successor links from + /// `source`. `continue_delegation` may only append after it. + async fn latest_task_of_chain(&self, source: &DelegationTaskRecord) -> Option { + let mut current = source.task_id.clone(); + // Bounded: a chain grows one link per continue and a cycle is + // impossible (each successor is minted fresh, sources are unique), but + // a corrupt row must not spin forever. + for _ in 0..MAX_CHAIN_LEN { + match self.ledger.successor_of(¤t).await { + Some(next) => current = next.task_id, + None => return Some(current), + } + } + Some(current) + } + + /// Release a continue round's reserved successor slot when nothing ran. + async fn discard_continue_round(&self, task_id: &str) { + self.ledger.discard(task_id).await; + } + + /// Terminal write for a resolved continue round: ledger first (the + /// authoritative record), then the round's own meta/event, then teardown of + /// only this round's child connection. The source round is never touched. + #[allow(clippy::too_many_arguments)] + async fn finish_continue_round( + &self, + parent_connection_id: &str, + parent_tool_use_id: &str, + child_connection_id: &str, + source: &DelegationTaskRecord, + assistant: Option<&AssistantRef>, + duration_ms: u64, + outcome: &DelegationOutcome, + task_preview: &str, + task_id: &str, + ) { + self.finalize_delegation( + parent_connection_id, + parent_tool_use_id, + child_connection_id, + source.child_conversation_id, + source.agent_type, + assistant, + duration_ms, + outcome, + task_preview, + task_id, + ) + .await; + } + + /// Claim the parent-side ACP id of an identity-less `continue_delegation` + /// call: the lifecycle registered its candidate off the "MCP: tool" + /// announcement, and only this round-trip can name it. Bounded poll, same + /// rationale as the status/cancel rename (sub-5 ms race in practice). + async fn claim_continue_tool_call( + &self, + parent_connection_id: &str, + source_task_id: &str, + task: &str, + ) -> Option { + if !self + .has_seen_identityless_tool_calls(parent_connection_id) + .await + { + return None; + } + for attempt in 0..IDENTITYLESS_RENAME_POLL_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(CLAIM_POLL_INTERVAL).await; + } + if let Some(id) = self.take_identityless_tool_call(parent_connection_id).await { + // Restore the call's identity with the arguments it actually + // carried, so the live card renders the continue (source id + + // new task) while the session is still loading. + self.meta_writer + .write_tool_call_identity( + parent_connection_id, + &id, + crate::acp::delegation::CONTINUE_TOOL_REWRITE_TITLE, + serde_json::json!({ + "task_id": source_task_id, + "task": task, + }), + ) + .await; + return Some(id); + } + } + None } /// Called by the child-session lifecycle subscriber on `TurnComplete` @@ -3150,6 +4005,31 @@ impl DelegationBroker { /// loop emits `TurnComplete` independently, so a completion CAN beat it. When /// the `call_id` is no longer reserved the call was already resolved by /// another terminal path, so the buffer is skipped (silent no-op). + /// Resolve whichever round is live on `child_connection_id`. + /// + /// A continued round shares the child conversation row with its source, and + /// that row's `delegation_call_id` stays pointed at the FIRST round. Routing + /// `TurnComplete` through the row would settle the finished source and leave + /// the live successor running forever. The connection id is the round. + /// + /// `None` while nothing is reserved or running on that connection, so the + /// caller can fall back to the row for a pre-ledger delegation. + pub async fn call_id_for_child_connection(&self, child_connection_id: &str) -> Option { + let inner = self.pending.inner.lock().await; + inner + .running + .iter() + .find(|(_, task)| task.child_connection_id == child_connection_id) + .map(|(call_id, _)| call_id.clone()) + .or_else(|| { + inner + .setups + .iter() + .find(|(_, child)| child.as_str() == child_connection_id) + .map(|(call_id, _)| call_id.clone()) + }) + } + pub async fn complete_call(&self, call_id: &str, outcome: DelegationOutcome) { // What the child got through, for the finished report. Read with the // pending lock released — the child's `SessionState` lock never nests @@ -3213,6 +4093,31 @@ impl DelegationBroker { } } + /// Project a terminal outcome onto its round's ledger row. Only the round + /// that finished is written: a successor never rewrites its source, and a + /// late duplicate is a no-op (see the service's `finish`). + async fn record_ledger_terminal( + &self, + task_id: &str, + duration_ms: u64, + outcome: &DelegationOutcome, + ) { + let (status, text, error_code, message) = terminal_fields(outcome); + self.ledger + .finish( + task_id, + TerminalUpdate { + status, + error_code, + error_message: message, + duration_ms: Some(duration_ms), + result_preview: text.map(|t| truncate_on_char_boundary(&t, STATUS_PREVIEW_CAP)), + external_session_id: None, + }, + ) + .await; + } + /// 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 @@ -3238,6 +4143,12 @@ impl DelegationBroker { task_preview: &str, task_id: &str, ) { + // The ledger is the authoritative record of the round's outcome; the + // meta/event below only mirror it onto the parent's card. `finish` + // updates a `running` row only, so a duplicate terminal (two paths + // racing to the same task) can never overwrite a real result. + self.record_ledger_terminal(task_id, duration_ms, outcome) + .await; let meta = match outcome { DelegationOutcome::Ok(ok) => build_delegation_meta( "completed", @@ -3444,7 +4355,8 @@ impl DelegationBroker { for (task, duration_ms) in drained { // The child already disconnected/errored — disconnect-only teardown // (no spawner `cancel`, there's no live turn to interrupt). - self.teardown_canceled_child(&task, duration_ms, false).await; + self.teardown_canceled_child(&task, duration_ms, false) + .await; } self.result_notify.notify_waiters(); } @@ -3584,6 +4496,15 @@ impl DelegationBroker { duration_ms: u64, cancel_turn: bool, ) { + // Every cancel path funnels here, so this is where a canceled round + // becomes terminal in the ledger. The source of a continue round is + // never touched — each round owns its own row. + self.record_ledger_terminal( + &task.task_id, + duration_ms, + &canceled_outcome(task.child_conversation_id, "canceled"), + ) + .await; self.write_meta_if_real( &task.parent_connection_id, &task.parent_tool_use_id, @@ -4129,50 +5050,62 @@ impl DelegationBroker { if inner.resuming.insert(req.task_id.clone()) { MemGate::Proceed { claimed: true } } else { - MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - status, - Some(child_conversation_id), - Some(agent_type), - "a resume of this task is already in progress.", - ).with_assistant(assistant.as_ref()))) + MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + status, + Some(child_conversation_id), + Some(agent_type), + "a resume of this task is already in progress.", + ) + .with_assistant(assistant.as_ref()), + )) } } - TaskStatus::Completed => MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - status, - Some(child_conversation_id), - Some(agent_type), - "the task already completed — resume only applies to a \ + TaskStatus::Completed => MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + status, + Some(child_conversation_id), + Some(agent_type), + "the task already completed — resume only applies to a \ canceled or interrupted task. Start a new \ delegate_to_agent call for follow-up work.", - ).with_assistant(assistant.as_ref()))), - _ => MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - status, - Some(child_conversation_id), - Some(agent_type), - "the task ended in failure rather than being \ + ) + .with_assistant(assistant.as_ref()), + )), + _ => MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + status, + Some(child_conversation_id), + Some(agent_type), + "the task ended in failure rather than being \ interrupted — resume only applies to a canceled or \ interrupted task. Start a new delegate_to_agent call \ to retry it.", - ).with_assistant(assistant.as_ref()))), + ) + .with_assistant(assistant.as_ref()), + )), } } } else if let Some(r) = inner.running.get(&req.task_id) { if r.parent_connection_id != req.parent_connection_id { MemGate::Refuse(Box::new(unknown_report(&req.task_id))) } else { - MemGate::Refuse(Box::new(not_resumable_report( - &req.task_id, - TaskStatus::Running, - Some(r.child_conversation_id), - Some(r.agent_type), - "the task is still running — resume only applies to a \ + MemGate::Refuse(Box::new( + not_resumable_report( + &req.task_id, + TaskStatus::Running, + Some(r.child_conversation_id), + Some(r.agent_type), + "the task is still running — resume only applies to a \ canceled or interrupted task. Poll it with \ get_delegation_status (or stop it with cancel_delegation) \ instead.", - ).with_assistant(r.assistant.as_ref()))) + ) + .with_assistant(r.assistant.as_ref()), + )) } } else { let claimed = inner.resuming.insert(req.task_id.clone()); @@ -4257,6 +5190,16 @@ impl DelegationBroker { self.drop_inflight(inflight_id).await; return unknown_report(&req.task_id); }; + if self.ledger.successor_of(&req.task_id).await.is_some() { + self.drop_inflight(inflight_id).await; + return not_resumable_report( + &req.task_id, + ctx.status, + Some(ctx.child_conversation_id), + Some(ctx.agent_type), + "this task already has a successor and cannot be resumed.", + ); + } // Every report from here on names the assistant the child ran as (an // `Unknown` one excepted — see `with_assistant`). let identity = ctx.assistant.identity(); @@ -4450,9 +5393,12 @@ impl DelegationBroker { // on the row so the parent's original delegation card re-binds; a row // without one gets a synthetic id, which `write_meta_if_real` / // `emit_*_if_real` skip. - let parent_tool_use_id = ctx - .parent_tool_use_id - .clone() + let parent_tool_use_id = self + .ledger + .find(&req.task_id) + .await + .and_then(|task| task.parent_tool_use_id) + .or_else(|| ctx.parent_tool_use_id.clone()) .unwrap_or_else(|| format!("delegation-{}", uuid::Uuid::new_v4())); // Reserve BEFORE sending — identical race contract to @@ -4714,10 +5660,8 @@ impl DelegationBroker { let canceled_duration_ms = { let mut inner = self.pending.inner.lock().await; if inner.running.remove(&call_id).is_some() { - let outcome = canceled_outcome( - ctx.child_conversation_id, - "canceled before await", - ); + let outcome = + canceled_outcome(ctx.child_conversation_id, "canceled before await"); let duration_ms = started_at.elapsed().as_millis() as u64; inner.insert_completed( &call_id, @@ -5452,7 +6396,10 @@ mod tests { let first = broker .get_task_status("parent-conn", Some(1), &task_id, StatusWait::Infinite) .await; - assert_eq!(first.blocked_on.map(|b| b.request_id).as_deref(), Some("req-1")); + assert_eq!( + first.blocked_on.map(|b| b.request_id).as_deref(), + Some("req-1") + ); // Same prompt, still unanswered: a bounded wait now runs to its deadline // rather than returning immediately. @@ -5478,7 +6425,10 @@ mod tests { started.elapsed() < Duration::from_millis(2_000), "a NEW blocking prompt must wake the wait" ); - assert_eq!(third.blocked_on.map(|b| b.request_id).as_deref(), Some("req-2")); + assert_eq!( + third.blocked_on.map(|b| b.request_id).as_deref(), + Some("req-2") + ); } /// The delivery-failure valve: marking a block reported is not proof the @@ -7652,7 +8602,9 @@ mod tests { "parent-conn", "tc-keyed".into(), Some(DelegationMatchKey { - target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::ClaudeCode), + target: crate::acp::delegation::types::DelegationTarget::Agent( + AgentType::ClaudeCode, + ), task: "do x".into(), working_dir: None, }), @@ -9551,7 +10503,10 @@ mod tests { // The event labels the card even when the parent tool call's // raw_input never carried the arguments (identity-less hosts). assert_eq!(task_preview, "do x"); - assert!(!task_id.is_empty(), "broker-minted task id must ride the event"); + assert!( + !task_id.is_empty(), + "broker-minted task id must ride the event" + ); } other => panic!("expected DelegationStarted, got {other:?}"), } @@ -9901,11 +10856,9 @@ mod tests { let mock = Arc::new(MockSpawner::new()); let lookup = Arc::new(MockResumeLookup::default()); *lookup.ctx.lock().await = ctx; - let broker = DelegationBroker::new( - mock.clone() as Arc, - shallow_lookup(), - ) - .with_status_lookup(lookup.clone() as Arc); + let broker = + DelegationBroker::new(mock.clone() as Arc, shallow_lookup()) + .with_status_lookup(lookup.clone() as Arc); enable_delegation(&broker).await; (mock, lookup, broker) } @@ -9934,7 +10887,8 @@ mod tests { #[tokio::test] async fn resume_after_interruption_runs_under_same_id_then_completes() { let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let mut req = resume_request("task-1"); @@ -9943,7 +10897,10 @@ mod tests { assert_eq!(ack.status, TaskStatus::Running); assert_eq!(ack.task_id.as_deref(), Some("task-1")); assert_eq!(ack.child_conversation_id, Some(42)); - assert!(ack.message.unwrap().starts_with("Delegation resumed. task_id=task-1")); + assert!(ack + .message + .unwrap() + .starts_with("Delegation resumed. task_id=task-1")); // The spawn resumed the recorded agent session in the recorded dir. let spawns = mock.resume_spawn_args.lock().await; @@ -10006,7 +10963,8 @@ mod tests { assert_eq!(canceled.status, TaskStatus::Canceled); assert_eq!(broker.completed_count().await, 1); - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request(&task_id)).await; assert_eq!(ack.status, TaskStatus::Running); @@ -10124,7 +11082,8 @@ mod tests { // No live connection → the stranded row resumes. let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Running))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request("task-1")).await; assert_eq!(ack.status, TaskStatus::Running); @@ -10226,7 +11185,8 @@ mod tests { #[tokio::test] async fn resume_send_failure_disconnects_and_unreserves() { let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Err(SpawnerError::Send("stdin closed".into()))) .await; let report = broker.resume_delegation(resume_request("task-1")).await; @@ -10284,11 +11244,18 @@ mod tests { assert_eq!(report.error_code.as_deref(), Some(NOT_RESUMABLE_CODE)); assert!(report.message.unwrap().contains("already in progress")); assert!(mock.resume_spawn_args.lock().await.is_empty()); - assert!(broker.pending.inner.lock().await.resuming.contains("task-1")); + assert!(broker + .pending + .inner + .lock() + .await + .resuming + .contains("task-1")); // A successful resume releases its own claim. let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request("task-1")).await; assert_eq!(ack.status, TaskStatus::Running); @@ -10323,7 +11290,8 @@ mod tests { #[tokio::test] async fn resumed_task_cancels_like_any_running_task() { let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request("task-1")).await; assert_eq!(ack.status, TaskStatus::Running); @@ -10420,7 +11388,10 @@ mod tests { assert_eq!(send.task, "do x", "the task goes out untouched"); let brief = send.brief.expect("the brief rides the first prompt"); assert!(brief.contains(r#"{"name":"Reviewer","instructions":"Cite line numbers."}"#)); - let link = send.link.assistant.expect("the child row learns its assistant"); + let link = send + .link + .assistant + .expect("the child row learns its assistant"); assert_eq!(link.id, 3); assert_eq!(link.snapshot, reviewer().run_snapshot()); @@ -10433,7 +11404,12 @@ mod tests { // Status keeps naming it; the terminal report and event do too. let report = broker - .get_task_status("parent-conn", Some(1), ack.task_id.as_deref().unwrap(), StatusWait::Immediate) + .get_task_status( + "parent-conn", + Some(1), + ack.task_id.as_deref().unwrap(), + StatusWait::Immediate, + ) .await; assert_eq!(report.assistant_id, Some(3)); broker @@ -10450,11 +11426,19 @@ mod tests { ) .await; let done = broker - .get_task_status("parent-conn", Some(1), ack.task_id.as_deref().unwrap(), StatusWait::Immediate) + .get_task_status( + "parent-conn", + Some(1), + ack.task_id.as_deref().unwrap(), + StatusWait::Immediate, + ) .await; assert_eq!(done.status, TaskStatus::Completed); assert_eq!(done.assistant_name.as_deref(), Some("Reviewer")); - assert_eq!(emitter.snapshot().await[0].assistant, Some(reviewer().identity())); + assert_eq!( + emitter.snapshot().await[0].assistant, + Some(reviewer().identity()) + ); let writes = writer.snapshot().await; let terminal_meta = &writes.last().unwrap().meta["codeg.delegation"]; assert_eq!(terminal_meta["status"], "completed"); @@ -10545,7 +11529,8 @@ mod tests { ..DelegationConfig::default() }) .await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-r"))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-r"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request("task-1")).await; @@ -10594,7 +11579,10 @@ mod tests { assert!(report.message.unwrap().contains("could not be read back")); assert_eq!(report.assistant_id, Some(3)); assert_eq!(report.assistant_name.as_deref(), Some("Reviewer")); - assert!(mock.resume_spawn_args.lock().await.is_empty(), "nothing relaunched"); + assert!( + mock.resume_spawn_args.lock().await.is_empty(), + "nothing relaunched" + ); } /// A resume refused before its gates (delegation switched off) still says @@ -10609,7 +11597,9 @@ mod tests { enable_delegation(&broker).await; let ack = broker.start_delegation(assistant_request(1, "pt-e")).await; let task_id = ack.task_id.unwrap(); - broker.cancel_task_by_id("parent-conn", Some(1), &task_id).await; + broker + .cancel_task_by_id("parent-conn", Some(1), &task_id) + .await; broker .set_config(DelegationConfig { enabled: false, @@ -10626,7 +11616,10 @@ mod tests { foreign.parent_connection_id = "someone-else".into(); let refused = broker.resume_delegation(foreign).await; assert_eq!(refused.agent_type, None); - assert_eq!(refused.assistant_id, None, "no identity for a foreign caller"); + assert_eq!( + refused.assistant_id, None, + "no identity for a foreign caller" + ); } /// The DB lookup decides "assistant child" off the row's `assistant_id`, @@ -10680,9 +11673,15 @@ mod tests { }), }; - let ctx = lookup.find_resume_context_by_call_id("call-a").await.unwrap(); + let ctx = lookup + .find_resume_context_by_call_id("call-a") + .await + .unwrap(); assert_eq!(ctx.assistant, ResumeAssistant::Snapshot(reviewer())); - let ctx = lookup.find_resume_context_by_call_id("call-p").await.unwrap(); + let ctx = lookup + .find_resume_context_by_call_id("call-p") + .await + .unwrap(); assert_eq!(ctx.assistant, ResumeAssistant::None); assert_eq!(plain.assistant_id, None); @@ -10697,7 +11696,10 @@ mod tests { )) .await .unwrap(); - let ctx = lookup.find_resume_context_by_call_id("call-a").await.unwrap(); + let ctx = lookup + .find_resume_context_by_call_id("call-a") + .await + .unwrap(); assert!( matches!(ctx.assistant, ResumeAssistant::Unrecoverable { id: 3, .. }), "{broken}: {:?}", @@ -10708,9 +11710,9 @@ mod tests { #[tokio::test] async fn resuming_an_agent_child_keeps_the_per_agent_defaults_and_no_brief() { - let (mock, _lookup, broker) = - resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; - mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-r2"))).await; + let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; + mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-r2"))) + .await; mock.queue_resume_send(Ok(())).await; let ack = broker.resume_delegation(resume_request("task-1")).await; assert_eq!(ack.assistant_id, None); diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index a0160b448d..9388e7eace 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -317,7 +317,8 @@ impl CompanionFeatures { "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" - | "resume_delegation" => self.delegation, + | "resume_delegation" + | "continue_delegation" => self.delegation, _ => false, } } @@ -1051,6 +1052,25 @@ async fn build_tools_call_spawn( ) .await } + "continue_delegation" => { + let task_id = arguments.get("task_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let task = arguments.get("task").and_then(|v| v.as_str()).filter(|s| !s.trim().is_empty()); + let (Some(task_id), Some(task)) = (task_id, task) else { + return LineAction::Respond(err(id, -32602, "continue_delegation requires non-empty task_id and task")); + }; + let req = crate::acp::delegation::transport::BrokerContinueTaskRequest { + token: ctx.token.clone(), + task_id: task_id.to_string(), + task: task.to_string(), + external_handle: Some(uuid::Uuid::new_v4().to_string()), + }; + let external_handle = req.external_handle.clone(); + let socket = ctx.socket_path.clone(); + let round_trip = Box::pin(async move { + crate::acp::delegation::transport::client_continue_task_round_trip(&socket, &req).await + }); + register_and_spawn(inflight, id, external_handle, round_trip, render_task_report).await + } "check_user_feedback" => { let req = BrokerFeedbackRequest { token: ctx.token.clone(), diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 2645604487..5e1bb9f157 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -33,7 +33,7 @@ use crate::acp::delegation::transport::{ BrokerComputerAppsRequest, BrokerComputerCaptureRequest, BrokerComputerLaunchRequest, BrokerComputerSnapshotRequest, BrokerComputerVerifyRequest, BrokerComputerWindowsRequest, BrokerCommitFeedbackRequest, - BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerMessage, BrokerRequest, + BrokerContinueTaskRequest, BrokerFeedbackRequest, BrokerListAssistantsRequest, BrokerMessage, BrokerRequest, BrokerCreateAutomationRequest, BrokerCreateWorkTaskRequest, BrokerResponse, BrokerResultSchemaRequest, BrokerResumeTaskRequest, BrokerSessionRequest, BrokerStatusRequest, BrokerSubmitResultRequest, BrokerTaskCompleteRequest, @@ -455,6 +455,7 @@ impl DelegationListener { outcome: self.process_list_assistants(req).await, }, BrokerMessage::Call(req) => report_response(self.process(req).await)?, + BrokerMessage::ContinueTask(req) => report_response(self.process_continue_task(req).await)?, BrokerMessage::Status(req) => { // A status long-poll — especially `wait_ms = 0` (block until // terminal) — can park for the whole lifetime of the child. @@ -847,6 +848,29 @@ impl DelegationListener { .await } + async fn process_continue_task(&self, req: BrokerContinueTaskRequest) -> DelegationTaskReport { + let Some(entry) = self.tokens.lookup(&req.token).await else { + return unknown_report(&req.task_id); + }; + let Some(parent_conversation_id) = self + .parent_lookup + .current_conversation_id(&entry.parent_connection_id) + .await + else { + return cancel("parent has no active conversation"); + }; + self.broker + .continue_delegation(crate::acp::delegation::types::ContinueDelegationRequest { + parent_connection_id: entry.parent_connection_id, + parent_conversation_id, + parent_tool_use_id: String::new(), + task_id: req.task_id, + task: req.task, + external_handle: req.external_handle, + }) + .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. @@ -1582,7 +1606,9 @@ fn report_canceled(message: &str) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, - } + + source_task_id: None, + successor_task_id: None, } } /// A `Failed` report carrying a wire-stable `error_code` for a bad argument. @@ -1600,7 +1626,9 @@ fn report_failed(error_code: &str, message: &str) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, - } + + source_task_id: None, + successor_task_id: None, } } /// An `Unknown` report — used when a status/cancel request fails the token @@ -1619,7 +1647,9 @@ fn unknown_report(task_id: &str) -> DelegationTaskReport { assistant_id: None, assistant_name: None, progress: None, - } + + source_task_id: None, + successor_task_id: None, } } fn cancel(message: &str) -> DelegationTaskReport { diff --git a/src-tauri/src/acp/delegation/meta_writer.rs b/src-tauri/src/acp/delegation/meta_writer.rs index 0512913f9c..f4cef7f48e 100644 --- a/src-tauri/src/acp/delegation/meta_writer.rs +++ b/src-tauri/src/acp/delegation/meta_writer.rs @@ -289,6 +289,39 @@ pub fn build_delegation_meta( task_preview: Option<&str>, task_id: Option<&str>, assistant: Option<&crate::acp::delegation::types::AssistantRef>, +) -> serde_json::Value { + build_delegation_meta_with_lineage( + status, + child_connection_id, + child_conversation_id, + error_code, + text_preview, + duration_ms, + task_preview, + task_id, + None, + None, + assistant, + ) +} + +/// [`build_delegation_meta`] plus the round's position in its chain: the task +/// this round continues (`source_task_id`) and the round that continues it +/// (`successor_task_id`). A continue card draws both; the first round of a +/// session carries neither. +#[allow(clippy::too_many_arguments)] +pub fn build_delegation_meta_with_lineage( + status: &str, + child_connection_id: Option<&str>, + child_conversation_id: Option, + error_code: Option<&str>, + text_preview: Option<&str>, + duration_ms: Option, + task_preview: Option<&str>, + task_id: Option<&str>, + source_task_id: Option<&str>, + successor_task_id: Option<&str>, + assistant: Option<&crate::acp::delegation::types::AssistantRef>, ) -> serde_json::Value { let mut inner = serde_json::Map::new(); inner.insert( @@ -355,6 +388,22 @@ pub fn build_delegation_meta( serde_json::Value::String(id.to_string()), ); } + // Chain position, on every write for the same replace-wholesale reason. + // Only a continue round names a source; only a refused continue names a + // successor (and that report never writes meta at all), so in practice a + // live card carries `source_task_id` and leaves the successor empty. + if let Some(id) = source_task_id { + inner.insert( + "source_task_id".to_string(), + serde_json::Value::String(id.to_string()), + ); + } + if let Some(id) = successor_task_id { + inner.insert( + "successor_task_id".to_string(), + serde_json::Value::String(id.to_string()), + ); + } // The assistant the task runs as, on EVERY write for the same // replace-wholesale reason as the task fields above: the card of a host // that announced the call identity-less (Cursor) has nothing else to draw diff --git a/src-tauri/src/acp/delegation/mod.rs b/src-tauri/src/acp/delegation/mod.rs index d12ac3ea7e..cac8b536b3 100644 --- a/src-tauri/src/acp/delegation/mod.rs +++ b/src-tauri/src/acp/delegation/mod.rs @@ -64,6 +64,7 @@ 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 RESUME_TOOL_REWRITE_TITLE: &str = "codeg-mcp__resume_delegation"; +pub const CONTINUE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__continue_delegation"; /// Whether a host's name for a tool is codeg-mcp's own `tool`, in whatever /// spelling the host composed it — `mcp__codeg-mcp__ask_user_question` from diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs index 739a63b588..1692a603b9 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -50,6 +50,11 @@ pub enum SpawnerError { Disconnect(String), #[error("cancel failed: {0}")] Cancel(String), + /// A strict `session/load` refused: the source round's session could not + /// be reopened. `code` is the classified reason (`resource_not_found`, + /// `session_unavailable`, `session_archived`, `session_busy`, …). + #[error("session load failed ({code}): {message}")] + SessionLoad { code: String, message: String }, } /// Result of [`ConnectionSpawner::spawn_for_resume`]: the connection now @@ -156,6 +161,43 @@ pub trait ConnectionSpawner: Send + Sync { preferred_config_values: BTreeMap, ) -> Result; + /// Re-spawn a connection for a `continue_delegation` round: load the ACP + /// session of the round being followed, STRICTLY. Unlike + /// [`Self::spawn_for_resume`] a failed load must never fall back to + /// `session/new` — an empty session answering under the source's identity + /// would silently lose every bit of context the round exists to continue. + /// Reports the failure as [`SpawnerError::SessionLoad`]. + /// + /// `reused` carries the same meaning as for resume: the manager handed + /// back a connection someone else is driving, which the broker must not + /// disconnect — and must not send a prompt through. + async fn spawn_for_continue( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + external_session_id: &str, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result; + + /// Send a continue round's task into the child's EXISTING conversation row + /// (`child_conversation_id`, in `folder_id`), on the connection that + /// loaded its session. + /// + /// The prompt is the user's FULL new task — never the resume + /// "continue the ORIGINAL task" frame — because a continue round carries + /// genuinely new work. `brief` rides it as an internal suffix exactly as + /// it rides every other delegation prompt. + async fn send_continue_prompt( + &self, + conn_id: &str, + task: String, + folder_id: i32, + child_conversation_id: i32, + brief: Option, + ) -> Result<(), SpawnerError>; + /// Send the resume continuation prompt into the child's EXISTING /// conversation row (`child_conversation_id`, in `folder_id`). Unlike /// [`Self::send_prompt_linked_for_delegation`] this adopts the row instead @@ -220,6 +262,14 @@ pub mod mock { pub resume_send_results: Mutex>>, pub resume_spawn_args: Mutex>, pub resume_send_args: Mutex>, + /// Queued results for `spawn_for_continue` / `send_continue_prompt`; + /// recorded calls land in `continue_spawn_args` / `continue_send_args`. + /// Kept apart from the resume queues so a test staging both can't + /// cross-consume. + pub continue_spawn_results: Mutex>>, + pub continue_send_results: Mutex>>, + pub continue_spawn_args: Mutex>, + pub continue_send_args: Mutex>, /// Conversation ids `has_live_connection_for_conversation` answers /// `true` for. Empty (default) = no live child connections. pub live_conversations: Mutex>, @@ -264,6 +314,25 @@ pub mod mock { pub preferred_config_values: BTreeMap, } + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ContinueSpawnCallArgs { + pub parent_connection_id: String, + pub agent_type: AgentType, + pub working_dir: Option, + pub external_session_id: String, + pub preferred_mode_id: Option, + pub preferred_config_values: BTreeMap, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ContinueSendCallArgs { + pub conn_id: String, + pub task: String, + pub folder_id: i32, + pub child_conversation_id: i32, + pub brief: Option, + } + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResumeSendCallArgs { pub conn_id: String, @@ -304,6 +373,14 @@ pub mod mock { self.resume_send_results.lock().await.push_back(r); } + pub async fn queue_continue_spawn(&self, r: Result) { + self.continue_spawn_results.lock().await.push_back(r); + } + + pub async fn queue_continue_send(&self, r: Result<(), SpawnerError>) { + self.continue_send_results.lock().await.push_back(r); + } + /// Mark `conversation_id` as having a live connection, so /// `has_live_connection_for_conversation` answers `true` for it. pub async fn mark_conversation_live(&self, conversation_id: i32) { @@ -405,6 +482,66 @@ pub mod mock { }) } + async fn spawn_for_continue( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + external_session_id: &str, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.continue_spawn_args + .lock() + .await + .push(ContinueSpawnCallArgs { + parent_connection_id: parent_connection_id.to_string(), + agent_type, + working_dir, + external_session_id: external_session_id.to_string(), + preferred_mode_id, + preferred_config_values, + }); + self.continue_spawn_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| { + Err(SpawnerError::Spawn( + "no queued continue spawn result".into(), + )) + }) + } + + async fn send_continue_prompt( + &self, + conn_id: &str, + task: String, + folder_id: i32, + child_conversation_id: i32, + brief: Option, + ) -> Result<(), SpawnerError> { + self.continue_send_args + .lock() + .await + .push(ContinueSendCallArgs { + conn_id: conn_id.to_string(), + task, + folder_id, + child_conversation_id, + brief, + }); + self.continue_send_results + .lock() + .await + .pop_front() + .unwrap_or_else(|| { + Err(SpawnerError::Send( + "no queued continue send result".into(), + )) + }) + } + async fn send_resume_prompt( &self, conn_id: &str, diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index e0271ee89a..fa6f81bed5 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -1,7 +1,7 @@ [ { "name": "delegate_to_agent", - "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", + "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. 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. For follow-up work, a changed direction, or rework on an existing result, you MUST use continue_delegation with the returned task_id; do not call delegate_to_agent again.", "inputSchema": { "type": "object", "required": [ @@ -45,6 +45,28 @@ } } }, + { + "name": "continue_delegation", + "description": "Start a new round of work on the latest delegated task, reusing its existing child session and context. This creates a new task_id; it does not resume or overwrite the source task.", + "inputSchema": { + "type": "object", + "required": [ + "task_id", + "task" + ], + "properties": { + "task_id": { + "type": "string", + "description": "The source task_id returned by delegate_to_agent or continue_delegation." + }, + "task": { + "type": "string", + "minLength": 1, + "description": "The complete new task for the next round." + } + } + } + }, { "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. A wait ALSO returns early when a sub-agent becomes blocked on the user: that entry stays status \"running\" but carries blocked_on, meaning the sub-agent is parked on a permission / question / plan approval in Codeg and will not progress until a human answers. Tell the user about it — that is the one case where speaking up mid-wait is right — then wait again; each distinct prompt wakes the wait exactly once, so re-calling is safe and will not spin. 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, a task is blocked_on them, or something genuinely needs their input.", @@ -116,7 +138,7 @@ }, { "name": "ask_user_question", - "description": "Ask the user one or more multiple-choice questions and BLOCK until they answer. The questions render as interactive cards above the user's input box; this call returns only once the user submits their choices (or dismisses the card). Use ONLY when you are blocked on a decision that is genuinely the user's to make — one you cannot resolve from the request, the code, or sensible defaults (e.g. which approach, which format, which library, which of several files). Do NOT use it to ask 'should I proceed?' or to confirm an obvious default, and do NOT use it for open-ended questions that aren't a small set of discrete choices. The user can always pick 'Other' and type a custom answer, so don't add your own 'Other' option. If you want to recommend a choice, put it first and append ' (Recommended)' to its label. Prefer ONE call with a few related questions over many sequential calls. Once the user has answered \u2014 including picking a recommended option or telling you to proceed \u2014 act on the answer immediately. Do NOT call this tool again to confirm execution or to ask whether you should proceed.", + "description": "Ask the user one or more multiple-choice questions and BLOCK until they answer. The questions render as interactive cards above the user's input box; this call returns only once the user submits their choices (or dismisses the card). Use ONLY when you are blocked on a decision that is genuinely the user's to make — one you cannot resolve from the request, the code, or sensible defaults (e.g. which approach, which format, which library, which of several files). Do NOT use it to ask 'should I proceed?' or to confirm an obvious default, and do NOT use it for open-ended questions that aren't a small set of discrete choices. The user can always pick 'Other' and type a custom answer, so don't add your own 'Other' option. If you want to recommend a choice, put it first and append ' (Recommended)' to its label. Prefer ONE call with a few related questions over many sequential calls. Once the user has answered — including picking a recommended option or telling you to proceed — act on the answer immediately. Do NOT call this tool again to confirm execution or to ask whether you should proceed.", "inputSchema": { "type": "object", "required": [ diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 4695f23bfc..321e485b51 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -153,6 +153,15 @@ pub struct BrokerResumeTaskRequest { pub external_handle: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerContinueTaskRequest { + pub token: String, + pub task_id: String, + pub task: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_handle: Option, +} + /// Pull the pending live-feedback notes for the parent session. Backs the /// `check_user_feedback` MCP tool. Authenticated by the same per-launch /// `token`; the listener resolves the parent connection from it and scopes the @@ -441,6 +450,7 @@ pub enum BrokerMessage { Status(BrokerStatusRequest), CancelTask(BrokerCancelTaskRequest), ResumeTask(BrokerResumeTaskRequest), + ContinueTask(BrokerContinueTaskRequest), Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), @@ -607,6 +617,13 @@ pub async fn client_resume_task_round_trip( message_round_trip(socket_path, &BrokerMessage::ResumeTask(req.clone())).await } +pub async fn client_continue_task_round_trip( + socket_path: &str, + req: &BrokerContinueTaskRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::ContinueTask(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 e0212c240d..e0647cc8b4 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -14,6 +14,10 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use crate::db::error::DbError; +use crate::db::service::delegation_task_service::{ + DelegationTaskRecord, NewDelegationTask, TerminalUpdate, +}; use crate::models::AgentType; /// Per-agent defaults applied when codeg-mcp spawns a subagent on behalf of a @@ -234,12 +238,105 @@ pub struct ResumeDelegationRequest { pub external_handle: Option, } +/// Everything the broker needs to open the NEXT round of work on a delegated +/// session. `task_id` names the round being followed; everything else about +/// the run — agent, working directory, ACP session, assistant snapshot — +/// comes off that round's ledger row, never from the caller. The tool has no +/// `agent_type` / `working_dir` / `assistant_id` on purpose: a continuation +/// must resume the session that exists, not start a different one. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContinueDelegationRequest { + pub parent_connection_id: String, + pub parent_conversation_id: i32, + /// The `continue_delegation` call's own parent-side ACP tool id. Empty + /// when the host announced the MCP call without one; the listener then + /// claims the id it observed through the lifecycle, and falls back to a + /// synthetic placeholder. + #[serde(default)] + pub parent_tool_use_id: String, + /// The previous round's task id. + pub task_id: String, + /// The complete new task text. Stored verbatim (after the ledger's bounded + /// preview) and hashed with SHA-256 for idempotency — never rewritten, + /// prefixed or wrapped in the resume continuation frame. + pub task: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_handle: Option, +} + +/// Wire-stable `continue_delegation` refusal codes. They ship to the parent +/// LLM and to card badges, so they are additive only. +pub mod continue_error { + /// The source still has an active run. + pub const TASK_RUNNING: &str = "task_running"; + /// The source is a stale `running` round with no live connection. Resume + /// it first; it cannot be continued directly. + pub const SOURCE_NOT_TERMINAL: &str = "source_not_terminal"; + /// Unknown task, or one owned by another parent. Deliberately opaque. + pub const UNKNOWN: &str = "unknown"; + /// The source already has a successor created from different task text. + pub const CONTINUATION_CONFLICT: &str = "continuation_conflict"; + /// Only the latest round of a chain may open a successor. + pub const TASK_NOT_LATEST: &str = "task_not_latest"; + /// The source has no ACP session id. Continue never falls back to + /// `session/new`. + pub const SESSION_ID_MISSING: &str = "session_id_missing"; + /// The source's working directory cannot be recovered. + pub const SESSION_CWD_MISSING: &str = "session_cwd_missing"; + /// `session/load` failed. `detail` carries the classification + /// (`resource_not_found`, `session_unavailable`, `session_archived`, …). + pub const SESSION_LOAD_FAILED: &str = "session_load_failed"; + /// Another live connection already owns the session. Never preempted. + pub const SESSION_BUSY: &str = "session_busy"; + pub const SPAWN_FAILED: &str = "spawn_failed"; + pub const SEND_FAILED: &str = "send_failed"; +} + +/// The broker's persistence port for the delegation task ledger. Defined as a +/// trait so the broker's unit tests can run against an in-memory ledger and +/// production can use the SeaORM implementation without either knowing the +/// other. +#[async_trait::async_trait] +pub trait DelegationTaskLedger: Send + Sync { + async fn insert(&self, task: NewDelegationTask) -> Result<(), DbError>; + async fn find(&self, task_id: &str) -> Option; + async fn successor_of(&self, source_task_id: &str) -> Option; + async fn finish(&self, task_id: &str, update: TerminalUpdate); + /// Return a task's own row to `running` for a resume of the SAME task id. + /// Default: no-op, so test doubles that never resume keep compiling. + async fn reopen(&self, _task_id: &str) {} + /// Drop a continue round that never sent a prompt, releasing its + /// one-successor slot for a retry. Default: no-op. + async fn discard(&self, _task_id: &str) {} +} + +/// Default ledger — every lookup is a miss, every write a no-op. Used by the +/// broker's short constructors so tests that predate the ledger keep working. +#[derive(Default)] +pub struct NoopDelegationTaskLedger; + +#[async_trait::async_trait] +impl DelegationTaskLedger for NoopDelegationTaskLedger { + async fn insert(&self, _task: NewDelegationTask) -> Result<(), DbError> { + Ok(()) + } + + async fn find(&self, _task_id: &str) -> Option { + None + } + + async fn successor_of(&self, _source_task_id: &str) -> Option { + None + } + + async fn finish(&self, _task_id: &str, _update: TerminalUpdate) {} +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenUsage { pub input: u64, pub output: u64, } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegationSuccess { pub text: String, @@ -465,6 +562,16 @@ pub struct DelegationTaskReport { /// tool, or the session it ran in is no longer in memory. #[serde(default, skip_serializing_if = "Option::is_none")] pub progress: Option, + /// The round this task continues, on a `continue_delegation` result. A + /// normal continuation names the previous task id; an idempotent replay + /// names it too; a conflict leaves `task_id` empty and names BOTH the + /// requested source and the successor that already exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_task_id: Option, + /// The successor already created from this source, when a continue request + /// is refused because one exists. Never set on the new task itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub successor_task_id: Option, } impl DelegationTaskReport { @@ -530,10 +637,20 @@ mod target_tests { #[test] fn a_present_but_invalid_assistant_id_never_falls_back_to_the_agent() { - for bad in [json!(0), json!(-1), json!(1.5), json!("abc"), json!("3a"), json!(true), json!(3_000_000_000_i64)] { + for bad in [ + json!(0), + json!(-1), + json!(1.5), + json!("abc"), + json!("3a"), + json!(true), + json!(3_000_000_000_i64), + ] { assert!( matches!( - delegation_target_from_args(&json!({"assistant_id": bad, "agent_type": "codex"})), + delegation_target_from_args( + &json!({"assistant_id": bad, "agent_type": "codex"}) + ), Err(DelegationTargetError::InvalidAssistantId(_)) ), "{bad} must not resolve" @@ -592,7 +709,8 @@ mod tests { ]; for (err, expected) in cases { let display = err.to_string(); - let DelegationOutcome::Err { code, message, .. } = DelegationOutcome::from_err(err, None) + let DelegationOutcome::Err { code, message, .. } = + DelegationOutcome::from_err(err, None) else { panic!("from_err must produce an Err outcome"); }; diff --git a/src-tauri/src/acp/error.rs b/src-tauri/src/acp/error.rs index 9184015d39..d0b4214699 100644 --- a/src-tauri/src/acp/error.rs +++ b/src-tauri/src/acp/error.rs @@ -6,6 +6,15 @@ pub enum AcpError { SpawnFailed(String), #[error("connection not found: {0}")] ConnectionNotFound(String), + /// A STRICT `session/load` (delegation continue) failed. Unlike the + /// ordinary launch path — which classifies the failure for the UI and opens + /// a fresh `session/new` so a user's historical conversation still opens — + /// a continue must never silently start a different, empty session. The + /// caller receives the classification (`resource_not_found`, + /// `session_unavailable`, `session_archived`, `session_busy`, …) and the + /// agent's own message. + #[error("session load failed ({code}): {message}")] + SessionLoadFailed { code: String, message: String }, #[error("ACP protocol error: {0}")] Protocol(String), #[error("agent process exited unexpectedly")] @@ -143,6 +152,7 @@ impl AcpError { Self::McpRejectedByAgent(_) => Some("mcp_rejected_by_agent"), Self::AgentAuthRequired(_) => Some("agent_auth_required"), Self::AgentRuntimeOutdated(_) => Some("agent_runtime_outdated"), + Self::SessionLoadFailed { .. } => Some("session_load_failed"), Self::Protocol(_) => None, } } diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 8ecbf26086..2b4fdbca62 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -362,6 +362,7 @@ pub(crate) async fn handle_event( forward_turn_complete_to_broker( db_conn, b.as_ref(), + &envelope.connection_id, cid, stop_reason.as_str(), last_text, @@ -476,6 +477,7 @@ pub(crate) async fn handle_event( async fn forward_turn_complete_to_broker( db_conn: &DatabaseConnection, broker: &DelegationBroker, + connection_id: &str, conversation_id: i32, stop_reason: &str, last_text: Option, @@ -490,11 +492,22 @@ async fn forward_turn_complete_to_broker( return; } }; - let call_id = match row.delegation_call_id.clone() { - Some(id) => id, - None => return, // not a delegation child; nothing to do. + // The live round first. A continuation shares this conversation row, whose + // `delegation_call_id` still names the first round; settling that id would + // drop the successor's TurnComplete on the floor. Resolved once: the row's + // parent tool id belongs to the first round, so only the row fallback needs + // that guard. A continuation carries its own on the running task. + let routed_by_connection = broker + .call_id_for_child_connection(connection_id) + .await; + let call_id = match &routed_by_connection { + Some(id) => id.clone(), + None => match row.delegation_call_id.clone() { + Some(id) => id, + None => return, // not a delegation child; nothing to do. + }, }; - if row.parent_tool_use_id.is_none() { + if routed_by_connection.is_none() && row.parent_tool_use_id.is_none() { tracing::info!( "[delegation][lifecycle] conversation {conversation_id} has \ delegation_call_id but no parent_tool_use_id; dropping" diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 36e310ece8..573f0640ac 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -11,8 +11,8 @@ use sea_orm::{ }; use crate::acp::connection::{ - spawn_agent_connection, AgentConnection, ConnectionCommand, GoalControlAction, SetterOutcome, - SteerOutcome, + spawn_agent_connection, AgentConnection, ConnectionCommand, GoalControlAction, + SessionLoadPolicy, SetterOutcome, SteerOutcome, }; use crate::acp::agent_mentions::strip_route_separator_from_prompt; use crate::acp::error::AcpError; @@ -741,6 +741,40 @@ impl ConnectionManager { preferred_mode_id, preferred_config_values, None, + SessionLoadPolicy::FallbackToNew, + ) + .await + } + + /// Like [`Self::spawn_agent_with_outcome`], but choosing whether a failed + /// `session/load` may fall back to a brand-new agent session. Delegation + /// continue passes [`SessionLoadPolicy::StrictDelegationContinue`]: the + /// source round's session IS the task's context, so a silent `session/new` + /// would answer with an empty session under the requested id. + #[allow(clippy::too_many_arguments)] + pub async fn spawn_agent_with_outcome_policy( + &self, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + runtime_env: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + session_load_policy: SessionLoadPolicy, + ) -> Result { + self.spawn_agent_seeded( + agent_type, + working_dir, + session_id, + runtime_env, + owner_window_label, + emitter, + preferred_mode_id, + preferred_config_values, + None, + session_load_policy, ) .await } @@ -770,6 +804,7 @@ impl ConnectionManager { preferred_mode_id, preferred_config_values, Some(seed), + SessionLoadPolicy::FallbackToNew, ) .await .map(|outcome| outcome.connection_id) @@ -787,6 +822,7 @@ impl ConnectionManager { preferred_mode_id: Option, preferred_config_values: BTreeMap, cli_root: Option, + session_load_policy: SessionLoadPolicy, ) -> Result { // Held for the whole establishment. A restore writing back to the // agents' own directories takes the write side, so it can never see an @@ -863,7 +899,7 @@ impl ConnectionManager { // installs the SessionStarted dedup signal on the state, registers // a cleanup hook, and returns the rx half of the signal. Any spawn // failure short-circuits before we touch the rx wait. - let session_started_rx = spawn_agent_connection( + let driver = spawn_agent_connection( connection_id.clone(), agent_type, working_dir, @@ -877,17 +913,57 @@ impl ConnectionManager { self.delegation_snapshot(), self.terminal_shell_config.clone(), cli_root, + session_load_policy, ) .await?; + // Strict delegation-continue waits for the handshake even without a + // dedup lock: only a `SessionStarted` proves the source session was + // actually loaded. Any other outcome means the round cannot run, and + // the connection must not linger as a half-open session. + let mut session_started = Some(driver.session_started); + if session_load_policy == SessionLoadPolicy::StrictDelegationContinue { + let timeout = self.spawn_handshake_timeout; + let (outcome, elapsed) = + wait_for_session_started(session_started.take().expect("fresh spawn signal"), timeout) + .await; + tracing::info!( + "[ACP] strict_load_wait connection_id={} session_id={} outcome={} \ + elapsed_ms={} timeout_ms={}", + connection_id, + session_id_for_log.as_deref().unwrap_or(""), + outcome.as_str(), + elapsed.as_millis(), + timeout.as_millis(), + ); + if outcome != HandshakeWaitOutcome::Ready { + let failure = driver.state.read().await.session_load_failure.clone(); + let _ = self.disconnect(&connection_id).await; + return Err(match failure { + Some(failure) => AcpError::SessionLoadFailed { + code: failure.code, + message: failure.message, + }, + None => AcpError::SessionLoadFailed { + code: "session_unavailable".to_string(), + message: format!( + "session/load did not complete ({} after {} ms)", + outcome.as_str(), + elapsed.as_millis() + ), + }, + }); + } + } + // When dedup is active, hold the lock until the agent's // SessionStarted has applied (so external_id is populated for the // next waiter), aborted (connection died), or the timeout fires. // Logged on every wait so production can audit real-world handshake // latencies and tune `CODEG_ACP_SPAWN_HANDSHAKE_TIMEOUT_SECS`. - if dedup_lock.is_some() { + if let (true, Some(session_started)) = (dedup_lock.is_some(), session_started) { let timeout = self.spawn_handshake_timeout; - let (outcome, elapsed) = wait_for_session_started(session_started_rx, timeout).await; + let (outcome, elapsed) = wait_for_session_started(session_started, timeout).await; tracing::info!( "[ACP] dedup_wait connection_id={} session_id={} outcome={} \ elapsed_ms={} timeout_ms={}", @@ -4400,6 +4476,111 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa }) } + async fn spawn_for_continue( + &self, + parent_connection_id: &str, + agent_type: AgentType, + working_dir: Option, + external_session_id: &str, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result + { + use crate::acp::delegation::spawner::{ResumedSpawn, SpawnerError}; + // Same parent inheritance as every other delegation spawn: the child + // must stream to the parent's emitter and window. + let (emitter, owner_window, parent_working_dir) = { + let conns = self.manager.connections.lock().await; + let parent = conns.get(parent_connection_id).ok_or_else(|| { + SpawnerError::Spawn(format!( + "parent connection {parent_connection_id} not found" + )) + })?; + let pwd = { + let s = parent.state.read().await; + s.working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + }; + ( + parent.emitter.clone(), + parent.owner_window_label.clone(), + pwd, + ) + }; + let effective_working_dir = working_dir.or(parent_working_dir); + let runtime_env = crate::commands::acp::build_session_runtime_env( + &self.db, + agent_type, + None, + self.data_dir.as_path(), + ) + .await + .map_err(|e| SpawnerError::Spawn(e.to_string()))?; + + // Strict load: a failure is reported, never papered over with a fresh + // session — see `SessionLoadPolicy`. + let outcome = self + .manager + .spawn_agent_with_outcome_policy( + agent_type, + effective_working_dir, + Some(external_session_id.to_string()), + runtime_env, + owner_window, + emitter, + preferred_mode_id, + preferred_config_values, + SessionLoadPolicy::StrictDelegationContinue, + ) + .await + .map_err(|e| match e { + AcpError::SessionLoadFailed { code, message } => { + SpawnerError::SessionLoad { code, message } + } + other => SpawnerError::Spawn(other.to_string()), + })?; + // A REUSED connection belongs to whoever was already driving that + // session; it must not inherit this run's CLI tag (the broker refuses + // the round instead — see `session_busy`). + if !outcome.reused { + self.manager + .inherit_cli_run_tag(parent_connection_id, &outcome.connection_id) + .await; + } + Ok(ResumedSpawn { + connection_id: outcome.connection_id, + reused: outcome.reused, + }) + } + + async fn send_continue_prompt( + &self, + conn_id: &str, + task: String, + folder_id: i32, + child_conversation_id: i32, + brief: Option, + ) -> Result<(), crate::acp::delegation::spawner::SpawnerError> { + use crate::acp::delegation::spawner::SpawnerError; + // Adopt the child's existing row, like resume: the row keeps its + // original linkage, which is exactly what must not change on continue. + // The prompt is the caller's complete new task — no continuation frame. + self.manager + .send_prompt_linked_with_suffix( + &self.db, + conn_id, + vec![PromptInputBlock::Text { text: task }], + Some(folder_id), + Some(child_conversation_id), + None, + brief_suffix(brief), + ) + .await + .map(|_| ()) + .map_err(|e| SpawnerError::Send(e.to_string())) + } + async fn send_resume_prompt( &self, conn_id: &str, diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 0e8e2e2af9..fae373587f 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -248,6 +248,18 @@ pub struct PendingUserMessage { pub blocks: Vec, } +/// A classified strict `session/load` failure, recorded on the state so the +/// connection manager can turn the aborted session-start wait into a typed +/// error for the delegation broker. See `SessionLoadPolicy`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionLoadFailure { + /// Wire-stable classification: `resource_not_found`, `session_unavailable`, + /// `session_archived`, `session_busy`, or `session_unavailable` when the + /// agent gave nothing more specific. + pub code: String, + pub message: String, +} + /// 后端权威的会话状态。每个 AgentConnection 持有一个 Arc>。 /// /// 字段范围:仅当前 turn 的 in-flight 数据 + 元信息 + 协商出的能力。 @@ -400,6 +412,13 @@ pub struct SessionState { /// Backend-internal — not serialized. pub pi_startup_banner: Option, + /// Why a STRICT delegation-continue `session/load` failed, for the + /// connection manager's strict wait to read after the session-start signal + /// aborts. `None` for every ordinary (fallback-to-new) launch: those + /// report the failure through `AcpEvent::SessionLoadFailed` and continue. + /// Backend-internal — not serialized. + pub session_load_failure: Option, + /// Config-option values codeg asserted while establishing this session /// (`apply_preferred_session_options`) and the agent confirmed — the user's /// saved preferences on a connect, the parent's selectors on a fork. @@ -881,6 +900,7 @@ impl SessionState { grok_catalog_broadcast: None, kiro_reasoning: None, pi_startup_banner: None, + session_load_failure: None, asserted_config_values: BTreeMap::new(), agent_chosen_config_values: BTreeMap::new(), env_pinned_config_option_ids: Vec::new(), diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 77ab999fe2..1d8e1ca535 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -172,14 +172,17 @@ pub fn build_delegation_stack( }) as Arc; let depth_lookup = Arc::new(DbDepthLookup { db: db_arc.clone() }) as Arc; - let agent_availability = Arc::new(crate::acp::connection::DbAgentAvailabilityLookup { - db: db_arc.clone(), - }) - as Arc; - let assistants = Arc::new(crate::acp::delegation::assistants::DbAssistantLookup { - db: db_arc.clone(), - }) as Arc; - let status_lookup = Arc::new(DbChildStatusLookup { db: db_arc }) as Arc; + let agent_availability = + Arc::new(crate::acp::connection::DbAgentAvailabilityLookup { db: db_arc.clone() }) + as Arc; + let assistants = + Arc::new(crate::acp::delegation::assistants::DbAssistantLookup { db: db_arc.clone() }) + as Arc; + let status_lookup = + Arc::new(DbChildStatusLookup { db: db_arc.clone() }) as Arc; + let ledger = Arc::new( + crate::db::service::delegation_task_service::DbDelegationTaskLedger { db: db_arc }, + ) as Arc; let meta_writer = Arc::new(ConnectionManagerMetaWriter { manager: cm_arc.clone(), }) as Arc; @@ -191,6 +194,7 @@ pub fn build_delegation_stack( let broker = Arc::new( DelegationBroker::with_writers(spawner, depth_lookup, meta_writer, event_emitter) .with_status_lookup(status_lookup) + .with_ledger(ledger) .with_live_reply_lookup(live_reply_lookup) .with_assistants(assistants), ); @@ -229,7 +233,15 @@ pub fn build_delegation_stack( }); ( - broker, tokens, socket_path, feedback, ask, sessions, authoring, browser, computer, + broker, + tokens, + socket_path, + feedback, + ask, + sessions, + authoring, + browser, + computer, ) } diff --git a/src-tauri/src/db/entities/delegation_task.rs b/src-tauri/src/db/entities/delegation_task.rs new file mode 100644 index 0000000000..eb428c20f4 --- /dev/null +++ b/src-tauri/src/db/entities/delegation_task.rs @@ -0,0 +1,80 @@ +//! One immutable round of delegated work. +//! +//! A delegation child conversation is a SESSION; a task is one ROUND of work +//! inside it. `delegate_to_agent` opens the session with the first round, and +//! every `continue_delegation` adds another row here that shares the child +//! conversation (and its ACP `external_id`) but owns its own task id, parent +//! tool-call id, status and result. The original `conversation` row keeps its +//! original `delegation_call_id` / `parent_tool_use_id` for compatibility with +//! the pre-ledger world; the ledger is authoritative for anything newer. +//! +//! Rows are append-only: terminal fields are written once and never rewritten +//! by a successor. `source_task_id` is UNIQUE (partial index) so a source can +//! have at most one successor. + +use sea_orm::entity::prelude::*; + +/// Wire-stable status strings. They mirror the delegation `TaskStatus` +/// vocabulary and are what the ledger, reports and history cards speak. +pub const STATUS_RUNNING: &str = "running"; +pub const STATUS_COMPLETED: &str = "completed"; +pub const STATUS_FAILED: &str = "failed"; +pub const STATUS_CANCELED: &str = "canceled"; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "delegation_task")] +pub struct Model { + /// Broker task id (UUID). Also the id `delegate_to_agent` returned for the + /// first round; a continue round mints a fresh one. + #[sea_orm(primary_key, auto_increment = false)] + pub task_id: String, + /// The round this one follows. `NULL` for the original delegation. Unique + /// when set: one source, at most one successor. + pub source_task_id: Option, + /// SHA-256 of the continue request's task text (the original string). NULL + /// for historical rows backfilled from a conversation and for the first + /// round, whose text was the `delegate_to_agent` task. + pub request_hash: Option, + /// The conversation that issued the delegation. + pub parent_conversation_id: i32, + /// The parent-side tool call this round answers (the `delegate_to_agent` + /// call for round one, the `continue_delegation` call after that). + pub parent_tool_use_id: Option, + /// The child conversation hosting the round — shared by every round of one + /// session. + pub child_conversation_id: i32, + pub agent_type: String, + /// The ACP session id the round ran in, when known. Continue refuses + /// without one (never `session/new`). + pub external_session_id: Option, + /// Absolute working directory the round ran in. + pub working_dir: Option, + pub assistant_id: Option, + pub assistant_name: Option, + /// JSON `AssistantRunSnapshot` as it was when the round started. + pub assistant_snapshot: Option, + /// Bounded preview of the round's task text. + pub task_preview: Option, + pub status: String, + pub error_code: Option, + pub error_message: Option, + pub duration_ms: Option, + pub result_preview: Option, + pub created_at: DateTimeUtc, + pub finished_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} + +impl Model { + /// Whether this row can be the source of a new continue round. + pub fn is_terminal(&self) -> bool { + matches!( + self.status.as_str(), + STATUS_COMPLETED | STATUS_FAILED | STATUS_CANCELED + ) + } +} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 34f2861ade..9db03b8719 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -13,6 +13,7 @@ pub mod conversation; pub mod conversation_tag; pub mod conversation_tag_link; pub mod custom_agent; +pub mod delegation_task; pub mod folder; pub mod folder_command; pub mod folder_group; diff --git a/src-tauri/src/db/migration/m20261027_000001_delegation_task.rs b/src-tauri/src/db/migration/m20261027_000001_delegation_task.rs new file mode 100644 index 0000000000..1a29d20b5b --- /dev/null +++ b/src-tauri/src/db/migration/m20261027_000001_delegation_task.rs @@ -0,0 +1,150 @@ +//! The delegation task ledger: one row per round of delegated work. +//! +//! Before this table a delegation child conversation WAS the task — one row, +//! one `delegation_call_id`, one lifecycle. Continuing a session with new work +//! therefore had nowhere to live: overwriting the row's linkage would break +//! the old card, its status routing and its cancel path. The ledger separates +//! the session (the conversation row) from its rounds, and its partial unique +//! index on `source_task_id` is what guarantees a source can have at most one +//! successor. + +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend, Statement}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const IDX_SOURCE: &str = "idx_delegation_task_source"; +const IDX_PARENT: &str = "idx_delegation_task_parent_conversation"; +const IDX_CHILD: &str = "idx_delegation_task_child_conversation"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DelegationTask::Table) + .if_not_exists() + .col( + ColumnDef::new(DelegationTask::TaskId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(DelegationTask::SourceTaskId).text().null()) + .col(ColumnDef::new(DelegationTask::RequestHash).text().null()) + .col( + ColumnDef::new(DelegationTask::ParentConversationId) + .integer() + .not_null(), + ) + .col(ColumnDef::new(DelegationTask::ParentToolUseId).text().null()) + .col( + ColumnDef::new(DelegationTask::ChildConversationId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(DelegationTask::AgentType) + .text() + .not_null(), + ) + .col(ColumnDef::new(DelegationTask::ExternalSessionId).text().null()) + .col(ColumnDef::new(DelegationTask::WorkingDir).text().null()) + .col(ColumnDef::new(DelegationTask::AssistantId).integer().null()) + .col(ColumnDef::new(DelegationTask::AssistantName).text().null()) + .col(ColumnDef::new(DelegationTask::AssistantSnapshot).text().null()) + .col(ColumnDef::new(DelegationTask::TaskPreview).text().null()) + .col( + ColumnDef::new(DelegationTask::Status) + .text() + .not_null(), + ) + .col(ColumnDef::new(DelegationTask::ErrorCode).text().null()) + .col(ColumnDef::new(DelegationTask::ErrorMessage).text().null()) + .col(ColumnDef::new(DelegationTask::DurationMs).big_integer().null()) + .col(ColumnDef::new(DelegationTask::ResultPreview).text().null()) + .col( + ColumnDef::new(DelegationTask::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(DelegationTask::FinishedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + // SQLite's table builder cannot express a PARTIAL unique index, so the + // one-source-one-successor invariant is installed directly. NULL + // sources (every first round) are exempt, which is exactly what a + // plain UNIQUE column would get wrong on some engines and what the + // contract requires. + let conn = manager.get_connection(); + conn.execute(Statement::from_string( + DbBackend::Sqlite, + format!( + "CREATE UNIQUE INDEX IF NOT EXISTS {IDX_SOURCE} \ + ON delegation_task(source_task_id) WHERE source_task_id IS NOT NULL" + ), + )) + .await?; + conn.execute(Statement::from_string( + DbBackend::Sqlite, + format!( + "CREATE INDEX IF NOT EXISTS {IDX_PARENT} \ + ON delegation_task(parent_conversation_id)" + ), + )) + .await?; + conn.execute(Statement::from_string( + DbBackend::Sqlite, + format!( + "CREATE INDEX IF NOT EXISTS {IDX_CHILD} \ + ON delegation_task(child_conversation_id)" + ), + )) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .if_exists() + .table(DelegationTask::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum DelegationTask { + Table, + TaskId, + SourceTaskId, + RequestHash, + ParentConversationId, + ParentToolUseId, + ChildConversationId, + AgentType, + ExternalSessionId, + WorkingDir, + AssistantId, + AssistantName, + AssistantSnapshot, + TaskPreview, + Status, + ErrorCode, + ErrorMessage, + DurationMs, + ResultPreview, + CreatedAt, + FinishedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 4bae1e013f..17e9707787 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -56,6 +56,7 @@ mod m20261017_000002_conversation_source; mod m20261020_000001_folder_command_run_local; mod m20261022_000001_tool_approval; mod m20261022_000001_work_task_delivery; +mod m20261027_000001_delegation_task; pub struct Migrator; #[async_trait::async_trait] @@ -118,6 +119,7 @@ impl MigratorTrait for Migrator { Box::new(m20261020_000001_folder_command_run_local::Migration), Box::new(m20261022_000001_tool_approval::Migration), Box::new(m20261022_000001_work_task_delivery::Migration), + Box::new(m20261027_000001_delegation_task::Migration), ] } } diff --git a/src-tauri/src/db/service/delegation_task_service.rs b/src-tauri/src/db/service/delegation_task_service.rs new file mode 100644 index 0000000000..aa7e680ffe --- /dev/null +++ b/src-tauri/src/db/service/delegation_task_service.rs @@ -0,0 +1,674 @@ +//! Persistence for the delegation task ledger. +//! +//! A child conversation is a SESSION; a [`DelegationTaskRecord`] is one ROUND +//! of work inside it. `delegate_to_agent` writes the first round, +//! `continue_delegation` appends a successor that shares the child +//! conversation and its ACP session but owns a new task id, parent tool-call +//! id, status and result. +//! +//! Two rules shape this module: +//! * terminal fields are written once (`finish` only updates a `running` +//! row), so a successor can never rewrite the round it followed; +//! * a source has at most one successor, enforced by the partial unique +//! index installed by the migration and surfaced as [`DbError::Conflict`]. +//! +//! [`DelegationTaskRecord`] is the broker's view: it resolves the two values a +//! ledger row may legitimately lack from the child conversation row — the ACP +//! session id (bound asynchronously on `SessionStarted`) and the working +//! directory (the child's folder) — because those are exactly the facts +//! `continue_delegation` must recover without ever guessing. + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; + +use crate::acp::delegation::types::{ + AssistantRef, DelegationAssistant, DelegationTaskLedger, TaskStatus, +}; +use crate::db::entities::delegation_task::{self, Model as DelegationTaskModel}; +use crate::db::entities::{conversation, folder}; +use crate::db::error::DbError; +use crate::models::{AgentType, AssistantRunSnapshot}; + +/// A round about to start. `task_id` is minted by the broker; a continue +/// round carries its `source_task_id` and request fingerprint. +#[derive(Debug, Clone)] +pub struct NewDelegationTask { + pub task_id: String, + pub source_task_id: Option, + pub request_hash: Option, + pub parent_conversation_id: i32, + pub parent_tool_use_id: Option, + pub child_conversation_id: i32, + pub agent_type: AgentType, + pub external_session_id: Option, + pub working_dir: Option, + pub assistant: Option, + pub task_preview: Option, +} + +/// The one write a terminal round accepts. Applied only while the row is +/// still `running`, which makes every terminal path idempotent and keeps a +/// late duplicate from overwriting a real outcome. +#[derive(Debug, Clone)] +pub struct TerminalUpdate { + pub status: TaskStatus, + pub error_code: Option, + pub error_message: Option, + pub duration_ms: Option, + pub result_preview: Option, + /// Learned once the child's `SessionStarted` bound it. Never overwrites a + /// value already on the row. + pub external_session_id: Option, +} + +/// A ledger row as the broker reads it, with the compatibility resolution +/// applied (session id / cwd / assistant snapshot may come from the child +/// conversation row when the ledger column is NULL). +#[derive(Debug, Clone)] +pub struct DelegationTaskRecord { + pub task_id: String, + pub source_task_id: Option, + pub request_hash: Option, + pub parent_conversation_id: i32, + pub parent_tool_use_id: Option, + pub child_conversation_id: i32, + /// The child row's folder — the continue/resume prompt adopts the row, so + /// it must name the folder that row lives in. + pub folder_id: i32, + pub agent_type: AgentType, + pub external_session_id: Option, + pub working_dir: Option, + pub assistant: Option, + pub assistant_ref: Option, + pub task_preview: Option, + pub status: TaskStatus, + pub error_code: Option, + pub error_message: Option, + pub duration_ms: Option, + pub result_preview: Option, + /// The round that followed this one, when there is one. + pub successor_task_id: Option, +} + +impl DelegationTaskRecord { + pub fn is_terminal(&self) -> bool { + matches!( + self.status, + TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled + ) + } +} + +fn status_from_str(raw: &str) -> TaskStatus { + match raw { + delegation_task::STATUS_COMPLETED => TaskStatus::Completed, + delegation_task::STATUS_FAILED => TaskStatus::Failed, + delegation_task::STATUS_CANCELED => TaskStatus::Canceled, + delegation_task::STATUS_RUNNING => TaskStatus::Running, + _ => TaskStatus::Unknown, + } +} + +fn status_to_str(status: TaskStatus) -> &'static str { + match status { + TaskStatus::Completed => delegation_task::STATUS_COMPLETED, + TaskStatus::Failed => delegation_task::STATUS_FAILED, + TaskStatus::Canceled => delegation_task::STATUS_CANCELED, + TaskStatus::Running | TaskStatus::Unknown => delegation_task::STATUS_RUNNING, + } +} + +/// Append a round. A duplicate `source_task_id` is [`DbError::Conflict`] — the +/// caller re-reads the successor and answers idempotently or with +/// `continuation_conflict`. +pub async fn insert( + conn: &DatabaseConnection, + task: NewDelegationTask, +) -> Result { + let now = Utc::now(); + let (assistant_id, assistant_name, assistant_snapshot) = match &task.assistant { + Some(a) => ( + Some(a.id), + Some(a.name.clone()), + Some(serde_json::to_string(&a.run_snapshot()).map_err(|e| { + DbError::Validation(format!("assistant snapshot: {e}")) + })?), + ), + None => (None, None, None), + }; + let model = delegation_task::ActiveModel { + task_id: Set(task.task_id), + source_task_id: Set(task.source_task_id), + request_hash: Set(task.request_hash), + parent_conversation_id: Set(task.parent_conversation_id), + parent_tool_use_id: Set(task.parent_tool_use_id), + child_conversation_id: Set(task.child_conversation_id), + agent_type: Set(serde_json::to_value(task.agent_type) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default()), + external_session_id: Set(task.external_session_id), + working_dir: Set(task.working_dir), + assistant_id: Set(assistant_id), + assistant_name: Set(assistant_name), + assistant_snapshot: Set(assistant_snapshot), + task_preview: Set(task.task_preview), + status: Set(delegation_task::STATUS_RUNNING.to_string()), + error_code: Set(None), + error_message: Set(None), + duration_ms: Set(None), + result_preview: Set(None), + created_at: Set(now), + finished_at: Set(None), + }; + model.insert(conn).await.map_err(|e| match unique_violation(&e) { + true => DbError::Conflict("delegation source already has a successor".to_string()), + false => DbError::Database(e), + }) +} + +fn unique_violation(e: &sea_orm::DbErr) -> bool { + let text = e.to_string(); + text.contains("UNIQUE constraint failed") + || text.contains("unique constraint") + || text.contains("UNIQUE constraint") +} + +pub async fn find( + conn: &DatabaseConnection, + task_id: &str, +) -> Result, DbError> { + Ok(delegation_task::Entity::find_by_id(task_id.to_string()) + .one(conn) + .await?) +} + +pub async fn find_successor( + conn: &DatabaseConnection, + source_task_id: &str, +) -> Result, DbError> { + Ok(delegation_task::Entity::find() + .filter(delegation_task::Column::SourceTaskId.eq(source_task_id)) + .one(conn) + .await?) +} + +pub async fn list_by_child_conversation( + conn: &DatabaseConnection, + child_conversation_id: i32, +) -> Result, DbError> { + Ok(delegation_task::Entity::find() + .filter(delegation_task::Column::ChildConversationId.eq(child_conversation_id)) + .order_by_asc(delegation_task::Column::CreatedAt) + .all(conn) + .await?) +} + +pub async fn list_by_parent_conversation( + conn: &DatabaseConnection, + parent_conversation_id: i32, +) -> Result, DbError> { + Ok(delegation_task::Entity::find() + .filter(delegation_task::Column::ParentConversationId.eq(parent_conversation_id)) + .order_by_asc(delegation_task::Column::CreatedAt) + .all(conn) + .await?) +} + +/// Write a round's terminal state. Returns `false` when the row was no longer +/// `running` — a duplicate terminal (or a successor's attempt to touch a +/// finished round) is a no-op, never an overwrite. +pub async fn finish( + conn: &DatabaseConnection, + task_id: &str, + update: TerminalUpdate, +) -> Result { + let Some(row) = find(conn, task_id).await? else { + return Ok(false); + }; + if row.status != delegation_task::STATUS_RUNNING { + return Ok(false); + } + let mut active: delegation_task::ActiveModel = row.into(); + active.status = Set(status_to_str(update.status).to_string()); + active.error_code = Set(update.error_code); + active.error_message = Set(update.error_message); + active.duration_ms = Set(update.duration_ms.map(|ms| ms as i64)); + active.result_preview = Set(update.result_preview); + if update.external_session_id.is_some() { + active.external_session_id = Set(update.external_session_id); + } + active.finished_at = Set(Some(Utc::now())); + let result = delegation_task::Entity::update_many() + .set(active) + .filter(delegation_task::Column::TaskId.eq(task_id)) + .filter(delegation_task::Column::Status.eq(delegation_task::STATUS_RUNNING)) + .exec(conn) + .await?; + Ok(result.rows_affected > 0) +} + +/// Return a finished round to `running` when `resume_delegation` revives the +/// SAME task id. Resume is a continuation of one task, not a successor, so its +/// row is the only one that may leave a terminal state — and only when nothing +/// has followed it (a task with a successor is refused before reaching here). +pub async fn reopen(conn: &DatabaseConnection, task_id: &str) -> Result { + let Some(row) = find(conn, task_id).await? else { + return Ok(false); + }; + if find_successor(conn, task_id).await?.is_some() { + return Ok(false); + } + let mut active: delegation_task::ActiveModel = row.into(); + active.status = Set(delegation_task::STATUS_RUNNING.to_string()); + active.error_code = Set(None); + active.error_message = Set(None); + active.duration_ms = Set(None); + active.result_preview = Set(None); + active.finished_at = Set(None); + let result = delegation_task::Entity::update_many() + .set(active) + .filter(delegation_task::Column::TaskId.eq(task_id)) + .exec(conn) + .await?; + Ok(result.rows_affected > 0) +} + +/// Drop a continue round that never dispatched a prompt. +/// +/// `continue_delegation` appends its successor row before the strict session +/// load (the ledger is what reserves the one-successor slot atomically). A +/// load that fails, a session already driven by someone else, or a spawn that +/// dies would otherwise burn that slot on work nobody ran, and every retry +/// would answer `continuation_conflict` forever. Only a still-`running`, +/// still-childless successor may be dropped: originals and finished rounds are +/// never touched. +pub async fn discard(conn: &DatabaseConnection, task_id: &str) -> Result { + let Some(row) = find(conn, task_id).await? else { + return Ok(false); + }; + if row.status != delegation_task::STATUS_RUNNING || row.source_task_id.is_none() { + return Ok(false); + } + if find_successor(conn, task_id).await?.is_some() { + return Ok(false); + } + let result = delegation_task::Entity::delete_many() + .filter(delegation_task::Column::TaskId.eq(task_id)) + .filter(delegation_task::Column::Status.eq(delegation_task::STATUS_RUNNING)) + .filter(delegation_task::Column::SourceTaskId.is_not_null()) + .exec(conn) + .await?; + Ok(result.rows_affected > 0) +} + +/// Resolve a ledger row into the broker's view. The child conversation row is +/// consulted ONLY for values the ledger may legitimately lack (the session id +/// bound after `SessionStarted`, the folder's path, the assistant snapshot) — +/// never as an alternative identity. +pub async fn resolve( + conn: &DatabaseConnection, + task_id: &str, +) -> Result, DbError> { + let Some(row) = find(conn, task_id).await? else { + return Ok(None); + }; + let child = conversation::Entity::find_by_id(row.child_conversation_id) + .one(conn) + .await?; + let working_dir = match row.working_dir.clone() { + Some(dir) => Some(dir), + None => match child.as_ref() { + Some(c) => folder::Entity::find_by_id(c.folder_id) + .one(conn) + .await? + .map(|f| f.path), + None => None, + }, + }; + let external_session_id = row + .external_session_id + .clone() + .or_else(|| child.as_ref().and_then(|c| c.external_id.clone())); + let snapshot_raw = row + .assistant_snapshot + .clone() + .or_else(|| child.as_ref().and_then(|c| c.assistant_snapshot.clone())); + let assistant_id = row + .assistant_id + .or_else(|| child.as_ref().and_then(|c| c.assistant_id)); + let assistant_name = row.assistant_name.clone().or_else(|| { + snapshot_raw + .as_deref() + .and_then(AssistantRunSnapshot::name_from_json) + }); + let assistant = match (assistant_id, snapshot_raw.as_deref()) { + (Some(id), Some(raw)) => serde_json::from_str::(raw) + .ok() + .map(|snapshot| DelegationAssistant { + id, + name: snapshot.name, + agent_options: snapshot.agent_options, + instructions: snapshot.instructions, + }), + _ => None, + }; + let successor_task_id = find_successor(conn, &row.task_id) + .await? + .map(|s| s.task_id); + Ok(Some(DelegationTaskRecord { + task_id: row.task_id, + source_task_id: row.source_task_id, + request_hash: row.request_hash, + parent_conversation_id: row.parent_conversation_id, + parent_tool_use_id: row.parent_tool_use_id, + child_conversation_id: row.child_conversation_id, + folder_id: child.as_ref().map(|c| c.folder_id).unwrap_or_default(), + agent_type: AgentType::from_wire(&row.agent_type).unwrap_or(AgentType::ClaudeCode), + external_session_id, + working_dir, + assistant_ref: assistant_id.map(|id| AssistantRef { + id, + name: assistant_name.clone().unwrap_or_default(), + }), + assistant, + task_preview: row.task_preview, + status: status_from_str(&row.status), + error_code: row.error_code, + error_message: row.error_message, + duration_ms: row.duration_ms.map(|ms| ms.max(0) as u64), + result_preview: row.result_preview, + successor_task_id, + })) +} + +/// Create baseline ledger rows for delegation children recorded before the +/// ledger existed. Idempotent, so it is safe to run on every startup. +/// +/// The historical mapping is deliberately lossy and documented on +/// [`backfill_record`]: the conversation row could not distinguish a failure +/// from a cancel, and never stored the full task text. +pub async fn backfill_from_conversations(conn: &DatabaseConnection) -> Result { + let rows = conversation::Entity::find() + .filter(conversation::Column::DelegationCallId.is_not_null()) + .filter(conversation::Column::ParentId.is_not_null()) + .all(conn) + .await?; + let mut inserted = 0usize; + for child in rows { + let Some(task_id) = child.delegation_call_id.clone() else { + continue; + }; + if find(conn, &task_id).await?.is_some() { + continue; + } + let working_dir = folder::Entity::find_by_id(child.folder_id) + .one(conn) + .await? + .map(|f| f.path); + let assistant_name = child + .assistant_snapshot + .as_deref() + .and_then(AssistantRunSnapshot::name_from_json); + let model = delegation_task::ActiveModel { + task_id: Set(task_id), + source_task_id: Set(None), + request_hash: Set(None), + parent_conversation_id: Set(child.parent_id.unwrap_or_default()), + parent_tool_use_id: Set(child.parent_tool_use_id.clone()), + child_conversation_id: Set(child.id), + agent_type: Set(child.agent_type.clone()), + external_session_id: Set(child.external_id.clone()), + working_dir: Set(working_dir), + assistant_id: Set(child.assistant_id), + assistant_name: Set(assistant_name), + assistant_snapshot: Set(child.assistant_snapshot.clone()), + // The full task text was never stored; the row's title was seeded + // from it, so it is the closest surviving preview. + task_preview: Set(child.title.clone()), + status: Set(historical_status(&child.status).to_string()), + error_code: Set(None), + error_message: Set(None), + duration_ms: Set(None), + result_preview: Set(None), + created_at: Set(child.created_at), + finished_at: Set(match historical_status(&child.status) { + delegation_task::STATUS_RUNNING => None, + _ => Some(child.updated_at), + }), + }; + match model.insert(conn).await { + Ok(_) => inserted += 1, + // A concurrent startup/backfill won the race; its row is the same + // baseline. + Err(e) if unique_violation(&e) => {} + Err(e) => return Err(DbError::Database(e)), + } + } + Ok(inserted) +} + +/// The historical conversation-status mapping required by the ledger +/// contract: `pending_review`/`completed` → completed, `cancelled` → canceled +/// (no error code — the row cannot tell a failure from a cancel), +/// `in_progress` → running. +fn historical_status(status: &conversation::ConversationStatus) -> &'static str { + match status { + conversation::ConversationStatus::InProgress => delegation_task::STATUS_RUNNING, + conversation::ConversationStatus::PendingReview + | conversation::ConversationStatus::Completed => delegation_task::STATUS_COMPLETED, + conversation::ConversationStatus::Cancelled => delegation_task::STATUS_CANCELED, + } +} + +/// The production ledger the broker holds. Thin adapter over this module so +/// the broker's trait stays free of SeaORM. +pub struct DbDelegationTaskLedger { + pub db: std::sync::Arc, +} + +#[async_trait::async_trait] +impl DelegationTaskLedger for DbDelegationTaskLedger { + async fn insert(&self, task: NewDelegationTask) -> Result<(), DbError> { + super::delegation_task_service::insert(&self.db.conn, task) + .await + .map(|_| ()) + } + + async fn find(&self, task_id: &str) -> Option { + resolve(&self.db.conn, task_id).await.ok().flatten() + } + + async fn successor_of(&self, source_task_id: &str) -> Option { + let successor = find_successor(&self.db.conn, source_task_id) + .await + .ok() + .flatten()?; + resolve(&self.db.conn, &successor.task_id).await.ok().flatten() + } + + async fn reopen(&self, task_id: &str) { + if let Err(e) = super::delegation_task_service::reopen(&self.db.conn, task_id).await { + tracing::warn!("[delegation] ledger reopen for {task_id} failed: {e}"); + } + } + + async fn discard(&self, task_id: &str) { + match super::delegation_task_service::discard(&self.db.conn, task_id).await { + Ok(true) => tracing::info!( + "[delegation] discarded never-sent continue round {task_id}" + ), + Ok(false) => {} + Err(e) => tracing::warn!("[delegation] ledger discard for {task_id} failed: {e}"), + } + } + + async fn finish(&self, task_id: &str, update: TerminalUpdate) { + if let Err(e) = super::delegation_task_service::finish(&self.db.conn, task_id, update).await + { + tracing::warn!("[delegation] ledger terminal write for {task_id} failed: {e}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + + fn new_task(task_id: &str, source: Option<&str>, child: i32) -> NewDelegationTask { + NewDelegationTask { + task_id: task_id.into(), + source_task_id: source.map(str::to_string), + request_hash: source.map(|_| "hash-1".to_string()), + parent_conversation_id: 1, + parent_tool_use_id: Some(format!("tu-{task_id}")), + child_conversation_id: child, + agent_type: AgentType::Codex, + external_session_id: Some("sess-1".into()), + working_dir: Some("/tmp/work".into()), + assistant: None, + task_preview: Some(format!("task {task_id}")), + } + } + + async fn seed_child(db: &crate::db::AppDatabase) -> i32 { + let folder = crate::db::test_helpers::seed_folder(db, "/tmp/work").await; + let parent = crate::db::test_helpers::seed_conversation(db, folder, AgentType::ClaudeCode).await; + let child = crate::db::service::conversation_service::create_with_delegation( + &db.conn, + folder, + AgentType::Codex, + Some("original task".into()), + None, + Some(crate::acp::delegation::spawner::DelegationLink { + parent_conversation_id: parent, + parent_tool_use_id: "tu-original".into(), + delegation_call_id: "task-1".into(), + assistant: None, + }), + ) + .await + .expect("seed child"); + child.id + } + + #[tokio::test] + async fn a_source_has_at_most_one_successor() { + let db = fresh_in_memory_db().await; + let child = seed_child(&db).await; + insert(&db.conn, new_task("task-1", None, child)).await.unwrap(); + insert(&db.conn, new_task("task-2", Some("task-1"), child)) + .await + .unwrap(); + let err = insert(&db.conn, new_task("task-3", Some("task-1"), child)) + .await + .expect_err("second successor must conflict"); + assert!(matches!(err, DbError::Conflict(_)), "{err:?}"); + assert_eq!( + find_successor(&db.conn, "task-1").await.unwrap().unwrap().task_id, + "task-2" + ); + } + + #[tokio::test] + async fn a_terminal_round_is_never_rewritten() { + let db = fresh_in_memory_db().await; + let child = seed_child(&db).await; + insert(&db.conn, new_task("task-1", None, child)).await.unwrap(); + assert!(finish( + &db.conn, + "task-1", + TerminalUpdate { + status: TaskStatus::Failed, + error_code: Some("spawn_failed".into()), + error_message: Some("boom".into()), + duration_ms: Some(7), + result_preview: None, + external_session_id: None, + }, + ) + .await + .unwrap()); + // A successor's late write (or a duplicate terminal) must not touch it. + assert!(!finish( + &db.conn, + "task-1", + TerminalUpdate { + status: TaskStatus::Completed, + error_code: None, + error_message: None, + duration_ms: Some(99), + result_preview: Some("other".into()), + external_session_id: None, + }, + ) + .await + .unwrap()); + let row = find(&db.conn, "task-1").await.unwrap().unwrap(); + assert_eq!(row.status, delegation_task::STATUS_FAILED); + assert_eq!(row.error_code.as_deref(), Some("spawn_failed")); + assert_eq!(row.duration_ms, Some(7)); + } + + #[tokio::test] + async fn historical_child_rows_backfill_once_with_the_documented_mapping() { + let db = fresh_in_memory_db().await; + let child = seed_child(&db).await; + let inserted = backfill_from_conversations(&db.conn).await.unwrap(); + assert_eq!(inserted, 1); + let row = find(&db.conn, "task-1").await.unwrap().unwrap(); + assert_eq!(row.status, delegation_task::STATUS_RUNNING); + assert_eq!(row.task_preview.as_deref(), Some("original task")); + assert_eq!(row.request_hash, None); + assert_eq!(row.external_session_id, None); + assert_eq!(row.working_dir.as_deref(), Some("/tmp/work")); + assert_eq!(row.child_conversation_id, child); + // Idempotent. + assert_eq!(backfill_from_conversations(&db.conn).await.unwrap(), 0); + } + + #[tokio::test] + async fn resolve_falls_back_to_the_child_row_for_session_and_cwd() { + let db = fresh_in_memory_db().await; + let child = seed_child(&db).await; + insert( + &db.conn, + NewDelegationTask { + external_session_id: None, + working_dir: None, + ..new_task("task-1", None, child) + }, + ) + .await + .unwrap(); + // Bind the ACP session the way SessionStarted would. + crate::db::service::conversation_service::bind_external_id( + &db.conn, + child, + "sess-live", + &[], + ) + .await + .unwrap(); + let record = resolve(&db.conn, "task-1").await.unwrap().unwrap(); + assert_eq!(record.external_session_id.as_deref(), Some("sess-live")); + assert_eq!(record.working_dir.as_deref(), Some("/tmp/work")); + assert_eq!(record.agent_type, AgentType::Codex); + } + + #[tokio::test] + async fn resolve_reports_the_successor_of_a_chain() { + let db = fresh_in_memory_db().await; + let child = seed_child(&db).await; + insert(&db.conn, new_task("task-1", None, child)).await.unwrap(); + insert(&db.conn, new_task("task-2", Some("task-1"), child)) + .await + .unwrap(); + let record = resolve(&db.conn, "task-1").await.unwrap().unwrap(); + assert_eq!(record.successor_task_id.as_deref(), Some("task-2")); + assert!(resolve(&db.conn, "task-2").await.unwrap().unwrap().successor_task_id.is_none()); + } +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index d11b8a081e..9952c3f390 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -8,6 +8,7 @@ pub mod chat_channel_service; pub mod conversation_service; pub mod conversation_tag_service; pub mod custom_agent_service; +pub mod delegation_task_service; pub mod folder_command_service; pub mod folder_group_service; pub mod folder_link_service; diff --git a/src/components/message/activity/rows/delegation-row.test.tsx b/src/components/message/activity/rows/delegation-row.test.tsx index 2f64ab6b9d..07e9c15a0e 100644 --- a/src/components/message/activity/rows/delegation-row.test.tsx +++ b/src/components/message/activity/rows/delegation-row.test.tsx @@ -114,6 +114,7 @@ function childConnWith(pending: Record) { function delegation( props: { id?: string + toolName?: string input?: string | null output?: string | null state?: Extract["state"] @@ -123,7 +124,7 @@ function delegation( return { type: "tool-call", toolCallId: props.id ?? "pt-1", - toolName: "mcp__codeg-mcp__delegate_to_agent", + toolName: props.toolName ?? "mcp__codeg-mcp__delegate_to_agent", input: props.input ?? null, state: props.state ?? "input-available", output: props.output ?? null, @@ -801,6 +802,214 @@ describe("delegation row: its checks", () => { }) }) +describe("delegation row: a continued round", () => { + const OLD = "oldtask1" + const NEXT = "newtask2" + const continuedInput = JSON.stringify({ + task_id: OLD, + task: "write the follow-up", + }) + const continuedAck = JSON.stringify({ + content: [{ type: "text", text: `Continued. task_id=${NEXT}` }], + structuredContent: { + task_id: NEXT, + status: "running", + child_conversation_id: 99, + }, + }) + + function check( + id: string, + taskId: string, + report: Record + ): AdaptedContentPart { + return { + type: "delegation-status-group", + polls: [ + { + type: "tool-call", + toolCallId: id, + toolName: "get_delegation_status", + input: JSON.stringify({ task_ids: [taskId] }), + output: JSON.stringify({ task_id: taskId, ...report }), + state: "output-available", + }, + ], + } + } + + it("draws a new card beside the old one, with the minted task id", () => { + mockedHook.mockImplementation((parentToolUseId) => ({ + binding: + parentToolUseId === "pt-old" + ? bindingOf({ + parentToolUseId: "pt-old", + status: "ok", + task: "first round", + taskId: OLD, + childConversationId: 40, + }) + : parentToolUseId === "pt-next" + ? bindingOf({ + parentToolUseId: "pt-next", + status: "running", + task: "write the follow-up", + taskId: NEXT, + childConversationId: 99, + childConnectionId: "c-next", + }) + : undefined, + detail: null, + loading: false, + error: null, + })) + render( + + + + ) + const rows = [...document.querySelectorAll("li")] + expect(rows).toHaveLength(4) + expect(rows[0]).toHaveTextContent(`#${OLD.slice(0, 8)}`) + expect(rows[0]).toHaveTextContent("done") + expect(rows[0]).not.toHaveTextContent("continued") + expect(rows[0]).toHaveTextContent("first round done") + expect(rows[2]).toHaveTextContent(`#${NEXT.slice(0, 8)}`) + expect(rows[2]).toHaveTextContent("continued") + expect(rows[2]).toHaveTextContent("write the follow-up") + expect(rows[2]).toHaveTextContent("2 steps") + expect(rows[2]).not.toHaveTextContent(`#${OLD.slice(0, 8)}`) + expect( + document.querySelector('[data-testid="resumed-delegation-card"]') + ).toBeNull() + }) + + it("keeps the finished card done while the new round waits", () => { + mockedHook.mockImplementation((parentToolUseId) => ({ + binding: + parentToolUseId === "pt-old" + ? bindingOf({ + parentToolUseId: "pt-old", + status: "ok", + taskId: OLD, + childConnectionId: "c-shared", + childConversationId: 40, + }) + : bindingOf({ + parentToolUseId: "pt-next", + status: "running", + task: "write the follow-up", + taskId: NEXT, + childConnectionId: "c-shared", + childConversationId: 99, + }), + detail: null, + loading: false, + error: null, + })) + mockChildConnection = childConnWith({ + connectionId: "c-shared", + pendingPermission: { + request_id: "req-next", + tool_call: { title: "Run bash", kind: "execute" }, + options: [{ id: "approve", label: "Approve", kind: "allow_once" }], + }, + }) + render( + + + + ) + const [oldRow, nextRow] = [...document.querySelectorAll("li")] + expect(oldRow).toHaveTextContent("done") + expect(oldRow).not.toHaveTextContent("awaiting approval") + expect(nextRow).toHaveTextContent("awaiting approval") + expect(nextRow).toHaveAttribute("data-status", "awaiting") + }) + + it("does not paint the source id when the continue failed to mint one", () => { + render( + + + + ) + expect(screen.queryByText(`#${OLD.slice(0, 8)}`)).not.toBeInTheDocument() + expect(screen.getByText("task still running")).toBeInTheDocument() + expect(screen.getByText("write the follow-up")).toBeInTheDocument() + }) +}) + describe("delegation row: the task it was handed", () => { // Longer than the title line shows: line breaks included. const TASK = diff --git a/src/components/message/activity/rows/delegation-rows.tsx b/src/components/message/activity/rows/delegation-rows.tsx index c277e908e1..74dbc96731 100644 --- a/src/components/message/activity/rows/delegation-rows.tsx +++ b/src/components/message/activity/rows/delegation-rows.tsx @@ -20,7 +20,11 @@ import { useTruncation } from "@/hooks/use-truncation" import { previewLine } from "@/lib/activity/format" import type { ActivityStep, StepStatus } from "@/lib/activity/types" import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" -import { delegateCallTaskId } from "@/lib/delegation-card" +import { + delegateCallTaskId, + isContinueDelegationToolName, +} from "@/lib/delegation-card" +import { normalizeToolName } from "@/lib/tool-call-normalization" import { buildDelegationTaskRows, deriveBadge, @@ -62,6 +66,7 @@ export const DelegationRow = memo(function DelegationRow({ step: ToolStep }) { const t = useTranslations("Folder.chat.activity") + const tDelegation = useTranslations("Folder.chat.delegation") const statusText = useStatusText() const { pinnedOnly } = useActivityRender() const { part } = step @@ -188,6 +193,11 @@ export const DelegationRow = memo(function DelegationRow({ #{model.taskId.slice(0, 8)} ) : null} + {isContinueDelegationToolName(normalizeToolName(part.toolName)) ? ( + + {tDelegation("continued")} + + ) : null} {progress && progress.steps > 0 ? ( // Pulled in by the space after its dot, so the dot sits centred // between the id and the count. diff --git a/src/components/message/codeg-mcp-tool-card.tsx b/src/components/message/codeg-mcp-tool-card.tsx index 2480d4dd72..385f03b6dc 100644 --- a/src/components/message/codeg-mcp-tool-card.tsx +++ b/src/components/message/codeg-mcp-tool-card.tsx @@ -2,8 +2,8 @@ /** * Inline card for the codeg-mcp *workbench* companion tools — `get_session_info`, - * `task_progress`, `task_complete`, `create_automation`, `create_work_task` and - * `resume_delegation`. + * `task_progress`, `task_complete`, `create_automation`, `create_work_task`, + * `resume_delegation` and, only as a fallback, `continue_delegation`. * * One collapsed line framed around what the agent was actually doing ("读取会话 * #2122", "进度:tests passing", "任务完成 · 成功"), with a status badge and an @@ -28,6 +28,7 @@ import { CircleAlert, CircleCheckBig, ClipboardList, + IterationCcw, RotateCcw, Signpost, } from "lucide-react" @@ -72,6 +73,7 @@ export const CODEG_MCP_TOOL_ICONS: Record< create_automation: CalendarClock, create_work_task: ClipboardList, resume_delegation: RotateCcw, + continue_delegation: IterationCcw, } /** The panel's Markdown blocks: the report and the result share one look, @@ -164,6 +166,15 @@ export function CodegMcpToolCard({ return model.detail ? t("resumeDelegation", { task: model.detail }) : t("resumeDelegationNoId") + case "continue_delegation": { + // The minted id, when the ack carried one, is the task this card is + // about. Otherwise the headline must not dress the source id up as + // the new one — the panel names that source separately. + const task = model.continuedTaskId + return task + ? t("continueDelegation", { task }) + : t("continueDelegationNoId") + } } }, [t, tool, model]) @@ -171,7 +182,12 @@ export function CodegMcpToolCard({ const isError = model.status === "err" const isRunning = model.status === "running" const resultText = model.resultText?.trim() ?? "" - const prompt = model.prompt?.trim() ?? "" + const prompt = + tool === "continue_delegation" ? "" : (model.prompt?.trim() ?? "") + const continuedTask = + tool === "continue_delegation" ? (model.prompt?.trim() ?? "") : "" + const sourceTaskId = + tool === "continue_delegation" ? (model.sourceTaskId ?? "") : "" // The progress message / completion summary, which the row cuts off at one // line — the panel is where it can be read whole. const report = @@ -184,7 +200,12 @@ export function CodegMcpToolCard({ // worth reading in full, and both arrive with the arguments, long before any // result. Past that, anything that came back is worth a look. const expandable = - resultText !== "" || prompt !== "" || report !== "" || ack !== null + resultText !== "" || + prompt !== "" || + continuedTask !== "" || + sourceTaskId !== "" || + report !== "" || + ack !== null // `title` restores the full text on hover: a long progress message or task // summary is CSS-truncated here, and the panel below has to be opened to @@ -297,6 +318,31 @@ export function CodegMcpToolCard({ {/* The instruction the automation / board task will run. Shown as plain pre-wrapped text, NOT Markdown: it is an argument the user is auditing, so it must read exactly as it was sent. */} + {sourceTaskId !== "" && ( +
+
+ {t("continueSourceTask")} +
+
+ {sourceTaskId} +
+
+ )} + {continuedTask !== "" && ( +
+
+ {t("continueTask")} +
+ +
+ {continuedTask} +
+
+
+ )} {prompt !== "" && (
diff --git a/src/components/message/delegation-status-badge.tsx b/src/components/message/delegation-status-badge.tsx index 0e14032b2a..7e1c81b290 100644 --- a/src/components/message/delegation-status-badge.tsx +++ b/src/components/message/delegation-status-badge.tsx @@ -177,6 +177,22 @@ export function ErrorLabel({ code }: { code?: string }) { return <>{t("assistants_unsupported")} case "assistant_support_unknown": return <>{t("assistant_support_unknown")} + case "task_running": + return <>{t("task_running")} + case "source_not_terminal": + return <>{t("source_not_terminal")} + case "continuation_conflict": + return <>{t("continuation_conflict")} + case "task_not_latest": + return <>{t("task_not_latest")} + case "session_id_missing": + return <>{t("session_id_missing")} + case "session_cwd_missing": + return <>{t("session_cwd_missing")} + case "session_load_failed": + return <>{t("session_load_failed")} + case "session_busy": + return <>{t("session_busy")} default: return <>{t("default")} } diff --git a/src/components/message/message-list-view.test.tsx b/src/components/message/message-list-view.test.tsx index f616b54808..78e7ad36c5 100644 --- a/src/components/message/message-list-view.test.tsx +++ b/src/components/message/message-list-view.test.tsx @@ -551,6 +551,34 @@ describe("extractDelegationSources", () => { // one contains no `delegate_to_agent` call at all (the original is in an // earlier turn), so without this arm a resumed sub-agent would be missing // from the overlay for its whole second run. + it("collects a continue_delegation as its own start, without a task hint", () => { + const input = JSON.stringify({ + task_id: "old-task", + task: "write the follow-up", + }) + const output = JSON.stringify({ + structuredContent: { task_id: "new-task", status: "running" }, + }) + const sources = extractDelegationSources([ + toolCall("tu-old", "delegate_to_agent", '{"task":"first"}'), + { + ...toolCall("tu-next", "mcp__codeg-mcp__continue_delegation", input), + output, + meta: { + "codeg.delegation": { status: "running", task_id: "new-task" }, + }, + }, + ]) + expect(sources).toHaveLength(2) + expect(sources[1]).toMatchObject({ + parentToolUseId: "tu-next", + input, + output, + }) + expect(sources[1].taskIdHint).toBeUndefined() + expect(sources[0].parentToolUseId).toBe("tu-old") + }) + it("collects a resume_delegation call keyed by the task id in its arguments", () => { const sources = extractDelegationSources([ toolCall("tu-resume", "resume_delegation", '{"task_id":"task-abc"}'), @@ -680,6 +708,49 @@ describe("extractMessageDelegationSources", () => { }) // That round put the sub-agent back to work, wherever it was started. + it("lists a continue beside the delegate it follows, by call id", () => { + const { byUserTurnId } = extractMessageDelegationSources([ + message("u1"), + reply("a1", [ + delegate("tu-1", { + output: JSON.stringify({ task_id: "task-1", status: "completed" }), + }), + { + type: "tool-call", + toolCallId: "tu-next", + toolName: "continue_delegation", + input: JSON.stringify({ + task_id: "task-1", + task: "write the follow-up", + }), + state: "output-available", + output: JSON.stringify({ + task_id: "task-2", + status: "running", + }), + }, + { + type: "tool-call", + toolCallId: "tu-next", + toolName: "continue_delegation", + input: JSON.stringify({ + task_id: "task-1", + task: "write the follow-up", + }), + state: "output-available", + output: JSON.stringify({ + task_id: "task-2", + status: "running", + text: "later copy", + }), + }, + ]), + ]) + expect(ids(byUserTurnId.get("u1"))).toEqual(["tu-1", "tu-next"]) + expect(byUserTurnId.get("u1")?.[1].taskIdHint).toBeUndefined() + expect(byUserTurnId.get("u1")?.[1].output).toContain("later copy") + }) + it("lists a resume under the message whose round issued it", () => { const { byUserTurnId } = extractMessageDelegationSources([ message("u1"), diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 9ae0acf0d1..8a29eb332d 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -57,6 +57,7 @@ import { normalizeToolName } from "@/lib/tool-call-normalization" import { parseResumeTaskId } from "@/lib/codeg-mcp-tool" import { isCodeModeDelegationCall, + isContinueDelegationToolName, isDelegateToAgentToolName, isRefusedResume, parseDelegateTaskId, @@ -501,8 +502,12 @@ function collectDelegationSources( // `code` string, so the tool name alone can't classify them. if ( isDelegateToAgentToolName(name) || + isContinueDelegationToolName(name) || isCodeModeDelegationCall(name, part.input ?? null) ) { + // A continue is a NEW start: its own tool call, its own task text, + // and — once the ack lands — its own task id. No `taskIdHint`: the + // argument `task_id` is the previous round's source, not this card's. out.push({ parentToolUseId: part.toolCallId, input: part.input ?? null, diff --git a/src/components/message/message-nav-entries.test.ts b/src/components/message/message-nav-entries.test.ts index 854910ffc5..255de29d8a 100644 --- a/src/components/message/message-nav-entries.test.ts +++ b/src/components/message/message-nav-entries.test.ts @@ -311,6 +311,36 @@ describe("buildMessageNavEntries", () => { expect(entries[0].subAgents[0].input).toBe(input) }) + it("lists a continue_delegation the outline shows started a new round", () => { + const entries = buildMessageNavEntries({ + earlierTurns: [ + user("turn-0", "first"), + delegations("turn-1", [ + { id: "started", meta: DELEGATION_META }, + { + id: "continued", + name: "mcp__codeg-mcp__continue_delegation", + meta: { + "codeg.delegation": { + status: "running", + task_id: "new-task", + child_conversation_id: 8, + }, + }, + }, + ]), + ], + earlierUserCount: 1, + loadedTurns: [], + threadIndexByTurnId: new Map(), + }) + expect(entries[0].subAgents.map((s) => s.parentToolUseId)).toEqual([ + "started", + "continued", + ]) + expect(entries[0].subAgents[1].taskIdHint).toBeUndefined() + }) + it("collects a round's sub-agents across the window start", () => { const entries = buildMessageNavEntries({ earlierTurns: [ diff --git a/src/components/message/message-nav-entries.ts b/src/components/message/message-nav-entries.ts index c1e2e2419c..fabe40f3f9 100644 --- a/src/components/message/message-nav-entries.ts +++ b/src/components/message/message-nav-entries.ts @@ -2,6 +2,7 @@ import type { MessageNavEntry } from "@/components/message/conversation-message- import type { DelegationCardSource } from "@/hooks/use-delegation-card-model" import { isCodeModeDelegationCall, + isContinueDelegationToolName, isDelegateToAgentToolName, parseDelegationMeta, } from "@/lib/delegation-card" @@ -126,6 +127,7 @@ function outlineTurnSubAgents( const name = normalizeToolName(block.tool_name) if ( !isDelegateToAgentToolName(name) && + !isContinueDelegationToolName(name) && !isCodeModeDelegationCall(name, block.input_preview) ) { continue diff --git a/src/components/message/resumed-delegation-card.test.tsx b/src/components/message/resumed-delegation-card.test.tsx index 35f7e76e2b..638f13917b 100644 --- a/src/components/message/resumed-delegation-card.test.tsx +++ b/src/components/message/resumed-delegation-card.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" +import { ContentPartsRenderer } from "./content-parts-renderer" import { ResumedDelegationCard } from "./resumed-delegation-card" import enMessages from "@/i18n/messages/en.json" import type { DelegationBinding } from "@/contexts/delegation-context" @@ -77,6 +78,54 @@ const PERSISTED_ACK = JSON.stringify({ task_id: TASK_ID, }) +describe("continue_delegation stays off the resume card", () => { + beforeEach(() => { + mockedSubSession.mockReset() + mockedSubSession.mockReturnValue({ + binding: undefined, + detail: null, + loading: false, + error: null, + }) + }) + + it("renders a new delegation row for a successful continue", () => { + const next = "c0ffee01-1111-2222-3333-444444444444" + renderWithIntl( + + ) + expect( + screen.queryByTestId("resumed-delegation-card") + ).not.toBeInTheDocument() + expect(screen.queryByTitle("Resumed")).not.toBeInTheDocument() + expect(screen.getByText("continued")).toBeInTheDocument() + expect(screen.getByText(`#${next.slice(0, 8)}`)).toBeInTheDocument() + expect( + screen.queryByText(`#${TASK_ID.slice(0, 8)}`) + ).not.toBeInTheDocument() + }) +}) + describe("ResumedDelegationCard", () => { beforeEach(() => { mockedSubSession.mockReset() diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts index da07b13693..ef10e50ceb 100644 --- a/src/i18n/messages.test.ts +++ b/src/i18n/messages.test.ts @@ -110,6 +110,11 @@ describe("Arabic keeps a task id one left-to-right token", () => { "#14be49e7", ], ["Folder.chat.delegation.cancelTask", { task: "#14be49e7" }, "#14be49e7"], + [ + "Folder.chat.codegMcpTool.continueDelegation", + { task: "#c0ffee01" }, + "#c0ffee01", + ], ] as const)("%s", (key, values, id) => { expect(t(key as never, values as never)).toContain(`\u2066${id}\u2069`) }) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 499ff51f81..7b6934f964 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3768,6 +3768,7 @@ "unknownAgent": "وكيل فرعي", "openDetail": "عرض المحادثة", "resumed": "تم الاستئناف", + "continued": "جولة جديدة", "resumeReasonLabel": "سبب الاستئناف", "detailTitle": "محادثة الوكيل الفرعي", "detailDescription": "عرض للقراءة فقط لمحادثة الوكيل الفرعي المُفوَّض.", @@ -3804,7 +3805,15 @@ "unknown": "مهمة غير معروفة", "assistant_not_found": "المساعد غير موجود", "assistants_unsupported": "يلزم إعادة التشغيل", - "assistant_support_unknown": "لا استجابة" + "assistant_support_unknown": "لا استجابة", + "task_running": "المهمة ما زالت قيد التشغيل", + "source_not_terminal": "الجولة السابقة لم تنته", + "continuation_conflict": "جولة أخرى قيد التنفيذ", + "task_not_latest": "ليست أحدث جولة", + "session_id_missing": "معرّف الجلسة مفقود", + "session_cwd_missing": "دليل العمل مفقود", + "session_load_failed": "تعذّر تحميل الجلسة", + "session_busy": "الجلسة مشغولة" } }, "unknownAssistant": "المساعد #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "إنشاء مهمة عمل", "resumeDelegation": "استئناف المهمة {task}", "resumeDelegationNoId": "استئناف مهمة مفوضة", + "continueDelegation": "استُكملت كمهمة \u2066{task}\u2069", + "continueDelegationNoId": "متابعة مهمة مفوضة", + "continueSourceTask": "المهمة السابقة", + "continueTask": "مهمة جديدة", "promptLabel": "التعليمات", "ackRecorded": "سُجِّل في مهمة العمل", "ackNotRecorded": "لم يُسجَّل في مهمة العمل", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 3273f7bb28..66251f9c8f 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3768,6 +3768,7 @@ "unknownAgent": "Sub-Agent", "openDetail": "Konversation anzeigen", "resumed": "Fortgesetzt", + "continued": "neue Runde", "resumeReasonLabel": "Grund der Fortsetzung", "detailTitle": "Unteragent-Konversation", "detailDescription": "Schreibgeschützte Ansicht der delegierten Unteragent-Konversation.", @@ -3804,7 +3805,15 @@ "unknown": "unbekannte Aufgabe", "assistant_not_found": "Assistent nicht gefunden", "assistants_unsupported": "Neustart nötig", - "assistant_support_unknown": "keine Antwort" + "assistant_support_unknown": "keine Antwort", + "task_running": "Aufgabe läuft noch", + "source_not_terminal": "vorherige Runde nicht beendet", + "continuation_conflict": "eine andere Runde läuft", + "task_not_latest": "nicht die letzte Runde", + "session_id_missing": "Sitzungs-ID fehlt", + "session_cwd_missing": "Arbeitsverzeichnis fehlt", + "session_load_failed": "Sitzung konnte nicht geladen werden", + "session_busy": "Sitzung ist belegt" } }, "unknownAssistant": "Assistent #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "Arbeitsaufgabe wird erstellt", "resumeDelegation": "Aufgabe {task} wird fortgesetzt", "resumeDelegationNoId": "Delegierte Aufgabe wird fortgesetzt", + "continueDelegation": "Fortgeführt als Aufgabe {task}", + "continueDelegationNoId": "Delegierte Aufgabe wird weitergeführt", + "continueSourceTask": "Vorherige Aufgabe", + "continueTask": "Neue Aufgabe", "promptLabel": "Anweisung", "ackRecorded": "In der Arbeitsaufgabe erfasst", "ackNotRecorded": "Nicht in der Arbeitsaufgabe erfasst", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 37222ab65a..b0c68e840d 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3768,6 +3768,7 @@ "unknownAgent": "Sub-agent", "openDetail": "Open conversation", "resumed": "Resumed", + "continued": "continued", "resumeReasonLabel": "Resume reason", "detailTitle": "Sub-agent conversation", "detailDescription": "Read-only view of the delegated sub-agent's conversation.", @@ -3804,7 +3805,15 @@ "unknown": "unknown task", "assistant_not_found": "assistant not found", "assistants_unsupported": "needs restart", - "assistant_support_unknown": "no answer" + "assistant_support_unknown": "no answer", + "task_running": "task still running", + "source_not_terminal": "previous round not finished", + "continuation_conflict": "another round is in progress", + "task_not_latest": "not the latest round", + "session_id_missing": "session id missing", + "session_cwd_missing": "working directory missing", + "session_load_failed": "session failed to load", + "session_busy": "session is busy" } }, "unknownAssistant": "Assistant #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "Creating a work task", "resumeDelegation": "Resuming task {task}", "resumeDelegationNoId": "Resuming a delegated task", + "continueDelegation": "Continued as task {task}", + "continueDelegationNoId": "Continuing a delegated task", + "continueSourceTask": "Previous task", + "continueTask": "New task", "promptLabel": "Prompt", "ackRecorded": "Recorded on the work task", "ackNotRecorded": "Not recorded on the work task", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e219d04824..85e9ee23f7 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3768,6 +3768,7 @@ "unknownAgent": "Sub-agente", "openDetail": "Ver conversación", "resumed": "Reanudado", + "continued": "nueva ronda", "resumeReasonLabel": "Motivo de reanudación", "detailTitle": "Conversación del subagente", "detailDescription": "Vista de solo lectura de la conversación del subagente delegado.", @@ -3804,7 +3805,15 @@ "unknown": "tarea desconocida", "assistant_not_found": "asistente no encontrado", "assistants_unsupported": "requiere reinicio", - "assistant_support_unknown": "sin respuesta" + "assistant_support_unknown": "sin respuesta", + "task_running": "la tarea sigue en curso", + "source_not_terminal": "la ronda anterior no terminó", + "continuation_conflict": "hay otra ronda en curso", + "task_not_latest": "no es la última ronda", + "session_id_missing": "falta el id de sesión", + "session_cwd_missing": "falta el directorio de trabajo", + "session_load_failed": "no se pudo cargar la sesión", + "session_busy": "la sesión está ocupada" } }, "unknownAssistant": "Asistente #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "Creando una tarea de trabajo", "resumeDelegation": "Reanudando la tarea {task}", "resumeDelegationNoId": "Reanudando una tarea delegada", + "continueDelegation": "Continuada como la tarea {task}", + "continueDelegationNoId": "Continuando una tarea delegada", + "continueSourceTask": "Tarea anterior", + "continueTask": "Nueva tarea", "promptLabel": "Instrucción", "ackRecorded": "Registrado en la tarea de trabajo", "ackNotRecorded": "No registrado en la tarea de trabajo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 387c94e737..b4328c5234 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3768,6 +3768,7 @@ "unknownAgent": "Sous-agent", "openDetail": "Voir la conversation", "resumed": "Repris", + "continued": "nouveau tour", "resumeReasonLabel": "Motif de la reprise", "detailTitle": "Conversation du sous-agent", "detailDescription": "Vue en lecture seule de la conversation du sous-agent délégué.", @@ -3804,7 +3805,15 @@ "unknown": "tâche inconnue", "assistant_not_found": "assistant introuvable", "assistants_unsupported": "redémarrage requis", - "assistant_support_unknown": "pas de réponse" + "assistant_support_unknown": "pas de réponse", + "task_running": "tâche encore en cours", + "source_not_terminal": "tour précédent non terminé", + "continuation_conflict": "un autre tour est en cours", + "task_not_latest": "pas le dernier tour", + "session_id_missing": "identifiant de session manquant", + "session_cwd_missing": "répertoire de travail manquant", + "session_load_failed": "échec du chargement de la session", + "session_busy": "session occupée" } }, "unknownAssistant": "Assistant #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "Création d’une tâche", "resumeDelegation": "Reprise de la tâche {task}", "resumeDelegationNoId": "Reprise d'une tâche déléguée", + "continueDelegation": "Poursuivie comme tâche {task}", + "continueDelegationNoId": "Poursuite d'une tâche déléguée", + "continueSourceTask": "Tâche précédente", + "continueTask": "Nouvelle tâche", "promptLabel": "Instruction", "ackRecorded": "Enregistré dans la tâche", "ackNotRecorded": "Non enregistré dans la tâche", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index be20a542b8..3e03ec82f6 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3768,6 +3768,7 @@ "unknownAgent": "サブエージェント", "openDetail": "会話を表示", "resumed": "再開済み", + "continued": "新しいラウンド", "resumeReasonLabel": "再開理由", "detailTitle": "サブエージェントの会話", "detailDescription": "委任されたサブエージェントの会話を読み取り専用で表示します。", @@ -3804,7 +3805,15 @@ "unknown": "不明なタスク", "assistant_not_found": "アシスタントなし", "assistants_unsupported": "再起動が必要", - "assistant_support_unknown": "応答なし" + "assistant_support_unknown": "応答なし", + "task_running": "タスクは実行中", + "source_not_terminal": "前のラウンドが未完了", + "continuation_conflict": "別のラウンドが進行中", + "task_not_latest": "最新のラウンドではない", + "session_id_missing": "セッション id がない", + "session_cwd_missing": "作業ディレクトリがない", + "session_load_failed": "セッションの読み込みに失敗", + "session_busy": "セッションが使用中" } }, "unknownAssistant": "アシスタント #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "作業タスクを作成中", "resumeDelegation": "タスク {task} を再開中", "resumeDelegationNoId": "委任タスクを再開中", + "continueDelegation": "タスク {task} として続行", + "continueDelegationNoId": "委任タスクを続行中", + "continueSourceTask": "前のタスク", + "continueTask": "新しいタスク", "promptLabel": "プロンプト", "ackRecorded": "作業タスクに記録しました", "ackNotRecorded": "作業タスクに記録されていません", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 7e7606ae33..4acc6f8144 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3768,6 +3768,7 @@ "unknownAgent": "하위 에이전트", "openDetail": "대화 보기", "resumed": "재개됨", + "continued": "새 라운드", "resumeReasonLabel": "재개 사유", "detailTitle": "서브에이전트 대화", "detailDescription": "위임된 서브에이전트 대화를 읽기 전용으로 봅니다.", @@ -3804,7 +3805,15 @@ "unknown": "알 수 없는 작업", "assistant_not_found": "어시스턴트 없음", "assistants_unsupported": "재시작 필요", - "assistant_support_unknown": "응답 없음" + "assistant_support_unknown": "응답 없음", + "task_running": "작업이 아직 실행 중", + "source_not_terminal": "이전 라운드가 끝나지 않음", + "continuation_conflict": "다른 라운드가 진행 중", + "task_not_latest": "최신 라운드가 아님", + "session_id_missing": "세션 id 없음", + "session_cwd_missing": "작업 디렉터리 없음", + "session_load_failed": "세션을 불러오지 못함", + "session_busy": "세션이 사용 중" } }, "unknownAssistant": "어시스턴트 #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "작업 항목 생성 중", "resumeDelegation": "작업 {task} 재개 중", "resumeDelegationNoId": "위임 작업 재개 중", + "continueDelegation": "작업 {task}(으)로 이어서 진행", + "continueDelegationNoId": "위임 작업을 이어서 진행 중", + "continueSourceTask": "이전 작업", + "continueTask": "새 작업", "promptLabel": "프롬프트", "ackRecorded": "작업 항목에 기록됨", "ackNotRecorded": "작업 항목에 기록되지 않음", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a8d215959c..ac7062c2f1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3768,6 +3768,7 @@ "unknownAgent": "Subagente", "openDetail": "Ver conversa", "resumed": "Retomado", + "continued": "nova rodada", "resumeReasonLabel": "Motivo da retomada", "detailTitle": "Conversa do subagente", "detailDescription": "Visualização somente leitura da conversa do subagente delegado.", @@ -3804,7 +3805,15 @@ "unknown": "tarefa desconhecida", "assistant_not_found": "assistente não encontrado", "assistants_unsupported": "requer reinício", - "assistant_support_unknown": "sem resposta" + "assistant_support_unknown": "sem resposta", + "task_running": "a tarefa ainda está em execução", + "source_not_terminal": "a rodada anterior não terminou", + "continuation_conflict": "outra rodada está em andamento", + "task_not_latest": "não é a rodada mais recente", + "session_id_missing": "id da sessão ausente", + "session_cwd_missing": "diretório de trabalho ausente", + "session_load_failed": "falha ao carregar a sessão", + "session_busy": "sessão ocupada" } }, "unknownAssistant": "Assistente #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "Criando uma tarefa de trabalho", "resumeDelegation": "Retomando a tarefa {task}", "resumeDelegationNoId": "Retomando uma tarefa delegada", + "continueDelegation": "Continuada como a tarefa {task}", + "continueDelegationNoId": "Continuando uma tarefa delegada", + "continueSourceTask": "Tarefa anterior", + "continueTask": "Nova tarefa", "promptLabel": "Instrução", "ackRecorded": "Registrado na tarefa de trabalho", "ackNotRecorded": "Não registrado na tarefa de trabalho", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index a73214918f..c5d920a586 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3768,6 +3768,7 @@ "unknownAgent": "子智能体", "openDetail": "查看会话", "resumed": "已恢复", + "continued": "新一轮", "resumeReasonLabel": "恢复原因", "detailTitle": "子智能体会话", "detailDescription": "只读查看委托给子智能体的会话内容。", @@ -3804,7 +3805,15 @@ "unknown": "未知任务", "assistant_not_found": "助手不存在", "assistants_unsupported": "需要重启", - "assistant_support_unknown": "未响应" + "assistant_support_unknown": "未响应", + "task_running": "任务仍在运行", + "source_not_terminal": "上一轮尚未结束", + "continuation_conflict": "已有一轮进行中", + "task_not_latest": "不是最新一轮", + "session_id_missing": "缺少会话 id", + "session_cwd_missing": "缺少工作目录", + "session_load_failed": "会话加载失败", + "session_busy": "会话正忙" } }, "unknownAssistant": "助手 #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "创建待办任务", "resumeDelegation": "恢复任务 {task}", "resumeDelegationNoId": "恢复委派任务", + "continueDelegation": "已继续为任务 {task}", + "continueDelegationNoId": "正在继续委派任务", + "continueSourceTask": "上一轮任务", + "continueTask": "新任务", "promptLabel": "指令", "ackRecorded": "已记录到待办任务", "ackNotRecorded": "未记录到待办任务", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 3f4a1dc324..5ac885ad63 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3768,6 +3768,7 @@ "unknownAgent": "子智慧體", "openDetail": "檢視會話", "resumed": "已恢復", + "continued": "新一輪", "resumeReasonLabel": "恢復原因", "detailTitle": "子智慧體會話", "detailDescription": "唯讀檢視委派給子智慧體的會話內容。", @@ -3804,7 +3805,15 @@ "unknown": "未知任務", "assistant_not_found": "助手不存在", "assistants_unsupported": "需要重新啟動", - "assistant_support_unknown": "未回應" + "assistant_support_unknown": "未回應", + "task_running": "任務仍在執行", + "source_not_terminal": "上一輪尚未結束", + "continuation_conflict": "已有一輪進行中", + "task_not_latest": "不是最新一輪", + "session_id_missing": "缺少會話 id", + "session_cwd_missing": "缺少工作目錄", + "session_load_failed": "會話載入失敗", + "session_busy": "會話忙碌中" } }, "unknownAssistant": "助手 #{id}", @@ -4116,6 +4125,10 @@ "createWorkTaskNoTitle": "建立待辦任務", "resumeDelegation": "恢復任務 {task}", "resumeDelegationNoId": "恢復委派任務", + "continueDelegation": "已繼續為任務 {task}", + "continueDelegationNoId": "正在繼續委派任務", + "continueSourceTask": "上一輪任務", + "continueTask": "新任務", "promptLabel": "指令", "ackRecorded": "已記錄到待辦任務", "ackNotRecorded": "未記錄到待辦任務", diff --git a/src/lib/activity/describe-tool-call.test.ts b/src/lib/activity/describe-tool-call.test.ts index 7914f02e53..dafb63b4d6 100644 --- a/src/lib/activity/describe-tool-call.test.ts +++ b/src/lib/activity/describe-tool-call.test.ts @@ -87,6 +87,24 @@ describe("toolStepKind", () => { { task_id: "t" }, "delegation-resume", ], + // A continue is a new delegation row, never a resume of the old task. + [ + "mcp__codeg-mcp__continue_delegation", + { task_id: "old", task: "next" }, + "delegation", + ], + [ + "codeg-mcp/continue_delegation", + { task_id: "old", task: "next" }, + "delegation", + ], + [ + "execute", + { + code: 'await tools["codeg-mcp"].continue_delegation({ task_id: "old", task: `next` })', + }, + "delegation", + ], ["mcp__codeg-mcp__check_user_feedback", {}, "feedback"], ["mcp__codeg-mcp__task_progress", {}, "workbench"], // codex @@ -117,6 +135,18 @@ describe("toolStepKind", () => { expect(toolStepKind(call(name, input))).toBe(expected) }) + it("keeps a continue without a tool call id off the resume card", () => { + expect( + toolStepKind( + call( + "continue_delegation", + { task_id: "old", task: "next" }, + { toolCallId: "" } + ) + ) + ).toBe("workbench") + }) + it("routes a compaction marker regardless of the tool name", () => { expect( toolStepKind(call("tool", null, { meta: { contextCompaction: true } })) diff --git a/src/lib/activity/describe-tool-call.ts b/src/lib/activity/describe-tool-call.ts index 9b2150ef68..abbb271611 100644 --- a/src/lib/activity/describe-tool-call.ts +++ b/src/lib/activity/describe-tool-call.ts @@ -21,6 +21,7 @@ import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" import { isContextCompactionMeta } from "@/lib/context-compaction" import { isCodeModeDelegationCall, + isContinueDelegationToolName, isDelegateToAgentToolName, } from "@/lib/delegation-card" import { @@ -158,6 +159,12 @@ export function toolStepKind(part: AdaptedToolCallPart): ToolStepKind { if (isDelegateToAgentToolName(name)) { return part.toolCallId ? "delegation" : "generic" } + if (isContinueDelegationToolName(name)) { + // A continue with its own tool call id is a new delegation row, never a + // resume of the previous task. Without an id there is no live binding to + // draw, so the workbench card states the source id and the new task. + return part.toolCallId ? "delegation" : "workbench" + } if (name === "resume_delegation") { return part.toolCallId ? "delegation-resume" : "workbench" } diff --git a/src/lib/adapters/tool-kind-classifier.ts b/src/lib/adapters/tool-kind-classifier.ts index fa370d051f..374ee02b71 100644 --- a/src/lib/adapters/tool-kind-classifier.ts +++ b/src/lib/adapters/tool-kind-classifier.ts @@ -15,6 +15,7 @@ import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" * name); the suffix regexes cover the historical path (raw tool name). */ const DELEGATE_TO_AGENT_SUFFIX_RE = /[^a-z0-9]delegate_to_agent$/ +const CONTINUE_DELEGATION_SUFFIX_RE = /[^a-z0-9]continue_delegation$/ const GET_DELEGATION_STATUS_SUFFIX_RE = /[^a-z0-9]get_delegation_status$/ const CANCEL_DELEGATION_SUFFIX_RE = /[^a-z0-9]cancel_delegation$/ const CREATE_GOAL_SUFFIX_RE = /[^a-z0-9]create_goal$/ @@ -42,9 +43,10 @@ const CODEG_MCP_WORKBENCH_NAMES: ReadonlySet = new Set([ "create_automation", "create_work_task", "resume_delegation", + "continue_delegation", ]) const CODEG_MCP_WORKBENCH_SUFFIX_RE = - /[^a-z0-9](?:get_session_info|task_progress|task_complete|create_automation|create_work_task|resume_delegation)$/ + /[^a-z0-9](?:get_session_info|task_progress|task_complete|create_automation|create_work_task|resume_delegation|continue_delegation)$/ export function isAgentLikeToolName(toolName: string): boolean { const name = toolName.toLowerCase().trim() @@ -54,6 +56,7 @@ export function isAgentLikeToolName(toolName: string): boolean { if (name === COLLAB_AGENT_TOOL_NAME) return true if ( name === "delegate_to_agent" || + name === "continue_delegation" || name === "get_delegation_status" || name === "cancel_delegation" || name === "create_goal" || @@ -79,6 +82,7 @@ export function isAgentLikeToolName(toolName: string): boolean { ) return true if (DELEGATE_TO_AGENT_SUFFIX_RE.test(name)) return true + if (CONTINUE_DELEGATION_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 (CREATE_GOAL_SUFFIX_RE.test(name)) return true diff --git a/src/lib/codeg-mcp-tool.ts b/src/lib/codeg-mcp-tool.ts index a947af335d..40c9ccf56c 100644 --- a/src/lib/codeg-mcp-tool.ts +++ b/src/lib/codeg-mcp-tool.ts @@ -12,6 +12,11 @@ * (`not_resumable`, unknown task): there is no sub-agent to draw then, only a * reason to read. * + * `continue_delegation` is the other exception. A call that carries a tool id + * is a new delegation row (`DelegationRow`); this parser is only the fallback + * when that row has nothing to bind to. Its `task_id` argument is the + * PREVIOUS round's source id, never the new task id the ack mints. + * * The delegation trio (`delegate_to_agent` / `get_delegation_status` / * `cancel_delegation`), `ask_user_question` and `check_user_feedback` each own a * richer, purpose-built card; these five had none, so they fell through to the @@ -32,6 +37,11 @@ */ import { extractEmbeddedJsonObject } from "@/lib/embedded-json" +import { + parseCodeModeContinueSourceTaskId, + parseCodeModeContinueTask, + parseDelegateTaskId, +} from "@/lib/delegation-card" import { peelMcpResultEnvelope } from "@/lib/mcp-result-envelope" import type { ToolCallState } from "@/lib/adapters/ai-elements-adapter" @@ -48,6 +58,7 @@ export const CODEG_MCP_WORKBENCH_TOOLS = [ "create_automation", "create_work_task", "resume_delegation", + "continue_delegation", ] as const export type CodegMcpWorkbenchTool = (typeof CODEG_MCP_WORKBENCH_TOOLS)[number] @@ -113,6 +124,17 @@ export interface CodegMcpToolModel { * would be visible nowhere. */ reason: string | null + /** + * `continue_delegation` only: the previous round's task id, taken from the + * call's `task_id` argument. Never the new task id. `null` otherwise. + */ + sourceTaskId: string | null + /** + * `continue_delegation` only: the task id the ack minted for this round. + * `null` when the call failed before minting one, and for every other tool. + * While set, `detail` is this id rather than `sourceTaskId`. + */ + continuedTaskId: string | null /** * `task_progress` / `task_complete` only: the ack, read from whichever half * of the result the host kept. `null` for every other tool, while the call is @@ -253,6 +275,13 @@ const ARG_SPECS: Record< fields: ["task_id"], detail: (args) => str(args, "task_id"), }, + continue_delegation: { + // Either argument identifies the payload. `detail` stays the SOURCE task + // id — the previous round — and `prompt` holds the new task text. The + // minted task id, when the ack carries one, replaces `detail` below. + fields: ["task_id", "task"], + detail: (args) => str(args, "task_id"), + }, } /** @@ -422,15 +451,33 @@ export function parseCodegMcpToolCall(params: { params.tool === "create_automation" || params.tool === "create_work_task" const reporting = params.tool === "task_progress" || params.tool === "task_complete" + const continuing = params.tool === "continue_delegation" const ack = reporting && !isError ? readTaskAck(params.output) : null const resultText = ack ? null : parseResultText(params.output) + // A successful continue mints a new task id. Prefer that over the source + // id in the arguments; a failure that minted none must keep showing the + // source id as the source, never as if it were the new task. + const continuedTaskId = continuing + ? parseDelegateTaskId(params.output, params.errorText) + : null + const sourceTaskId = + continuing && args + ? (str(args, "task_id") ?? + parseCodeModeContinueSourceTaskId(params.input)) + : null + const continuedTask = continuing + ? ((args ? str(args, "task") : null) ?? + parseCodeModeContinueTask(params.input)) + : null return { - detail: args ? spec.detail(args) : null, + detail: continuedTaskId ?? (args ? spec.detail(args) : sourceTaskId), verdict: params.tool === "task_complete" ? parseVerdict(args) : null, - prompt: authoring && args ? str(args, "prompt") : null, + prompt: (authoring && args ? str(args, "prompt") : null) ?? continuedTask, reason: params.tool === "resume_delegation" && args ? str(args, "reason") : null, + sourceTaskId: continuing ? sourceTaskId : null, + continuedTaskId, ack, resultText: isError ? (params.errorText?.trim() ?? resultText) : resultText, status: isError diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 46bb4ecbc6..7906842746 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -6,7 +6,10 @@ import { isAffirmedResume, isCodeModeDelegationCall, isCodeModeDelegationInput, + isContinueDelegationToolName, + isDelegateToAgentToolName, isRefusedResume, + parseCodeModeContinueSourceTaskId, parseDelegateTaskId, parseDelegationMeta, parseInput, @@ -1092,4 +1095,113 @@ describe("resolveDelegationStatus and a later check", () => { }) ).toBe("waiting") }) + + it("does not reopen a finished card when the shared child is waiting", () => { + const finished = { + status: "ok" as const, + childConnectionId: "child-1", + childConversationId: 7, + taskId: "old-task", + } + expect( + resolveDelegationStatus({ + ...base, + binding: { + parentConnectionId: "p", + parentToolUseId: "old-call", + agentType: "codex", + task: "first round", + errorCode: undefined, + assistantId: null, + assistantName: null, + ...finished, + }, + childAwaitingUser: true, + }) + ).toBe("ok") + expect( + resolveDelegationStatus({ + ...base, + parsedMeta: parseDelegationMeta({ + "codeg.delegation": { status: "completed", task_id: "old-task" }, + }), + childAwaitingUser: true, + }) + ).toBe("ok") + }) + + it("still waits when the current binding is the run that is parked", () => { + expect( + resolveDelegationStatus({ + ...base, + binding: { + parentConnectionId: "p", + parentToolUseId: "new-call", + childConnectionId: "child-2", + childConversationId: 7, + agentType: "codex", + status: "running", + task: "next round", + taskId: "new-task", + assistantId: null, + assistantName: null, + }, + childAwaitingUser: true, + }) + ).toBe("waiting") + }) +}) + +describe("continue_delegation identity", () => { + it.each([ + "continue_delegation", + "mcp__codeg-mcp__continue_delegation", + "codeg-mcp/continue_delegation", + "codeg-mcp.continue_delegation", + "codeg-mcp:continue_delegation", + ])("recognizes %s without treating it as a cold delegate", (name) => { + expect(isContinueDelegationToolName(name)).toBe(true) + expect(isDelegateToAgentToolName(name)).toBe(false) + }) + + it("reads the new task text and not the source id as a task id", () => { + const raw = JSON.stringify({ + task_id: "old-task", + task: "write the follow-up", + }) + expect(parseInput(raw).task).toBe("write the follow-up") + expect(parseDelegateTaskId(raw, null)).toBe("old-task") + const ack = JSON.stringify({ + content: [{ type: "text", text: "Continued. task_id=new-task" }], + structuredContent: { task_id: "new-task", status: "running" }, + }) + expect(parseDelegateTaskId(ack, null)).toBe("new-task") + }) +}) + +describe("code-mode continue_delegation", () => { + const call = + 'await tools["codeg-mcp"].continue_delegation({ task_id: "old-task", task: `follow up\\nnext` })' + + it("recognizes the call and reads task plus the source task id", () => { + const raw = JSON.stringify({ code: call }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + expect(isCodeModeDelegationCall("bash", raw)).toBe(true) + expect(parseInput(raw)).toEqual({ + agentType: null, + assistantId: null, + task: "follow up\nnext", + workingDir: null, + }) + expect(parseCodeModeContinueSourceTaskId(raw)).toBe("old-task") + }) + + it("does not treat a quoted continue call as a tool call", () => { + const raw = JSON.stringify({ + code: 'const note = "tools[\\"codeg-mcp\\"].continue_delegation({ task_id: \\"old\\" })"', + }) + expect(isCodeModeDelegationInput(raw)).toBe(false) + expect(parseCodeModeContinueSourceTaskId(raw)).toBeNull() + expect(parseInput(raw).task).toBeNull() + }) }) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 2fe876b87f..8278f161d8 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -234,13 +234,14 @@ function findDelegationArgs( // opencode code-mode (`execute`) payloads // // opencode 2.x runs MCP tools through its built-in `execute` tool: the model -// writes `tools["codeg-mcp"].delegate_to_agent({…})` as JS source packed into -// a `{code: "…"}` raw input. These helpers recognize such a payload and read +// writes `tools["codeg-mcp"].delegate_to_agent({…})` or +// `tools["codeg-mcp"].continue_delegation({…})` as JS source packed into a +// `{code: "…"}` raw input. These helpers recognize such a payload and read // the argument values back out of the JS source — the same structural scanner // as `acp/lifecycle.rs` (`code_mode_call_span` and friends), so the backend's // registration decision and the frontend's card classification can never // disagree about what counts as a delegation call. Like the Rust side, the -// scanner is STRICT: `tools` must reach `delegate_to_agent(` through a real +// scanner is STRICT: `tools` must reach the method `(` through a real // accessor, strings/templates/comments are skipped (a task text *quoting* a // call is not a call), and every ambiguity degrades to "unreadable" rather // than a guessed value — spread / computed-key / escaped-quoted-key syntax @@ -415,27 +416,46 @@ const CODE_MODE_BRANCH_KEYWORDS = [ "function", ] -/** The first `delegate_to_agent(` call reached via a `tools` accessor: the - * `(open, close)` of its argument list, and whether those arguments may be - * read as the delegation's — it is the only call site in a scan that - * reached the end of the script, or it provably runs before any other (at - * brace depth 0, outside any parameter list or destructuring pattern, with - * nothing before it that branches, defers or hides code in a `${…}`). - * `null` past the scan cap, on unbalanced input before the call, with no - * such call, or when a `/` that is no comment precedes the call: a regex - * literal is indistinguishable from division here, and its body can carry - * the quote or backtick that desynchronizes everything after it. Mirrors - * `code_mode_call_site` in `acp/lifecycle.rs`. */ -function codeModeCallSite( - code: string -): { open: number; close: number; readable: boolean } | null { +/** The methods a code-mode script can use to start a delegation. A + * `continue_delegation` is a new round (new task id), not a resume. */ +const CODE_MODE_DELEGATION_METHODS = [ + "delegate_to_agent", + "continue_delegation", +] as const + +type CodeModeDelegationMethod = (typeof CODE_MODE_DELEGATION_METHODS)[number] + +type CodeModeCallSite = { + open: number + close: number + readable: boolean + method: CodeModeDelegationMethod +} + +/** The first delegation call reached via a `tools` accessor: the `(open, + * close)` of its argument list, which method it called, and whether those + * arguments may be read as the delegation's — it is the only call site in a + * scan that reached the end of the script, or it provably runs before any + * other (at brace depth 0, outside any parameter list or destructuring + * pattern, with nothing before it that branches, defers or hides code in a + * `${…}`). `null` past the scan cap, on unbalanced input before the call, + * with no such call, or when a `/` that is no comment precedes the call: a + * regex literal is indistinguishable from division here, and its body can + * carry the quote or backtick that desynchronizes everything after it. + * Mirrors `code_mode_call_site` in `acp/lifecycle.rs`, extended to the + * continue method the same way. */ +function codeModeCallSite(code: string): CodeModeCallSite | null { if (utf8LengthExceeds(code, CODE_MODE_MAX_CODE_BYTES)) return null let i = 0 let depth = 0 // The `(` / `[` groups open at `i`, innermost last. const groups: string[] = [] let branched = false - let first: { open: number; close: number } | null = null + let first: { + open: number + close: number + method: CodeModeDelegationMethod + } | null = null let runsFirst = false // How many groups enclosing the first site have yet to close; each must // close as a call, a grouping or an array — not as an arrow's parameter @@ -508,9 +528,9 @@ function codeModeCallSite( } } if (isJsWordAt(code, i, "tools")) { - const open = codeModeToolsCallOpen(code, i) - if (open !== null) { - const close = matchingParen(code, open) + const found = codeModeToolsCallOpen(code, i) + if (found !== null) { + const close = matchingParen(code, found.open) if (close === null) { if (first === null) return null counted = false @@ -518,7 +538,7 @@ function codeModeCallSite( } sites += 1 if (first === null) { - first = { open, close } + first = { open: found.open, close, method: found.method } runsFirst = depth === 0 && !branched enclosing = groups.length } @@ -536,11 +556,15 @@ function codeModeCallSite( return { open: first.open, close: first.close, + method: first.method, readable: provable || (sites === 1 && counted), } } -function codeModeToolsCallOpen(code: string, toolsAt: number): number | null { +function codeModeToolsCallOpen( + code: string, + toolsAt: number +): { open: number; method: CodeModeDelegationMethod } | null { let i = toolsAt + "tools".length i = skipJsWsAndComments(code, i) const c = code[i] @@ -566,9 +590,12 @@ function codeModeToolsCallOpen(code: string, toolsAt: number): number | null { } else { return null } - if (!isJsWordAt(code, i, "delegate_to_agent")) return null - i = skipJsWsAndComments(code, i + "delegate_to_agent".length) - return code[i] === "(" ? i : null + const method = CODE_MODE_DELEGATION_METHODS.find((name) => + isJsWordAt(code, i, name) + ) + if (!method) return null + i = skipJsWsAndComments(code, i + method.length) + return code[i] === "(" ? { open: i, method } : null } function matchingParen(code: string, open: number): number | null { @@ -876,7 +903,7 @@ function templateLiteralTextEnd( * card renders exactly when the backend registers the call. */ function codeModeCallOf(parsed: unknown): { code: string - site: { open: number; close: number; readable: boolean } + site: CodeModeCallSite } | null { if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return null @@ -890,8 +917,15 @@ function codeModeCallOf(parsed: unknown): { /** The argument keys the scanner reads; a bare one not followed by `:` is a * SHORTHAND property (or a variable in value position) — either way its * value is a runtime value, and a shorthand after an explicit spelling - * overrides it. */ -const CODE_MODE_ARG_KEYS = ["agent_type", "task", "working_dir", "assistant_id"] + * overrides it. `continue_delegation` adds `task_id` (the previous round's + * source id) — still not the new task id the ack mints. */ +const CODE_MODE_ARG_KEYS = [ + "agent_type", + "task", + "working_dir", + "assistant_id", + "task_id", +] /** Conservative pre-pass over the argument region: `false` when the region * contains syntax whose effect the scanner cannot evaluate. Spread @@ -976,7 +1010,10 @@ function readAssistantIdValue(code: string, at: number): string | null { * unknown and the card renders the generic task row — correlation goes * through the tool_call id binding, not these values, so unlike the backend * key there is nothing to mint wrong. Readable values become card content; - * unreadable ones (interpolation, unknown escapes) stay `null` per field. */ + * unreadable ones (interpolation, unknown escapes) stay `null` per field. + * + * A `continue_delegation` reads only `task` and `task_id`. The latter is the + * previous round's source id, never this card's task id. */ function parseCodeModeInput(parsed: unknown): ParsedInput | null { const call = codeModeCallOf(parsed) if (call === null) return null @@ -993,10 +1030,14 @@ function parseCodeModeInput(parsed: unknown): ParsedInput | null { ) { return EMPTY_PARSED_INPUT } + const taskAt = codeModeFindKey(code, start, close, "task") + const task = taskAt !== null ? objectTaskValue(code, taskAt) : null + if (site.method === "continue_delegation") { + return { ...EMPTY_PARSED_INPUT, task } + } const assistantAt = codeModeFindKey(code, start, close, "assistant_id") const agentAt = codeModeFindKey(code, start, close, "agent_type") const workingAt = codeModeFindKey(code, start, close, "working_dir") - const taskAt = codeModeFindKey(code, start, close, "task") return { agentType: agentAt !== null @@ -1006,11 +1047,58 @@ function parseCodeModeInput(parsed: unknown): ParsedInput | null { assistantAt !== null ? coerceAssistantId(readAssistantIdValue(code, assistantAt)) : null, - task: taskAt !== null ? objectTaskValue(code, taskAt) : null, + task, workingDir: workingAt !== null ? objectStringValue(code, workingAt) : null, } } +/** The new task text a code-mode `continue_delegation` carries, or `null`. */ +export function parseCodeModeContinueTask( + raw: string | null | undefined +): string | null { + if (!raw) return null + const parsed = parseInput(raw) + return isCodeModeContinueInput(raw) ? parsed.task : null +} + +function isCodeModeContinueInput(raw: string): boolean { + if (!raw.includes('"code"')) return false + try { + return ( + codeModeCallOf(JSON.parse(raw))?.site.method === "continue_delegation" + ) + } catch { + return false + } +} + +/** The previous round's task id a code-mode `continue_delegation` names, or + * `null` when the payload is not that call or the value can't be read. Never + * the new task id. */ +export function parseCodeModeContinueSourceTaskId( + raw: string | null | undefined +): string | null { + if (!raw || typeof raw !== "string" || !raw.includes('"code"')) return null + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + const call = codeModeCallOf(parsed) + if (call === null || call.site.method !== "continue_delegation") return null + const { code, site } = call + if ( + !site.readable || + !codeModeSingleObjectArgument(code, site.open, site.close) || + !codeModeArgsTransparent(code, site.open + 1, site.close) + ) { + return null + } + const at = codeModeFindKey(code, site.open + 1, site.close, "task_id") + return at === null ? null : objectStringValue(code, at) +} + /** * Whether a raw tool input is an opencode code-mode `execute` payload whose * `code` source announces a `delegate_to_agent` call. Shape only — classify a @@ -1665,6 +1753,25 @@ export function isDelegateToAgentToolName(name: string): boolean { ) } +/** + * Whether a tool name denotes `continue_delegation`: a new round on an + * existing child session. Same host spellings as `delegate_to_agent`. It is + * not a resume — the call mints its own task id and its own card. + */ +export function isContinueDelegationToolName(name: string): boolean { + const lower = name.toLowerCase() + return ( + lower === "continue_delegation" || + /[^a-z0-9]continue_delegation$/.test(lower) + ) +} + +/** A tool that starts a delegation card: a cold `delegate_to_agent`, or a + * `continue_delegation` that opens the next round. */ +export function isDelegationStartToolName(name: string): boolean { + return isDelegateToAgentToolName(name) || isContinueDelegationToolName(name) +} + /** * Resolve the card status from the live binding / persisted meta / parsed tool * output, in priority order. @@ -1693,9 +1800,16 @@ export function resolveDelegationStatus({ * call reported it (`taskEnding`). */ ended?: { status: "ok" | "err" } | null }): DelegationCardStatus { - // A child awaiting a decision is blocked until the user acts; surface it - // over the plain running state so the card cues opening "查看会话". - if (childAwaitingUser) return "waiting" + // Waiting belongs to the run that is actually parked. A later round on the + // same child session can share (or replace) the connection the finished + // card still names; its permission/question/plan must not flip that card + // back to waiting. With no binding and no meta yet, the child being + // blocked is still the only signal this card has. + const active = + binding?.status === "running" || + (!binding && parsedMeta?.status === "running") || + (!binding && !parsedMeta) + if (childAwaitingUser && active) return "waiting" if (binding) return binding.status if (parsedMeta) return parsedMeta.status if (state === "output-error" || errorText) return "err" diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index 3b85ca0f6c..881f790d2a 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -165,6 +165,33 @@ describe("inferLiveToolName meta.claudeCode.toolName override", () => { ).toBe("delegate_to_agent") }) + it("resolves continue_delegation from meta and title, not as a resume", () => { + for (const candidate of [ + "mcp__codeg-mcp__continue_delegation", + "codeg-mcp/continue_delegation", + "continue_delegation", + ]) { + expect( + inferLiveToolName({ + title: "MCP: tool", + kind: "other", + rawInput: JSON.stringify({ + task_id: "old-task", + task: "next round", + }), + meta: { claudeCode: { toolName: candidate } }, + }) + ).toBe("continue_delegation") + expect( + inferLiveToolName({ + title: candidate, + kind: "other", + rawInput: JSON.stringify({ task_id: "old-task" }), + }) + ).toBe("continue_delegation") + } + }) + it("keeps input-shape priority for calls without delegation meta", () => { // A generic "MCP: tool" call WITHOUT the broker meta must stay generic — // the delegation resolution is scoped to the codeg-minted marker. @@ -566,6 +593,36 @@ describe("normalizeToolName collapses delegate_to_agent across hosts", () => { }) }) +describe("normalizeToolName collapses continue_delegation across hosts", () => { + it.each([ + "continue_delegation", + "mcp__codeg-mcp__continue_delegation", + "mcp__codeg-delegate__continue_delegation", + "mcp__codeg__continue_delegation", + "codeg-mcp/continue_delegation", + "codeg-delegate/continue_delegation", + "codeg-delegate.continue_delegation", + "codeg-delegate:continue_delegation", + "codeg_delegate__continue_delegation", + ])("%s -> continue_delegation", (input) => { + expect(normalizeToolName(input)).toBe("continue_delegation") + }) + + it("does not match a suffix without a separator", () => { + expect(normalizeToolName("xcontinue_delegation")).not.toBe( + "continue_delegation" + ) + }) + + it("does not collapse continue into resume or delegate", () => { + expect(normalizeToolName("continue_delegation")).not.toBe( + "resume_delegation" + ) + expect(normalizeToolName("resume_delegation")).toBe("resume_delegation") + expect(normalizeToolName("delegate_to_agent")).toBe("delegate_to_agent") + }) +}) + describe("codex view_image", () => { // codex-acp streams it as `kind: "read"` titled `View Image ` with // `{path}` as its input; the rollout records the raw function name. Both diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 0794edcf64..db1d53d7da 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -129,6 +129,12 @@ const EXACT_TOOL_NAME_ALIASES: Record = { "mcp__codeg-mcp__delegate_to_agent": "delegate_to_agent", "mcp__codeg-delegate__delegate_to_agent": "delegate_to_agent", mcp__codeg__delegate_to_agent: "delegate_to_agent", + // A new round on the same child session. Its own tool, not a resume: the + // ack mints a new task id and the card is a new delegation row. + continue_delegation: "continue_delegation", + "mcp__codeg-mcp__continue_delegation": "continue_delegation", + "mcp__codeg-delegate__continue_delegation": "continue_delegation", + mcp__codeg__continue_delegation: "continue_delegation", get_delegation_status: "get_delegation_status", cancel_delegation: "cancel_delegation", resume_delegation: "resume_delegation", @@ -616,6 +622,8 @@ function normalizeUncached(toolName: string): string { // name after any non-alphanumeric separator so every form collapses to // the same canonical name the renderer dispatches on. if (/[^a-z0-9]delegate_to_agent$/.test(canonical)) return "delegate_to_agent" + if (/[^a-z0-9]continue_delegation$/.test(canonical)) + return "continue_delegation" if (/[^a-z0-9]get_delegation_status$/.test(canonical)) return "get_delegation_status" if (/[^a-z0-9]cancel_delegation$/.test(canonical)) return "cancel_delegation" @@ -664,6 +672,7 @@ function normalizeUncached(toolName: string): string { // `{task_id}` (generic "task" tool). const DELEGATION_COMPANION_TOOLS: ReadonlySet = new Set([ "delegate_to_agent", + "continue_delegation", "get_delegation_status", "cancel_delegation", "resume_delegation",