diff --git a/src-tauri/src/acp/handoff.rs b/src-tauri/src/acp/handoff.rs new file mode 100644 index 0000000000..12a4ae3e90 --- /dev/null +++ b/src-tauri/src/acp/handoff.rs @@ -0,0 +1,1275 @@ +//! Mid-conversation agent handoff: switch an existing conversation to a +//! different agent in place, carrying its context over. +//! +//! Two paths, decided by [`plan_path`]: +//! +//! * **Native** (same family, Claude today): the agent's own transcript is +//! copied into the target's home ([`copy_claude_session`]) and the target +//! `session/load`s it under the SAME session id. Nothing is summarized; the +//! target continues with the full context the source had. +//! * **Summary** (everything else): the target starts a fresh session seeded +//! with a briefing ([`build_briefing`]) built from the conversation: a digest +//! of every earlier turn, the last few turns in full, and the user's note. +//! +//! Either way the conversation ROW keeps its id: `conversation_service:: +//! rebind_for_handoff` moves it to the new agent + session, and a +//! `conversation_handoff` record keeps the earlier segment reachable so the +//! detail read ([`splice_handoffs`]) renders the whole history with a divider +//! where the switch happened. +//! +//! Everything here is pure or filesystem-only so it can be unit tested; the +//! connection choreography lives in `commands::handoff`. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::acp::registry::{self, AgentDistribution}; +use crate::db::entities::conversation_handoff; +use crate::models::{AgentType, ContentBlock, MessageTurn, TurnRole}; + +/// First line of every seeded briefing. An HTML comment renders as nothing +/// wherever the prompt is shown as Markdown, and the detail read folds the +/// turn that starts with it into the handoff divider (`splice_handoffs`). +pub const BRIEFING_MARKER: &str = ""; + +/// Upper bound on a seeded briefing, in characters. Generous for a long +/// conversation, and bounded so a pathological one cannot exhaust the target +/// agent's first turn. +pub const DEFAULT_BRIEFING_BUDGET: usize = 60_000; + +/// `_meta` key on the divider tool call. Recognized by the frontend the same +/// way `contextCompaction` is: by key, never by agent. +pub const HANDOFF_META_KEY: &str = "codeg.handoff"; + +/// How many trailing turns the briefing carries in full before shrinking. +const VERBATIM_TURNS: usize = 6; +/// Fewest trailing turns the briefing keeps in full while shrinking. +const MIN_VERBATIM_TURNS: usize = 2; +/// Per-turn character caps for the digest, tried in order while shrinking. +const DIGEST_CAPS: [usize; 3] = [600, 300, 150]; +/// Cap on one tool input/output preview inside the verbatim tail. +const TOOL_PREVIEW_CAP: usize = 300; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HandoffPath { + Native, + Summary, +} + +impl HandoffPath { + pub fn as_str(self) -> &'static str { + match self { + HandoffPath::Native => "native", + HandoffPath::Summary => "summary", + } + } + + pub fn parse(s: &str) -> Self { + match s { + "native" => HandoffPath::Native, + _ => HandoffPath::Summary, + } + } +} + +/// Agent families whose sessions codeg can move between homes losslessly. +/// +/// Claude only, deliberately: `claude-agent-acp`'s `session/load` resolves a +/// session id by scanning `/projects/*/.jsonl`, so a +/// transcript copied into another home loads there under the same id +/// (verified against the real adapter). Codex and Grok keep session stores +/// too, but their load paths have not been verified with a copied file, and +/// advertising a native transfer that silently starts an empty session would +/// be worse than the summary path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeFamily { + Claude, +} + +/// npm packages that wrap Claude Code as an ACP agent. An extra isolated +/// Claude account in codeg is a custom agent running one of these with its own +/// `CLAUDE_CONFIG_DIR`, so its sessions are Claude sessions. +pub fn is_claude_adapter_package(package: &str) -> bool { + // `@scope/name@1.2.3` → `@scope/name`; an unversioned `@scope/name` has its + // only `@` at index 0, which `rsplit_once` would misread as a version split. + let name = package + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(package); + matches!( + name, + "@agentclientprotocol/claude-agent-acp" + | "@zed-industries/claude-agent-acp" + | "@zed-industries/claude-code-acp" + ) +} + +/// [`native_family`] against an explicit distribution, so the decision can be +/// tested without the process-global custom registry. +pub fn native_family_of(agent_type: AgentType, distribution: &AgentDistribution) -> Option { + match agent_type { + AgentType::ClaudeCode => Some(NativeFamily::Claude), + AgentType::Custom(_) => match distribution { + AgentDistribution::Npx { package, .. } if is_claude_adapter_package(package) => { + Some(NativeFamily::Claude) + } + _ => None, + }, + _ => None, + } +} + +pub fn native_family(agent_type: AgentType) -> Option { + let meta = registry::get_agent_meta(agent_type); + native_family_of(agent_type, &meta.distribution) +} + +/// Which path a handoff between two agents takes. Same family on both sides +/// means the transcript can move; anything else is briefed. +pub fn plan_path(source: AgentType, target: AgentType) -> HandoffPath { + plan_path_of(native_family(source), native_family(target)) +} + +pub fn plan_path_of(source: Option, target: Option) -> HandoffPath { + match (source, target) { + (Some(a), Some(b)) if a == b => HandoffPath::Native, + _ => HandoffPath::Summary, + } +} + +/// The static env a distribution launches with (`merge_agent_env` applies it +/// under the per-agent runtime env). +pub fn distribution_env(distribution: &AgentDistribution) -> &'static [(&'static str, &'static str)] { + match distribution { + AgentDistribution::Npx { env, .. } + | AgentDistribution::Binary { env, .. } + | AgentDistribution::Uvx { env, .. } => env, + } +} + +/// The Claude config dir an agent's process actually uses, resolved the way +/// the spawn layer resolves it: the per-agent runtime env wins over the +/// distribution's static env, which wins over codeg's own process env, which +/// falls back to `~/.claude`. An explicitly EMPTY runtime value is what the +/// spawn layer `env_remove`s, so it means the default, not "". +pub fn claude_config_dir_for(agent_type: AgentType, runtime_env: &BTreeMap) -> PathBuf { + let meta = registry::get_agent_meta(agent_type); + claude_config_dir_from( + runtime_env, + distribution_env(&meta.distribution), + std::env::var_os("CLAUDE_CONFIG_DIR"), + dirs::home_dir(), + ) +} + +pub fn claude_config_dir_from( + runtime_env: &BTreeMap, + distribution_env: &[(&str, &str)], + process_env: Option, + home_dir: Option, +) -> PathBuf { + if let Some(value) = runtime_env.get("CLAUDE_CONFIG_DIR") { + if !value.is_empty() { + return PathBuf::from(value); + } + return crate::parsers::claude::resolve_claude_config_dir_from(None, home_dir); + } + if let Some((_, value)) = distribution_env + .iter() + .find(|(key, value)| *key == "CLAUDE_CONFIG_DIR" && !value.is_empty()) + { + return PathBuf::from(value); + } + crate::parsers::claude::resolve_claude_config_dir_from(process_env, home_dir) +} + +#[derive(Debug, thiserror::Error)] +pub enum HandoffError { + #[error("session id {0:?} is not a valid transcript name")] + InvalidSessionId(String), + #[error("no transcript for session {0} under {dir}", dir = .1.display())] + SessionNotFound(String, PathBuf), + #[error("{0}")] + Io(#[from] std::io::Error), +} + +/// What [`copy_claude_session`] wrote, so a failed load can take it back. +#[derive(Debug, Clone)] +pub struct CopiedSession { + pub transcript: PathBuf, + /// `//` (tool results, sub-agent transcripts) when the + /// source had one. + pub sidecar: Option, +} + +/// A session id is embedded in a file name; refuse anything that could leave +/// the project directory (`parsers::claude::find_session_file_in` applies the +/// same rule when looking the file up). +fn safe_session_id(session_id: &str) -> bool { + !session_id.is_empty() && crate::parsers::is_safe_subagent_id(session_id) +} + +/// Copy `/projects//.jsonl` (plus its sidecar +/// directory) into the same project directory under `dst_home`. +/// +/// The project directory keeps its name: Claude derives it from the working +/// directory, which does not change with the home, so the target finds the +/// file exactly where its own resolver looks. An existing copy is replaced; +/// the source is the authority at handoff time (a round trip A→B→A must bring +/// B's newer turns back over A's stale file). The source is never touched. +/// +/// The transcript lands through a temp file + rename so a partial copy can +/// never be mistaken for a whole session. +pub fn copy_claude_session( + src_home: &Path, + dst_home: &Path, + session_id: &str, +) -> Result { + if !safe_session_id(session_id) { + return Err(HandoffError::InvalidSessionId(session_id.to_string())); + } + let projects = src_home.join("projects"); + let src = crate::parsers::claude::find_session_file_in(&projects, session_id) + .ok_or_else(|| HandoffError::SessionNotFound(session_id.to_string(), projects.clone()))?; + let project_dir_name = src + .parent() + .and_then(Path::file_name) + .ok_or_else(|| HandoffError::SessionNotFound(session_id.to_string(), projects.clone()))? + .to_os_string(); + let dst_dir = dst_home.join("projects").join(project_dir_name); + fs::create_dir_all(&dst_dir)?; + + let dst = dst_dir.join(format!("{session_id}.jsonl")); + let tmp = dst_dir.join(format!("{session_id}.jsonl.handoff-tmp")); + fs::copy(&src, &tmp)?; + if let Err(e) = fs::rename(&tmp, &dst) { + let _ = fs::remove_file(tmp); + return Err(e.into()); + } + + let src_sidecar = src.with_file_name(session_id); + let sidecar = if src_sidecar.is_dir() { + let dst_sidecar = dst_dir.join(session_id); + copy_dir_recursive(&src_sidecar, &dst_sidecar)?; + Some(dst_sidecar) + } else { + None + }; + Ok(CopiedSession { + transcript: dst, + sidecar, + }) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let target = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_recursive(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + +/// Undo [`copy_claude_session`] after the target failed to load it. Best +/// effort: the copy is inert either way, so a leftover costs nothing but disk. +pub fn remove_copied_session(copied: &CopiedSession) { + let _ = fs::remove_file(&copied.transcript); + if let Some(sidecar) = &copied.sidecar { + let _ = fs::remove_dir_all(sidecar); + } +} + +// ─── Briefing ────────────────────────────────────────────────────────────── + +pub struct BriefingInput<'a> { + pub source_label: &'a str, + pub target_label: &'a str, + pub working_dir: Option<&'a str>, + pub title: Option<&'a str>, + pub turns: &'a [MessageTurn], + pub note: Option<&'a str>, + pub budget: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Briefing { + pub text: String, + /// Earlier turns had to be dropped (or the text cut) to fit the budget. + pub truncated: bool, + /// Trailing turns carried in full. + pub verbatim_turns: usize, + /// Earlier turns carried as a digest. + pub digest_turns: usize, + /// Earlier turns that did not fit at all. + pub omitted_turns: usize, +} + +/// True for a prompt this module seeded (the folded-away turn). +pub fn is_briefing_text(text: &str) -> bool { + text.trim_start().starts_with(BRIEFING_MARKER) +} + +fn turn_text(turn: &MessageTurn) -> String { + let mut out = String::new(); + for block in &turn.blocks { + match block { + ContentBlock::Text { text } => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); + } + ContentBlock::Image { .. } => { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("[image]"); + } + _ => {} + } + } + out +} + +fn tool_names(turn: &MessageTurn) -> Vec<&str> { + let mut names: Vec<&str> = Vec::new(); + for block in &turn.blocks { + if let ContentBlock::ToolUse { tool_name, .. } = block { + if !names.contains(&tool_name.as_str()) { + names.push(tool_name); + } + } + } + names +} + +fn role_label(role: &TurnRole) -> &'static str { + match role { + TurnRole::User => "User", + TurnRole::Assistant => "Assistant", + TurnRole::System => "System", + } +} + +/// Collapse runs of whitespace and cut at `cap` characters (never inside one). +fn clip(text: &str, cap: usize) -> String { + let collapsed: String = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= cap { + return collapsed; + } + let mut cut: String = collapsed.chars().take(cap).collect(); + cut.push('…'); + cut +} + +fn is_edit_tool(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "edit" | "write" | "multiedit" | "notebookedit" | "apply_patch" | "create_file" + ) || lower.contains("edit_file") + || lower.contains("write_file") + || lower.contains("str_replace") +} + +/// Paths named by editing tools, in first-seen order. Heuristic on the input +/// preview (a path-shaped token), which is all the parsers keep. +fn touched_files(turns: &[MessageTurn]) -> Vec { + let mut files: Vec = Vec::new(); + for turn in turns { + for block in &turn.blocks { + let ContentBlock::ToolUse { + tool_name, + input_preview: Some(preview), + .. + } = block + else { + continue; + }; + if !is_edit_tool(tool_name) { + continue; + } + for token in preview.split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | '{' | '}')) { + let token = token.trim_matches(|c: char| matches!(c, ':' | '[' | ']' | '(' | ')')); + if token.len() < 3 || token.len() > 260 { + continue; + } + if !(token.contains('/') || token.contains('\\')) || token.starts_with("http") { + continue; + } + if !files.iter().any(|f| f == token) { + files.push(token.to_string()); + } + break; + } + } + } + files +} + +fn render_verbatim(turn: &MessageTurn, index: usize) -> String { + let mut out = format!("### {} (turn {})\n", role_label(&turn.role), index + 1); + let mut wrote = false; + for block in &turn.blocks { + match block { + ContentBlock::Text { text } => { + out.push_str(text.trim_end()); + out.push('\n'); + wrote = true; + } + ContentBlock::Image { .. } => { + out.push_str("[image]\n"); + wrote = true; + } + ContentBlock::ToolUse { + tool_name, + input_preview, + .. + } => { + out.push_str(&format!("[tool: {tool_name}]")); + if let Some(preview) = input_preview { + out.push(' '); + out.push_str(&clip(preview, TOOL_PREVIEW_CAP)); + } + out.push('\n'); + wrote = true; + } + ContentBlock::ToolResult { + output_preview: Some(preview), + is_error, + .. + } => { + out.push_str(if *is_error { "[error] " } else { "[result] " }); + out.push_str(&clip(preview, TOOL_PREVIEW_CAP)); + out.push('\n'); + wrote = true; + } + _ => {} + } + } + if !wrote { + out.push_str("(no text)\n"); + } + out +} + +fn render_digest(turn: &MessageTurn, index: usize, cap: usize) -> String { + let text = turn_text(turn); + let mut line = format!("- {} (turn {}): ", role_label(&turn.role), index + 1); + let clipped = clip(&text, cap); + if clipped.is_empty() { + line.push_str("(no text)"); + } else { + line.push_str(&clipped); + } + let tools = tool_names(turn); + if !tools.is_empty() { + line.push_str(&format!(" [tools: {}]", tools.iter().take(8).cloned().collect::>().join(", "))); + } + line.push('\n'); + line +} + +struct Shape { + verbatim: usize, + cap: usize, + omitted: usize, +} + +fn render(input: &BriefingInput<'_>, shape: &Shape) -> String { + let turns = input.turns; + let n = turns.len(); + let verbatim_from = n.saturating_sub(shape.verbatim); + let digest_from = shape.omitted.min(verbatim_from); + + let mut out = String::new(); + out.push_str(BRIEFING_MARKER); + out.push('\n'); + out.push_str(&format!( + "This conversation was handed off to you ({}) from {} inside Codeg. You are continuing it in place: \ + the user sees the earlier turns above this message, so pick up the work without re-introducing yourself.\n", + input.target_label, input.source_label + )); + if let Some(dir) = input.working_dir.filter(|d| !d.trim().is_empty()) { + out.push_str(&format!("Working directory: {dir}\n")); + } + if let Some(title) = input.title.filter(|t| !t.trim().is_empty()) { + out.push_str(&format!("Conversation title: {title}\n")); + } + if let Some(note) = input.note.map(str::trim).filter(|n| !n.is_empty()) { + out.push_str("\n## What the user wants you to focus on\n"); + out.push_str(note); + out.push('\n'); + } + + out.push_str(&format!("\n## Conversation so far ({n} turns)\n")); + if digest_from > 0 { + out.push_str(&format!( + "({digest_from} earlier turn{} omitted to fit this briefing)\n", + if digest_from == 1 { "" } else { "s" } + )); + } + if digest_from < verbatim_from { + out.push_str("Digest of earlier turns:\n"); + for (i, turn) in turns.iter().enumerate().take(verbatim_from).skip(digest_from) { + out.push_str(&render_digest(turn, i, shape.cap)); + } + } + let files = touched_files(&turns[..verbatim_from]); + if !files.is_empty() { + out.push_str("\nFiles edited earlier:\n"); + for file in files.iter().take(40) { + out.push_str(&format!("- {file}\n")); + } + } + if verbatim_from < n { + out.push_str(&format!( + "\n## Last {} turn{} in full (tool previews shortened)\n", + n - verbatim_from, + if n - verbatim_from == 1 { "" } else { "s" } + )); + for (i, turn) in turns.iter().enumerate().skip(verbatim_from) { + out.push_str(&render_verbatim(turn, i)); + } + } + out.push_str("\nContinue from here.\n"); + out +} + +/// Build the prompt that seeds the target's fresh session. +/// +/// Shrinks in a fixed order until it fits `budget`: the digest's per-turn cap +/// first, then the number of verbatim trailing turns (never below two), then +/// the oldest digest turns are dropped, and as a last resort the text is cut. +/// Anything past the first step is reported as `truncated` so the UI can say +/// the briefing is not the whole story. +pub fn build_briefing(input: &BriefingInput<'_>) -> Briefing { + let n = input.turns.len(); + let budget = input.budget.max(BRIEFING_MARKER.len() + 64); + let mut shape = Shape { + verbatim: VERBATIM_TURNS.min(n), + cap: DIGEST_CAPS[0], + omitted: 0, + }; + let mut cap_index = 0; + let mut truncated = false; + loop { + let text = render(input, &shape); + if text.chars().count() <= budget { + return Briefing { + text, + truncated, + verbatim_turns: shape.verbatim, + digest_turns: n.saturating_sub(shape.verbatim).saturating_sub(shape.omitted), + omitted_turns: shape.omitted, + }; + } + if cap_index + 1 < DIGEST_CAPS.len() { + cap_index += 1; + shape.cap = DIGEST_CAPS[cap_index]; + continue; + } + if shape.verbatim > MIN_VERBATIM_TURNS.min(n) { + shape.verbatim -= 1; + truncated = true; + continue; + } + let digest_turns = n.saturating_sub(shape.verbatim); + if shape.omitted < digest_turns { + // Drop the oldest quarter at a time so a very long conversation + // converges in a few rounds instead of one per turn. + let step = ((digest_turns - shape.omitted) / 4).max(1); + shape.omitted += step; + truncated = true; + continue; + } + // Nothing left to drop: cut the text itself, keeping the marker line. + let suffix = "\n[briefing cut to fit the prompt budget]\n"; + let keep = budget.saturating_sub(suffix.chars().count()); + let mut cut: String = text.chars().take(keep).collect(); + cut.push_str(suffix); + return Briefing { + text: cut, + truncated: true, + verbatim_turns: shape.verbatim, + digest_turns: 0, + omitted_turns: shape.omitted, + }; + } +} + +// ─── Divider + splice ─────────────────────────────────────────────────────── + +/// One handoff in a conversation's chain, as the detail read consumes it. +#[derive(Debug, Clone, PartialEq)] +pub struct HandoffLink { + pub from_agent_type: AgentType, + pub from_external_id: Option, + pub to_agent_type: AgentType, + pub to_external_id: String, + pub path: HandoffPath, + pub carried: bool, + pub user_turns_before: usize, + pub note: Option, + pub briefing: Option, + pub truncated: bool, + pub at: DateTime, +} + +impl HandoffLink { + /// `None` when either agent's wire name no longer parses (a row written by + /// a newer codeg); such a link is skipped rather than rendered wrongly. + pub fn from_row(row: &conversation_handoff::Model) -> Option { + Some(Self { + from_agent_type: AgentType::from_wire(&row.from_agent_type)?, + from_external_id: row.from_external_id.clone(), + to_agent_type: AgentType::from_wire(&row.to_agent_type)?, + to_external_id: row.to_external_id.clone(), + path: HandoffPath::parse(&row.path), + carried: row.carried, + user_turns_before: usize::try_from(row.user_turns_before).unwrap_or(0), + note: row.note.clone(), + briefing: row.briefing.clone(), + truncated: row.truncated, + at: row.created_at, + }) + } +} + +/// The divider between two segments: a paired `ToolUse`/`ToolResult` carrying +/// `_meta["codeg.handoff"]`, the same shape the context-compaction divider +/// takes so the frontend hoists it to a standalone timeline item. +pub fn divider_turn(link: &HandoffLink, index: usize) -> MessageTurn { + let tool_use_id = format!("handoff-{index}"); + let mut marker = serde_json::Map::new(); + marker.insert("version".into(), serde_json::json!(1)); + marker.insert("from".into(), serde_json::json!(link.from_agent_type.as_wire())); + marker.insert("to".into(), serde_json::json!(link.to_agent_type.as_wire())); + marker.insert("path".into(), serde_json::json!(link.path.as_str())); + marker.insert("carried".into(), serde_json::json!(link.carried)); + marker.insert("truncated".into(), serde_json::json!(link.truncated)); + marker.insert("at".into(), serde_json::json!(link.at.to_rfc3339())); + if let Some(note) = link.note.as_deref().map(str::trim).filter(|n| !n.is_empty()) { + marker.insert("note".into(), serde_json::json!(note)); + } + if let Some(briefing) = &link.briefing { + marker.insert("briefing".into(), serde_json::json!(briefing)); + } + let meta = serde_json::Value::Object( + [(HANDOFF_META_KEY.to_string(), serde_json::Value::Object(marker))] + .into_iter() + .collect(), + ); + MessageTurn { + id: tool_use_id.clone(), + role: TurnRole::Assistant, + blocks: vec![ + ContentBlock::ToolUse { + tool_use_id: Some(tool_use_id.clone()), + tool_name: "agent_handoff".to_string(), + input_preview: None, + status: None, + meta: Some(meta), + }, + ContentBlock::ToolResult { + tool_use_id: Some(tool_use_id), + output_preview: None, + is_error: false, + agent_stats: None, + images: Vec::new(), + }, + ], + timestamp: link.at, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(link.at), + agent_message_id: None, + } +} + +pub fn count_user_turns(turns: &[MessageTurn]) -> usize { + turns + .iter() + .filter(|t| matches!(t.role, TurnRole::User)) + .count() +} + +/// True for a turn [`divider_turn`] produced: bookkeeping, not conversation, +/// so a briefing built from a spliced timeline leaves it out. +pub fn is_divider_turn(turn: &MessageTurn) -> bool { + turn.blocks.iter().any(|b| { + matches!( + b, + ContentBlock::ToolUse { meta: Some(meta), .. } if meta.get(HANDOFF_META_KEY).is_some() + ) + }) +} + +fn is_briefing_turn(turn: &MessageTurn) -> bool { + matches!(turn.role, TurnRole::User) + && turn + .blocks + .iter() + .find_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .is_some_and(is_briefing_text) +} + +/// Assemble one timeline out of a conversation's handoff chain. +/// +/// `segments[i]` is the history read from `links[i]`'s source (empty for a +/// carried link, whose target already holds it). Uncarried segments come first, +/// each followed by its divider; then the current session. A carried link's +/// divider is placed by the user-turn count recorded at handoff time, right +/// before the next user prompt, since its store holds both halves and nothing +/// else marks the seam. The prompt that seeded a summary handoff is folded +/// away: its text already rides on the divider, and a screen-long briefing is +/// not something the user wrote. +pub fn splice_handoffs( + links: &[HandoffLink], + segments: Vec>, + current: Vec, +) -> Vec { + if links.is_empty() { + return current; + } + let mut current = current; + if let Some(pos) = current.iter().position(is_briefing_turn) { + current.remove(pos); + } + + let mut out: Vec = Vec::new(); + let mut segments = segments.into_iter(); + let mut carried: Vec = Vec::new(); + for (i, link) in links.iter().enumerate() { + let segment = segments.next().unwrap_or_default(); + if link.carried { + carried.push(i); + continue; + } + out.extend(segment); + out.push(divider_turn(link, i)); + } + out.extend(current); + + for i in carried { + let link = &links[i]; + let mut seen = 0usize; + let mut pos = out.len(); + for (idx, turn) in out.iter().enumerate() { + if matches!(turn.role, TurnRole::User) { + if seen == link.user_turns_before { + pos = idx; + break; + } + seen += 1; + } + } + out.insert(pos, divider_turn(link, i)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_turn(id: &str, role: TurnRole, text: &str) -> MessageTurn { + MessageTurn { + id: id.into(), + role, + blocks: vec![ContentBlock::Text { text: text.into() }], + timestamp: Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + agent_message_id: None, + } + } + + fn npx(package: &'static str) -> AgentDistribution { + AgentDistribution::Npx { + version: "1", + package, + cmd: "x", + args: &[], + env: &[], + node_required: None, + } + } + + fn link(carried: bool, user_turns_before: usize) -> HandoffLink { + HandoffLink { + from_agent_type: AgentType::ClaudeCode, + from_external_id: Some("S1".into()), + to_agent_type: AgentType::Codex, + to_external_id: "S2".into(), + path: if carried { + HandoffPath::Native + } else { + HandoffPath::Summary + }, + carried, + user_turns_before, + note: Some("finish it".into()), + briefing: Some(format!("{BRIEFING_MARKER}\nbrief")), + truncated: false, + at: Utc::now(), + } + } + + #[test] + fn claude_adapter_packages_are_recognized_with_or_without_a_version() { + assert!(is_claude_adapter_package("@agentclientprotocol/claude-agent-acp@0.65.0")); + assert!(is_claude_adapter_package("@agentclientprotocol/claude-agent-acp")); + assert!(is_claude_adapter_package("@zed-industries/claude-code-acp@0.10.0")); + assert!(!is_claude_adapter_package("@agentclientprotocol/codex-acp@1.1.9")); + assert!(!is_claude_adapter_package("@vibe-kit/grok-cli@1.0.5")); + assert!(!is_claude_adapter_package("")); + } + + #[test] + fn same_family_is_native_and_everything_else_is_summary() { + let claude_slot = AgentType::custom("claude-code-2").unwrap(); + let codex_slot = AgentType::custom("codex-2").unwrap(); + let claude_dist = npx("@agentclientprotocol/claude-agent-acp@0.65.0"); + let codex_dist = npx("@agentclientprotocol/codex-acp@1.1.9"); + + let builtin = native_family_of(AgentType::ClaudeCode, &npx("unused")); + let slot = native_family_of(claude_slot, &claude_dist); + assert_eq!(builtin, Some(NativeFamily::Claude)); + assert_eq!(slot, Some(NativeFamily::Claude)); + assert_eq!(plan_path_of(builtin, slot), HandoffPath::Native); + assert_eq!(plan_path_of(slot, builtin), HandoffPath::Native); + + // Codex and Grok have stores too, but no verified load-from-copy path: + // they stay on the summary side until that is proven. + assert_eq!(native_family_of(AgentType::Codex, &codex_dist), None); + assert_eq!(native_family_of(codex_slot, &codex_dist), None); + assert_eq!(native_family_of(AgentType::Grok, &npx("@vibe-kit/grok-cli@1")), None); + assert_eq!(plan_path_of(builtin, None), HandoffPath::Summary); + assert_eq!(plan_path_of(None, builtin), HandoffPath::Summary); + assert_eq!(plan_path_of(None, None), HandoffPath::Summary); + // A custom agent that is NOT a Claude wrapper never counts as Claude, + // whatever its id says. + assert_eq!( + native_family_of(AgentType::custom("claude-ish").unwrap(), &codex_dist), + None + ); + } + + #[test] + fn config_dir_precedence_matches_the_spawn_layer() { + let home = Some(PathBuf::from("/home/u")); + let dist: &[(&str, &str)] = &[("CLAUDE_CONFIG_DIR", "/dist/home")]; + let mut runtime = BTreeMap::new(); + + // Nothing set anywhere: the agent's own default. + assert_eq!( + claude_config_dir_from(&runtime, &[], None, home.clone()), + PathBuf::from("/home/u/.claude") + ); + // codeg's process env reaches the child when nothing overrides it. + assert_eq!( + claude_config_dir_from(&runtime, &[], Some("/proc/home".into()), home.clone()), + PathBuf::from("/proc/home") + ); + // The distribution's static env (an isolated custom slot) beats the + // process env. + assert_eq!( + claude_config_dir_from(&runtime, dist, Some("/proc/home".into()), home.clone()), + PathBuf::from("/dist/home") + ); + // The per-agent runtime env beats both. + runtime.insert("CLAUDE_CONFIG_DIR".into(), "/runtime/home".into()); + assert_eq!( + claude_config_dir_from(&runtime, dist, Some("/proc/home".into()), home.clone()), + PathBuf::from("/runtime/home") + ); + // An explicitly empty runtime value is what the spawn layer removes, so + // the child falls back to its default, not to the distribution value. + runtime.insert("CLAUDE_CONFIG_DIR".into(), String::new()); + assert_eq!( + claude_config_dir_from(&runtime, dist, Some("/proc/home".into()), home), + PathBuf::from("/home/u/.claude") + ); + } + + #[test] + fn copy_moves_the_transcript_and_sidecar_into_the_same_project_dir() { + let root = tempfile::tempdir().unwrap(); + let src_home = root.path().join("src"); + let dst_home = root.path().join("dst"); + let sid = "49e95410-6304-4967-980b-c94986d39913"; + let project = src_home.join("projects").join("C--work-repo"); + fs::create_dir_all(project.join(sid).join("tool-results")).unwrap(); + fs::write(project.join(format!("{sid}.jsonl")), "{\"type\":\"user\"}\n").unwrap(); + fs::write(project.join(sid).join("tool-results").join("a.txt"), "out").unwrap(); + + let copied = copy_claude_session(&src_home, &dst_home, sid).unwrap(); + let dst_project = dst_home.join("projects").join("C--work-repo"); + assert_eq!(copied.transcript, dst_project.join(format!("{sid}.jsonl"))); + assert_eq!( + fs::read_to_string(&copied.transcript).unwrap(), + "{\"type\":\"user\"}\n" + ); + assert_eq!( + fs::read_to_string(dst_project.join(sid).join("tool-results").join("a.txt")).unwrap(), + "out" + ); + assert!(!dst_project.join(format!("{sid}.jsonl.handoff-tmp")).exists()); + // The source is untouched. + assert!(project.join(format!("{sid}.jsonl")).exists()); + + // A newer source replaces a stale copy (the A→B→A round trip). + fs::write(project.join(format!("{sid}.jsonl")), "{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n").unwrap(); + let copied = copy_claude_session(&src_home, &dst_home, sid).unwrap(); + assert_eq!( + fs::read_to_string(&copied.transcript).unwrap().lines().count(), + 2 + ); + + remove_copied_session(&copied); + assert!(!copied.transcript.exists()); + assert!(!dst_project.join(sid).exists()); + } + + #[test] + fn copy_refuses_missing_and_unsafe_sessions() { + let root = tempfile::tempdir().unwrap(); + let src_home = root.path().join("src"); + let dst_home = root.path().join("dst"); + fs::create_dir_all(src_home.join("projects").join("p")).unwrap(); + assert!(matches!( + copy_claude_session(&src_home, &dst_home, "missing-id"), + Err(HandoffError::SessionNotFound(..)) + )); + assert!(matches!( + copy_claude_session(&src_home, &dst_home, "../escape"), + Err(HandoffError::InvalidSessionId(..)) + )); + assert!(matches!( + copy_claude_session(&src_home, &dst_home, ""), + Err(HandoffError::InvalidSessionId(..)) + )); + assert!(!dst_home.exists(), "a refused copy writes nothing"); + } + + #[test] + fn briefing_carries_the_marker_note_digest_and_verbatim_tail() { + let turns: Vec = (0..10) + .map(|i| { + if i % 2 == 0 { + text_turn(&format!("t{i}"), TurnRole::User, &format!("user prompt {i}")) + } else { + text_turn(&format!("t{i}"), TurnRole::Assistant, &format!("assistant reply {i}")) + } + }) + .collect(); + let briefing = build_briefing(&BriefingInput { + source_label: "Claude Code", + target_label: "Codex CLI", + working_dir: Some("/work/repo"), + title: Some("Fix the flaky test"), + turns: &turns, + note: Some(" focus on the retry loop "), + budget: DEFAULT_BRIEFING_BUDGET, + }); + assert!(briefing.text.starts_with(BRIEFING_MARKER)); + assert!(is_briefing_text(&briefing.text)); + assert!(briefing.text.contains("from Claude Code")); + assert!(briefing.text.contains("(Codex CLI)")); + assert!(briefing.text.contains("Working directory: /work/repo")); + assert!(briefing.text.contains("Conversation title: Fix the flaky test")); + assert!(briefing.text.contains("focus on the retry loop")); + assert!(!briefing.truncated); + assert_eq!(briefing.verbatim_turns, 6); + assert_eq!(briefing.digest_turns, 4); + assert_eq!(briefing.omitted_turns, 0); + // Digest lines for the first four, full sections for the last six. + assert!(briefing.text.contains("- User (turn 1): user prompt 0")); + assert!(briefing.text.contains("### User (turn 5)\nuser prompt 4")); + assert!(briefing.text.contains("### Assistant (turn 10)\nassistant reply 9")); + assert!(briefing.text.ends_with("Continue from here.\n")); + } + + #[test] + fn briefing_shrinks_to_the_budget_and_says_so() { + let turns: Vec = (0..40) + .map(|i| { + let role = if i % 2 == 0 { + TurnRole::User + } else { + TurnRole::Assistant + }; + text_turn(&format!("t{i}"), role, &"lorem ipsum ".repeat(200)) + }) + .collect(); + let big = build_briefing(&BriefingInput { + source_label: "A", + target_label: "B", + working_dir: None, + title: None, + turns: &turns, + note: None, + budget: DEFAULT_BRIEFING_BUDGET, + }); + assert!(big.text.chars().count() <= DEFAULT_BRIEFING_BUDGET); + + let small = build_briefing(&BriefingInput { + source_label: "A", + target_label: "B", + working_dir: None, + title: None, + turns: &turns, + note: None, + budget: 3_000, + }); + assert!(small.text.chars().count() <= 3_000, "{}", small.text.chars().count()); + assert!(small.truncated); + assert!(small.text.starts_with(BRIEFING_MARKER)); + assert!(small.verbatim_turns >= MIN_VERBATIM_TURNS); + + // Even an absurd budget keeps the marker and reports the cut. + let tiny = build_briefing(&BriefingInput { + source_label: "A", + target_label: "B", + working_dir: None, + title: None, + turns: &turns, + note: None, + budget: 10, + }); + assert!(tiny.truncated); + assert!(tiny.text.starts_with(BRIEFING_MARKER)); + assert!(tiny.text.contains("[briefing cut to fit the prompt budget]")); + } + + #[test] + fn briefing_of_an_empty_conversation_is_still_well_formed() { + let briefing = build_briefing(&BriefingInput { + source_label: "A", + target_label: "B", + working_dir: None, + title: None, + turns: &[], + note: None, + budget: DEFAULT_BRIEFING_BUDGET, + }); + assert!(briefing.text.starts_with(BRIEFING_MARKER)); + assert!(briefing.text.contains("(0 turns)")); + assert_eq!(briefing.verbatim_turns, 0); + assert!(!briefing.truncated); + } + + #[test] + fn briefing_lists_files_edited_earlier_and_images() { + let mut edit = text_turn("a", TurnRole::Assistant, "editing"); + edit.blocks.push(ContentBlock::ToolUse { + tool_use_id: Some("tu1".into()), + tool_name: "Edit".into(), + input_preview: Some("file_path: src/lib/retry.ts, old_string: x".into()), + status: None, + meta: None, + }); + let mut shot = text_turn("b", TurnRole::User, "look"); + shot.blocks.push(ContentBlock::Image { + data: "AAAA".into(), + mime_type: "image/png".into(), + uri: None, + }); + let turns = vec![ + edit, + shot, + text_turn("c", TurnRole::Assistant, "ok"), + text_turn("d", TurnRole::User, "next"), + text_turn("e", TurnRole::Assistant, "done"), + text_turn("f", TurnRole::User, "thanks"), + text_turn("g", TurnRole::Assistant, "np"), + text_turn("h", TurnRole::User, "more"), + ]; + let briefing = build_briefing(&BriefingInput { + source_label: "A", + target_label: "B", + working_dir: None, + title: None, + turns: &turns, + note: None, + budget: DEFAULT_BRIEFING_BUDGET, + }); + assert!(briefing.text.contains("Files edited earlier:\n- src/lib/retry.ts")); + assert!(briefing.text.contains("[tools: Edit]")); + assert!(briefing.text.contains("[image]")); + } + + #[test] + fn divider_carries_the_handoff_meta_the_frontend_hoists() { + let l = link(false, 3); + let turn = divider_turn(&l, 2); + assert_eq!(turn.id, "handoff-2"); + assert!(matches!(turn.role, TurnRole::Assistant)); + let ContentBlock::ToolUse { + tool_use_id, + tool_name, + meta: Some(meta), + .. + } = &turn.blocks[0] + else { + panic!("expected a ToolUse first"); + }; + assert_eq!(tool_use_id.as_deref(), Some("handoff-2")); + assert_eq!(tool_name, "agent_handoff"); + let marker = meta.get(HANDOFF_META_KEY).expect("meta key"); + assert_eq!(marker["version"], 1); + assert_eq!(marker["from"], "claude_code"); + assert_eq!(marker["to"], "codex"); + assert_eq!(marker["path"], "summary"); + assert_eq!(marker["carried"], false); + assert_eq!(marker["note"], "finish it"); + assert!(marker["briefing"].as_str().unwrap().starts_with(BRIEFING_MARKER)); + assert!(marker["at"].is_string()); + let ContentBlock::ToolResult { tool_use_id, .. } = &turn.blocks[1] else { + panic!("expected the paired ToolResult"); + }; + assert_eq!(tool_use_id.as_deref(), Some("handoff-2")); + assert!(is_divider_turn(&turn)); + assert!(!is_divider_turn(&text_turn("t", TurnRole::Assistant, "plain"))); + } + + #[test] + fn divider_omits_a_blank_note() { + let mut l = link(false, 0); + l.note = Some(" ".into()); + l.briefing = None; + let turn = divider_turn(&l, 0); + let ContentBlock::ToolUse { meta: Some(meta), .. } = &turn.blocks[0] else { + panic!("expected a ToolUse"); + }; + let marker = &meta[HANDOFF_META_KEY]; + assert!(marker.get("note").is_none()); + assert!(marker.get("briefing").is_none()); + } + + #[test] + fn summary_segment_renders_before_its_divider_and_the_briefing_folds_away() { + let segment = vec![ + text_turn("s1", TurnRole::User, "old prompt"), + text_turn("s2", TurnRole::Assistant, "old reply"), + ]; + let current = vec![ + text_turn("c1", TurnRole::User, &format!("{BRIEFING_MARKER}\nseeded briefing")), + text_turn("c2", TurnRole::Assistant, "new agent reply"), + text_turn("c3", TurnRole::User, "follow-up"), + ]; + let out = splice_handoffs(&[link(false, 1)], vec![segment], current); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["s1", "s2", "handoff-0", "c2", "c3"]); + assert_eq!(count_user_turns(&out), 2); + } + + #[test] + fn carried_divider_lands_before_the_next_user_prompt() { + // The native target holds both halves; the divider goes after the + // reply to the last prompt the source answered (2 user turns before). + let current = vec![ + text_turn("u1", TurnRole::User, "one"), + text_turn("a1", TurnRole::Assistant, "reply one"), + text_turn("u2", TurnRole::User, "two"), + text_turn("a2", TurnRole::Assistant, "reply two"), + text_turn("u3", TurnRole::User, "three (asked after the handoff)"), + text_turn("a3", TurnRole::Assistant, "reply three"), + ]; + let out = splice_handoffs(&[link(true, 2)], vec![Vec::new()], current.clone()); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["u1", "a1", "u2", "a2", "handoff-0", "u3", "a3"]); + + // Handed off after the last reply: nothing follows, so the divider is + // the tail. A count past the end lands there too. + let out = splice_handoffs(&[link(true, 3)], vec![Vec::new()], current.clone()); + assert_eq!(out.last().unwrap().id, "handoff-0"); + let out = splice_handoffs(&[link(true, 99)], vec![Vec::new()], current); + assert_eq!(out.last().unwrap().id, "handoff-0"); + } + + #[test] + fn mixed_chain_keeps_every_segment_in_order() { + // A → B (summary), then B → B2 (native, carried): the A segment first, + // then B2's own store holding B's turns, with the second divider placed + // by the user-turn count measured on the combined rendering. + let a_segment = vec![ + text_turn("a-u1", TurnRole::User, "start"), + text_turn("a-a1", TurnRole::Assistant, "hi"), + ]; + let b2_store = vec![ + text_turn("b-u1", TurnRole::User, "under B"), + text_turn("b-a1", TurnRole::Assistant, "B reply"), + text_turn("b2-u1", TurnRole::User, "under B2"), + text_turn("b2-a1", TurnRole::Assistant, "B2 reply"), + ]; + // At the second handoff the rendering showed a-u1 and b-u1: 2 user turns. + let links = vec![link(false, 1), link(true, 2)]; + let out = splice_handoffs(&links, vec![a_segment, Vec::new()], b2_store); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!( + ids, + vec!["a-u1", "a-a1", "handoff-0", "b-u1", "b-a1", "handoff-1", "b2-u1", "b2-a1"] + ); + } + + #[test] + fn no_links_means_no_change() { + let current = vec![text_turn("u1", TurnRole::User, "hi")]; + let out = splice_handoffs(&[], Vec::new(), current.clone()); + assert_eq!(out.len(), 1); + assert_eq!(out[0].id, "u1"); + } + + #[test] + fn links_round_trip_through_rows() { + let row = conversation_handoff::Model { + id: 1, + conversation_id: 7, + seq: 0, + from_agent_type: "claude_code".into(), + from_external_id: Some("S1".into()), + to_agent_type: "custom:claude-code-2".into(), + to_external_id: "S1".into(), + path: "native".into(), + carried: true, + user_turns_before: 4, + note: None, + briefing: None, + truncated: false, + created_at: Utc::now(), + }; + let link = HandoffLink::from_row(&row).expect("parses"); + assert_eq!(link.from_agent_type, AgentType::ClaudeCode); + assert_eq!( + link.to_agent_type, + AgentType::custom("claude-code-2").unwrap() + ); + assert_eq!(link.path, HandoffPath::Native); + assert!(link.carried); + assert_eq!(link.user_turns_before, 4); + + let mut unknown = row; + unknown.to_agent_type = "not_an_agent".into(); + assert!(HandoffLink::from_row(&unknown).is_none()); + } +} diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 9f67bce578..53d01f6749 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -14,6 +14,7 @@ pub mod event_stream; pub mod feedback; pub mod file_system_runtime; pub mod fork; +pub mod handoff; pub mod host_tools_policy; pub mod idle_sweep; pub mod internal_bus; diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 01d46d718c..9b28727897 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -3,10 +3,13 @@ use std::collections::{HashMap, HashSet}; #[cfg(feature = "tauri-runtime")] use tauri::Manager; +use crate::acp::handoff::{self, HandoffLink}; use crate::app_error::AppCommandError; use crate::db::entities::conversation; use crate::db::entities::folder::FolderKind; -use crate::db::service::{conversation_service, folder_service, import_service, tab_service}; +use crate::db::service::{ + conversation_service, folder_service, handoff_service, import_service, tab_service, +}; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; use crate::models::*; @@ -1302,6 +1305,42 @@ pub async fn get_folder_conversation_core( .await; } + // A conversation that was handed off to another agent keeps its earlier + // segments reachable through `conversation_handoff`. Each uncarried + // segment is read from the agent that ran it (its own store, its own + // parser) and spliced in front of the current session behind a divider; + // a carried segment already lives in the current session's store and only + // gets the divider. A segment whose store is gone renders as nothing + // rather than failing the whole detail: the current session still loads. + let handoffs = handoff_service::list_for_conversation(conn, conversation_id) + .await + .unwrap_or_default(); + if !handoffs.is_empty() { + let links: Vec = handoffs.iter().filter_map(HandoffLink::from_row).collect(); + let mut segments: Vec> = Vec::with_capacity(links.len()); + for link in &links { + if link.carried { + segments.push(Vec::new()); + continue; + } + let Some(eid) = link.from_external_id.clone() else { + segments.push(Vec::new()); + continue; + }; + let at = link.from_agent_type; + let segment = tokio::task::spawn_blocking(move || { + build_agent_parser(at) + .get_conversation(&eid) + .map(|d| d.turns) + .unwrap_or_default() + }) + .await + .unwrap_or_default(); + segments.push(segment); + } + turns = handoff::splice_handoffs(&links, segments, turns); + } + let mut summary = summary; summary.message_count = turns.len() as u32; // The transcript is the richer source for the session's model. Codex is diff --git a/src-tauri/src/commands/handoff.rs b/src-tauri/src/commands/handoff.rs new file mode 100644 index 0000000000..bcc1bc50bb --- /dev/null +++ b/src-tauri/src/commands/handoff.rs @@ -0,0 +1,628 @@ +//! Mid-conversation agent handoff commands (`acp_handoff_plan`, +//! `acp_handoff`). The pure halves (path decision, transcript copy, briefing, +//! divider splice) live in `acp::handoff`; this module is the choreography: +//! stop the source, move or brief, spawn the target, verify it took the +//! session, and only then move the conversation row. + +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Duration; + +use serde::Serialize; +#[cfg(feature = "tauri-runtime")] +use tauri::{Manager, State}; + +use crate::acp::handoff::{ + self, build_briefing, BriefingInput, HandoffPath, DEFAULT_BRIEFING_BUDGET, +}; +use crate::acp::manager::ConnectionManager; +use crate::acp::types::{ConnectionStatus, PromptInputBlock}; +use crate::app_error::{AppCommandError, AppErrorCode}; +use crate::commands::acp::{build_session_runtime_env, verify_agent_installed}; +use crate::commands::conversations::{emit_conversation_upsert, get_folder_conversation_core}; +use crate::db::service::{conversation_service, folder_service, handoff_service}; +use crate::db::AppDatabase; +use crate::models::{AgentType, DbConversationSummary, MessageTurn}; +use crate::web::event_bridge::EventEmitter; + +/// How long the target gets to report its session (a `session/load` replay of +/// a long transcript, or a cold `session/new`). Generous on purpose: a slow +/// load is not a failure, an unrelated session id is. +const TARGET_SESSION_TIMEOUT: Duration = Duration::from_secs(120); +const TARGET_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// What the dialog shows before the user confirms. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffPlan { + pub source_agent_type: AgentType, + pub target_agent_type: AgentType, + pub path: HandoffPath, + /// Set when the same-family path was possible in principle but had to be + /// demoted to a summary (`"transcript_missing"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub native_reason: Option<&'static str>, + /// Stable code the frontend localizes when the handoff cannot run: + /// `same_agent`, `no_session`, `not_installed`, `disabled`. + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked_message: Option, + pub turn_count: usize, + /// Summary path only: what the briefing would carry. + pub briefing_chars: usize, + pub briefing_truncated: bool, + pub verbatim_turns: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffResult { + pub conversation_id: i32, + pub folder_id: i32, + pub from_agent_type: AgentType, + pub to_agent_type: AgentType, + /// The session the conversation is bound to now. + pub external_id: String, + pub path: HandoffPath, + pub connection_id: String, + pub briefing_truncated: bool, +} + +#[derive(Debug, Clone)] +pub struct HandoffRequest { + pub conversation_id: i32, + pub target_agent_type: AgentType, + pub note: Option, + /// The target agent's own saved selector preferences (mode / model), read + /// by the frontend from the same store a normal connect uses, so the + /// handoff never carries the source agent's model over. + pub preferred_mode_id: Option, + pub preferred_config_values: BTreeMap, +} + +struct Prepared { + summary: DbConversationSummary, + working_dir: String, + plan: HandoffPlan, + target_env: BTreeMap, + turns: Vec, +} + +async fn prepare( + db: &AppDatabase, + data_dir: &Path, + conversation_id: i32, + target: AgentType, +) -> Result { + let summary = conversation_service::get_by_id(&db.conn, conversation_id) + .await + .map_err(AppCommandError::from)?; + let source = summary.agent_type; + let folder = folder_service::get_folder_by_id(&db.conn, summary.folder_id) + .await + .map_err(AppCommandError::from)?; + let working_dir = summary + .origin_cwd + .clone() + .or_else(|| folder.map(|f| f.path)) + .ok_or_else(|| AppCommandError::not_found("conversation folder not found"))?; + + let mut plan = HandoffPlan { + source_agent_type: source, + target_agent_type: target, + path: HandoffPath::Summary, + native_reason: None, + blocked: None, + blocked_message: None, + turn_count: 0, + briefing_chars: 0, + briefing_truncated: false, + verbatim_turns: 0, + }; + + let mut target_env = BTreeMap::new(); + if target == source { + plan.blocked = Some("same_agent"); + } else if summary.external_id.as_deref().unwrap_or("").is_empty() { + plan.blocked = Some("no_session"); + } else { + match build_session_runtime_env(db, target, None, data_dir).await { + Ok(env) => target_env = env, + Err(e) => { + plan.blocked = Some("disabled"); + plan.blocked_message = Some(e.to_string()); + } + } + if plan.blocked.is_none() { + if let Err(e) = verify_agent_installed(target).await { + plan.blocked = Some("not_installed"); + plan.blocked_message = Some(e.to_string()); + } + } + } + + let (detail, _) = get_folder_conversation_core(&db.conn, conversation_id).await?; + // Earlier handoffs already sit in this timeline as dividers; they are + // bookkeeping, not something the next agent needs to read. + let turns: Vec = detail + .turns + .into_iter() + .filter(|t| !handoff::is_divider_turn(t)) + .collect(); + plan.turn_count = turns.len(); + + if plan.blocked.is_none() { + plan.path = handoff::plan_path(source, target); + if plan.path == HandoffPath::Native { + // The move is only lossless when the source's own store still has + // the transcript. A row whose session file is gone (pruned, or an + // external id that never matched a file) gets the summary instead + // of a native transfer that would come up empty. + let source_env = build_session_runtime_env(db, source, None, data_dir) + .await + .unwrap_or_default(); + let source_home = handoff::claude_config_dir_for(source, &source_env); + let session_id = summary.external_id.as_deref().unwrap_or(""); + let present = crate::parsers::claude::find_session_file_in( + &source_home.join("projects"), + session_id, + ) + .is_some(); + if !present { + plan.path = HandoffPath::Summary; + plan.native_reason = Some("transcript_missing"); + } + } + if plan.path == HandoffPath::Summary { + let briefing = build_briefing(&BriefingInput { + source_label: &source.to_string(), + target_label: &target.to_string(), + working_dir: Some(&working_dir), + title: summary.title.as_deref(), + turns: &turns, + note: None, + budget: DEFAULT_BRIEFING_BUDGET, + }); + plan.briefing_chars = briefing.text.chars().count(); + plan.briefing_truncated = briefing.truncated; + plan.verbatim_turns = briefing.verbatim_turns; + } + } + + Ok(Prepared { + summary, + working_dir, + plan, + target_env, + turns, + }) +} + +pub async fn handoff_plan_core( + db: &AppDatabase, + data_dir: &Path, + conversation_id: i32, + target: AgentType, +) -> Result { + Ok(prepare(db, data_dir, conversation_id, target).await?.plan) +} + +/// The live connection currently serving this conversation, if any. +async fn source_connection( + manager: &ConnectionManager, + summary: &DbConversationSummary, +) -> Option { + if let Some(id) = manager + .find_connection_by_conversation_id(summary.id) + .await + { + return Some(id); + } + let sid = summary.external_id.as_deref()?; + manager + .find_connection_by_external_id(sid, summary.agent_type) + .await +} + +/// Wait for the freshly spawned target to name its session. Returns the id +/// it reported and the status it settled on; `None` means it never did. +async fn wait_for_target_session( + manager: &ConnectionManager, + connection_id: &str, +) -> (Option, ConnectionStatus) { + let deadline = tokio::time::Instant::now() + TARGET_SESSION_TIMEOUT; + loop { + let Some(state) = manager.get_state(connection_id).await else { + return (None, ConnectionStatus::Disconnected); + }; + let (external_id, status) = { + let s = state.read().await; + (s.external_id.clone(), s.status.clone()) + }; + if external_id.is_some() + || matches!(status, ConnectionStatus::Error | ConnectionStatus::Disconnected) + { + return (external_id, status); + } + if tokio::time::Instant::now() >= deadline { + return (external_id, status); + } + tokio::time::sleep(TARGET_POLL_INTERVAL).await; + } +} + +/// Where codeg keeps its own transcript for a custom agent's session. A stale +/// copy from an earlier visit of the same session would stop the load replay +/// from re-hydrating it (`has_recorded_history` gate), so it is set aside +/// before the target loads and put back if the load fails. +fn custom_transcript_path(agent_type: AgentType, session_id: &str) -> Option { + agent_type.custom_id()?; + let dir = crate::acp::registry::registry_id_for(agent_type); + crate::acp_transcript::transcript_path_in( + &crate::paths::codeg_acp_transcripts_root(), + dir, + session_id, + ) + .filter(|p| p.exists()) +} + +fn set_aside(path: &Path) -> Option { + let aside = path.with_extension(format!( + "jsonl.superseded-{}", + crate::acp_transcript::now_epoch_ms() + )); + std::fs::rename(path, &aside).ok().map(|_| aside) +} + +#[allow(clippy::too_many_arguments)] +pub async fn handoff_core( + db: &AppDatabase, + manager: &ConnectionManager, + emitter: &EventEmitter, + data_dir: &Path, + owner_window_label: String, + request: HandoffRequest, +) -> Result { + let target = request.target_agent_type; + let prepared = prepare(db, data_dir, request.conversation_id, target).await?; + if let Some(code) = prepared.plan.blocked { + let message = prepared + .plan + .blocked_message + .clone() + .unwrap_or_else(|| format!("handoff blocked: {code}")); + return Err(AppCommandError::invalid_input(message).with_detail(code)); + } + let summary = prepared.summary; + let source = summary.agent_type; + let conversation_id = summary.id; + let session_id = summary + .external_id + .clone() + .ok_or_else(|| AppCommandError::invalid_input("conversation has no session yet"))?; + + // A turn in flight would keep writing into the source session after the + // row moved. Refuse rather than race it; the frontend re-queues on this + // code the way it does for a fork. + let source_conn = source_connection(manager, &summary).await; + if let Some(conn_id) = &source_conn { + if let Some(state) = manager.get_state(conn_id).await { + if state.read().await.turn_in_flight { + // Wording carries the `turn already in progress` marker the + // frontend's `isTurnInProgressRejection` matches on the Tauri + // transport, where only the Display string reaches it. + return Err(AppCommandError::new( + AppErrorCode::TurnInProgress, + "turn already in progress: stop it or wait for it to finish before handing off", + )); + } + } + } + + let user_turns_before = handoff::count_user_turns(&prepared.turns); + let note = request + .note + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_string); + + match prepared.plan.path { + HandoffPath::Native => { + let source_env = build_session_runtime_env(db, source, None, data_dir) + .await + .unwrap_or_default(); + let source_home = handoff::claude_config_dir_for(source, &source_env); + let target_home = handoff::claude_config_dir_for(target, &prepared.target_env); + if source_home == target_home { + return Err(AppCommandError::invalid_input( + "both agents use the same Claude config directory; there is nothing to move", + )); + } + + // The source connection is done with this session; disconnect it + // so nothing appends to the file while it is copied. + if let Some(conn_id) = &source_conn { + let _ = manager.disconnect(conn_id).await; + } + + let copied = handoff::copy_claude_session(&source_home, &target_home, &session_id) + .map_err(|e| AppCommandError::io_error(e.to_string()))?; + let set_aside_transcript = custom_transcript_path(target, &session_id) + .and_then(|p| set_aside(&p).map(|aside| (p, aside))); + + let rollback = |copied: &handoff::CopiedSession| { + handoff::remove_copied_session(copied); + if let Some((original, aside)) = &set_aside_transcript { + let _ = std::fs::rename(aside, original); + } + }; + + let connection_id = match manager + .spawn_agent( + target, + Some(prepared.working_dir.clone()), + Some(session_id.clone()), + prepared.target_env.clone(), + owner_window_label, + emitter.clone(), + request.preferred_mode_id.clone(), + request.preferred_config_values.clone(), + ) + .await + { + Ok(id) => id, + Err(e) => { + rollback(&copied); + return Err(AppCommandError::task_execution_failed(e.to_string())); + } + }; + + // The target must have loaded THIS session. A custom Claude slot + // that cannot load a session quietly opens a fresh one instead + // (`recovers_load_failure_locally`), and a built-in that cannot + // ends in `Error`; both leave the copy where it is and the row + // untouched. + let (reported, status) = wait_for_target_session(manager, &connection_id).await; + let loaded = reported.as_deref() == Some(session_id.as_str()) + && !matches!(status, ConnectionStatus::Error | ConnectionStatus::Disconnected); + if !loaded { + let _ = manager.disconnect(&connection_id).await; + if let Some(stray) = reported.filter(|r| r != &session_id) { + // The fallback session's header-only transcript would list + // as an empty conversation of the target; drop it. + if let Some(path) = custom_transcript_path(target, &stray) { + let dir = crate::acp::registry::registry_id_for(target); + if !crate::acp_transcript::has_entries_in( + &crate::paths::codeg_acp_transcripts_root(), + dir, + &stray, + ) { + let _ = std::fs::remove_file(path); + } + } + } + rollback(&copied); + return Err(AppCommandError::task_execution_failed(format!( + "{target} could not load the copied transcript (session {session_id}); \ + the conversation was left on {source}" + ))); + } + + if let Err(e) = + conversation_service::rebind_for_handoff(&db.conn, conversation_id, target, &session_id) + .await + { + let _ = manager.disconnect(&connection_id).await; + rollback(&copied); + return Err(AppCommandError::from(e)); + } + handoff_service::record( + &db.conn, + handoff_service::NewHandoff { + conversation_id, + from_agent_type: source, + from_external_id: Some(session_id.clone()), + to_agent_type: target, + to_external_id: session_id.clone(), + path: HandoffPath::Native.as_str(), + carried: true, + user_turns_before: u32::try_from(user_turns_before).unwrap_or(u32::MAX), + note, + briefing: None, + truncated: false, + }, + ) + .await + .map_err(AppCommandError::from)?; + emit_conversation_upsert(emitter, &db.conn, conversation_id).await; + tracing::info!( + conversation_id, + from = %source, + to = %target, + session_id = %session_id, + "[handoff] native transfer complete" + ); + Ok(HandoffResult { + conversation_id, + folder_id: summary.folder_id, + from_agent_type: source, + to_agent_type: target, + external_id: session_id, + path: HandoffPath::Native, + connection_id, + briefing_truncated: false, + }) + } + HandoffPath::Summary => { + let briefing = build_briefing(&BriefingInput { + source_label: &source.to_string(), + target_label: &target.to_string(), + working_dir: Some(&prepared.working_dir), + title: summary.title.as_deref(), + turns: &prepared.turns, + note: note.as_deref(), + budget: DEFAULT_BRIEFING_BUDGET, + }); + + if let Some(conn_id) = &source_conn { + let _ = manager.disconnect(conn_id).await; + } + + let connection_id = manager + .spawn_agent( + target, + Some(prepared.working_dir.clone()), + None, + prepared.target_env.clone(), + owner_window_label, + emitter.clone(), + request.preferred_mode_id.clone(), + request.preferred_config_values.clone(), + ) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + + let (reported, status) = wait_for_target_session(manager, &connection_id).await; + let new_session_id = match reported { + Some(id) + if !matches!(status, ConnectionStatus::Error | ConnectionStatus::Disconnected) => + { + id + } + _ => { + let _ = manager.disconnect(&connection_id).await; + return Err(AppCommandError::task_execution_failed(format!( + "{target} did not open a session; the conversation was left on {source}" + ))); + } + }; + + if let Err(e) = conversation_service::rebind_for_handoff( + &db.conn, + conversation_id, + target, + &new_session_id, + ) + .await + { + let _ = manager.disconnect(&connection_id).await; + return Err(AppCommandError::from(e)); + } + handoff_service::record( + &db.conn, + handoff_service::NewHandoff { + conversation_id, + from_agent_type: source, + from_external_id: Some(session_id.clone()), + to_agent_type: target, + to_external_id: new_session_id.clone(), + path: HandoffPath::Summary.as_str(), + carried: false, + user_turns_before: u32::try_from(user_turns_before).unwrap_or(u32::MAX), + note, + briefing: Some(briefing.text.clone()), + truncated: briefing.truncated, + }, + ) + .await + .map_err(AppCommandError::from)?; + emit_conversation_upsert(emitter, &db.conn, conversation_id).await; + + // The row already holds the new session, so this bind is the + // ordinary "row already holds the id" case in `bind_external_id`: + // nothing splits. The briefing is the target's first prompt. + manager + .send_prompt_linked( + db, + &connection_id, + vec![PromptInputBlock::Text { + text: briefing.text, + }], + Some(summary.folder_id), + Some(conversation_id), + None, + ) + .await + .map_err(|e| { + AppCommandError::task_execution_failed(format!( + "the conversation now belongs to {target}, but the briefing could not be \ + sent: {e}. Send a message to continue." + )) + })?; + tracing::info!( + conversation_id, + from = %source, + to = %target, + session_id = %new_session_id, + truncated = briefing.truncated, + "[handoff] summary handoff complete" + ); + Ok(HandoffResult { + conversation_id, + folder_id: summary.folder_id, + from_agent_type: source, + to_agent_type: target, + external_id: new_session_id, + path: HandoffPath::Summary, + connection_id, + briefing_truncated: briefing.truncated, + }) + } + } +} + +#[cfg(feature = "tauri-runtime")] +fn effective_data_dir(app_handle: &tauri::AppHandle) -> std::path::PathBuf { + app_handle + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| std::path::PathBuf::from(".")) +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn acp_handoff_plan( + conversation_id: i32, + target_agent_type: AgentType, + db: State<'_, AppDatabase>, + app_handle: tauri::AppHandle, +) -> Result { + let data_dir = effective_data_dir(&app_handle); + handoff_plan_core(&db, &data_dir, conversation_id, target_agent_type).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +#[allow(clippy::too_many_arguments)] +pub async fn acp_handoff( + conversation_id: i32, + target_agent_type: AgentType, + note: Option, + preferred_mode_id: Option, + preferred_config_values: Option>, + db: State<'_, AppDatabase>, + manager: State<'_, ConnectionManager>, + app_handle: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Result { + let data_dir = effective_data_dir(&app_handle); + let emitter = EventEmitter::Tauri(app_handle.clone()); + handoff_core( + &db, + &manager, + &emitter, + &data_dir, + window.label().to_string(), + HandoffRequest { + conversation_id, + target_agent_type, + note, + preferred_mode_id, + preferred_config_values: preferred_config_values.unwrap_or_default(), + }, + ) + .await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6b6e316fc7..b6d786ecea 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ pub mod folder_commands; pub mod folder_links; pub mod folders; pub mod forge; +pub mod handoff; pub mod logging; pub mod mcp; pub mod mcp_service; diff --git a/src-tauri/src/db/entities/conversation_handoff.rs b/src-tauri/src/db/entities/conversation_handoff.rs new file mode 100644 index 0000000000..31f5cd4202 --- /dev/null +++ b/src-tauri/src/db/entities/conversation_handoff.rs @@ -0,0 +1,51 @@ +use sea_orm::entity::prelude::*; + +/// One in-place agent switch of a conversation (see `acp::handoff`). +/// +/// The conversation row names only its CURRENT agent and session. Each handoff +/// leaves one of these behind so the segment that lived under the previous +/// agent stays reachable: the detail read walks the rows in `seq` order, reads +/// every uncarried segment from its own agent's store, and renders a divider +/// between segments. `conversation_id` is a soft reference (conversations +/// soft-delete), matching every other cross-table reference in this schema. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "conversation_handoff")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub conversation_id: i32, + /// Position in the conversation's chain, oldest first. + pub seq: i32, + /// Wire form of the agent the segment ran under (`AgentType::as_wire`). + #[sea_orm(column_type = "Text")] + pub from_agent_type: String, + /// The session that segment is read from, in `from_agent_type`'s store. + #[sea_orm(column_type = "Text", nullable)] + pub from_external_id: Option, + #[sea_orm(column_type = "Text")] + pub to_agent_type: String, + #[sea_orm(column_type = "Text")] + pub to_external_id: String, + /// `"native"` or `"summary"` (`acp::handoff::HandoffPath::as_str`). + #[sea_orm(column_type = "Text")] + pub path: String, + /// The target session carries the whole prior history itself, so the + /// earlier segment is NOT read again (it would render twice). + pub carried: bool, + /// User turns rendered at handoff time; where the divider goes inside a + /// carried session, whose own store holds both halves. + pub user_turns_before: i32, + #[sea_orm(column_type = "Text", nullable)] + pub note: Option, + /// The briefing the target was seeded with (summary path only). + #[sea_orm(column_type = "Text", nullable)] + pub briefing: Option, + /// The briefing had to drop earlier turns to fit the prompt budget. + pub truncated: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 6d8bbe07ad..1ae516d09d 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -8,6 +8,7 @@ pub mod chat_channel_message_log; pub mod chat_channel_sender_context; pub mod chat_channel_thread_binding; pub mod conversation; +pub mod conversation_handoff; pub mod custom_agent; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index b40272cba9..b5f2ec07d4 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -10,6 +10,7 @@ pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; pub use super::chat_channel_thread_binding::Entity as ChatChannelThreadBinding; pub use super::conversation::Entity as Conversation; +pub use super::conversation_handoff::Entity as ConversationHandoff; pub use super::custom_agent::Entity as CustomAgent; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; diff --git a/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs b/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs new file mode 100644 index 0000000000..75f6b31d06 --- /dev/null +++ b/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs @@ -0,0 +1,212 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // conversation_handoff: one row per time a conversation was handed to a + // different agent in place (see `acp::handoff`). The conversation row + // itself only ever names its CURRENT agent + session; this table is what + // keeps the earlier segments reachable, so a conversation that moved + // from Claude to Codex still renders the Claude turns above the divider. + // + // `conversation_id` is a SOFT reference (no FK): conversations + // soft-delete, so a cascade would never fire, and a handoff record has + // nothing to outlive: it is meaningless without its conversation and is + // simply never read once the row is gone. + manager + .create_table( + Table::create() + .table(ConversationHandoff::Table) + .if_not_exists() + .col( + ColumnDef::new(ConversationHandoff::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(ConversationHandoff::ConversationId) + .integer() + .not_null(), + ) + // Position in the conversation's chain, oldest first. + .col(ColumnDef::new(ConversationHandoff::Seq).integer().not_null()) + .col( + ColumnDef::new(ConversationHandoff::FromAgentType) + .text() + .not_null(), + ) + // The session the earlier segment is read from. Its store + // belongs to `from_agent_type`, which is why the pair is + // kept rather than the id alone. + .col(ColumnDef::new(ConversationHandoff::FromExternalId).text()) + .col( + ColumnDef::new(ConversationHandoff::ToAgentType) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ConversationHandoff::ToExternalId) + .text() + .not_null(), + ) + // "native" | "summary" + .col(ColumnDef::new(ConversationHandoff::Path).text().not_null()) + // True when the target session carries the whole prior + // history itself (native transfer), so the earlier segment + // must NOT be read again or it would render twice. + .col( + ColumnDef::new(ConversationHandoff::Carried) + .boolean() + .not_null() + .default(false), + ) + // User turns rendered at handoff time: where the divider + // goes inside a carried session. + .col( + ColumnDef::new(ConversationHandoff::UserTurnsBefore) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(ConversationHandoff::Note).text()) + // The briefing the target was seeded with (summary path). + .col(ColumnDef::new(ConversationHandoff::Briefing).text()) + .col( + ColumnDef::new(ConversationHandoff::Truncated) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ConversationHandoff::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // Every detail read of a conversation asks "which handoffs does this + // row have", so the lookup must not be a table scan. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_conversation_handoff_conversation_id") + .table(ConversationHandoff::Table) + .col(ConversationHandoff::ConversationId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ConversationHandoff::Table) + .if_exists() + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ConversationHandoff { + Table, + Id, + ConversationId, + Seq, + FromAgentType, + FromExternalId, + ToAgentType, + ToExternalId, + Path, + Carried, + UserTurnsBefore, + Note, + Briefing, + Truncated, + CreatedAt, +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm_migration::sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + + /// `up` creates the table with its defaults, so a row written with only the + /// required columns reads back as an uncarried, untruncated handoff. + #[tokio::test] + async fn up_creates_the_table_with_defaults() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + + Migration + .up(&SchemaManager::new(&conn)) + .await + .expect("run migration up"); + + conn.execute_unprepared( + "INSERT INTO conversation_handoff (conversation_id, seq, from_agent_type, \ + to_agent_type, to_external_id, path, created_at) \ + VALUES (7, 0, 'claude_code', 'codex', 'S2', 'summary', '2026-09-06T00:00:00Z')", + ) + .await + .expect("insert row"); + + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT carried, truncated, user_turns_before, from_external_id \ + FROM conversation_handoff" + .to_owned(), + )) + .await + .expect("query rows"); + assert_eq!(rows.len(), 1); + let carried: bool = rows[0].try_get("", "carried").expect("carried col"); + let truncated: bool = rows[0].try_get("", "truncated").expect("truncated col"); + let before: i32 = rows[0] + .try_get("", "user_turns_before") + .expect("user_turns_before col"); + let from: Option = rows[0] + .try_get("", "from_external_id") + .expect("from_external_id col"); + assert!(!carried); + assert!(!truncated); + assert_eq!(before, 0); + assert!(from.is_none()); + } + + /// `down` removes the table again, and `up` is idempotent over an existing + /// table (`if_not_exists`), so a re-run cannot fail a fresh install. + #[tokio::test] + async fn up_is_idempotent_and_down_drops() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + let manager = SchemaManager::new(&conn); + Migration.up(&manager).await.expect("first up"); + Migration.up(&manager).await.expect("second up"); + Migration.down(&manager).await.expect("down"); + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation_handoff'" + .to_owned(), + )) + .await + .expect("query sqlite_master"); + assert!(rows.is_empty(), "table must be gone after down"); + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 54e642be74..84899811c0 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -44,6 +44,7 @@ mod m20260825_000001_remote_workspace_connection_headers; mod m20260829_000001_folder_group; mod m20260830_000001_canvas_node; mod m20260831_000001_canvas_node_group_grid; +mod m20260906_000001_conversation_handoff; mod m20260907_000001_canvas_node_path; pub struct Migrator; @@ -95,6 +96,7 @@ impl MigratorTrait for Migrator { Box::new(m20260829_000001_folder_group::Migration), Box::new(m20260830_000001_canvas_node::Migration), Box::new(m20260831_000001_canvas_node_group_grid::Migration), + Box::new(m20260906_000001_conversation_handoff::Migration), Box::new(m20260907_000001_canvas_node_path::Migration), ] } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 08ec608838..5c9db0bf34 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -865,6 +865,98 @@ pub async fn bind_external_id( } } +/// Move a conversation to a different agent AND session in one write, for an +/// in-place handoff (`acp::handoff`). +/// +/// This is deliberately not [`bind_external_id`]: that primitive keeps the +/// outgoing session reachable by splitting it onto a fresh row, because for it +/// a session change means "unrelated session landed here". A handoff is the +/// opposite promise. The previous `(agent_type, external_id)` pair is recorded +/// in `conversation_handoff` BEFORE this runs, so the earlier history stays +/// reachable through the same row and a split would produce exactly the +/// duplicate conversation the chain exists to avoid. +/// +/// Refuses with [`DbError::Conflict`] when another row (live or soft-deleted; +/// the unique index counts both) already holds the incoming pair. Nothing is +/// written then, so the caller's session stays where it is. `model` is +/// cleared for the same reason `bind_external_id` clears it: it described the +/// previous agent's session and `seed_model_if_empty` would never correct it. +pub async fn rebind_for_handoff( + conn: &DatabaseConnection, + conversation_id: i32, + agent_type: AgentType, + external_id: &str, +) -> Result<(), DbError> { + use sea_orm::sea_query::Expr; + use sea_orm::TransactionTrait; + + let agent_type_str = agent_type.as_wire().into_owned(); + let external_id = external_id.to_string(); + let requested = (agent_type_str.clone(), external_id.clone()); + let outcome = conn + .transaction::<_, Option, sea_orm::DbErr>(|txn| { + Box::pin(async move { + // Write first, as in `bind_external_id`: a self-assignment takes + // the SQLite writer lock without a deferred read snapshot. + let claimed = conversation::Entity::update_many() + .col_expr( + conversation::Column::UpdatedAt, + Expr::col(conversation::Column::UpdatedAt).into(), + ) + .filter(conversation::Column::Id.eq(conversation_id)) + .filter(conversation::Column::DeletedAt.is_null()) + .exec(txn) + .await?; + if claimed.rows_affected == 0 { + return Err(sea_orm::DbErr::RecordNotFound(format!( + "conversation {conversation_id} not found" + ))); + } + let holder = conversation::Entity::find() + .filter(conversation::Column::ExternalId.eq(external_id.clone())) + .filter(conversation::Column::AgentType.eq(agent_type_str.clone())) + .filter(conversation::Column::Id.ne(conversation_id)) + .one(txn) + .await?; + if let Some(holder) = holder { + return Ok(Some(holder.id)); + } + let current = conversation::Entity::find_by_id(conversation_id) + .one(txn) + .await? + .ok_or_else(|| { + sea_orm::DbErr::RecordNotFound(format!( + "conversation {conversation_id} not found" + )) + })?; + let mut active: conversation::ActiveModel = current.into(); + active.agent_type = Set(agent_type_str); + active.external_id = Set(Some(external_id)); + active.model = Set(None); + active.updated_at = Set(Utc::now()); + active.update(txn).await?; + Ok(None) + }) + }) + .await + .map_err(|e| match e { + sea_orm::TransactionError::Connection(sea_orm::DbErr::RecordNotFound(msg)) + | sea_orm::TransactionError::Transaction(sea_orm::DbErr::RecordNotFound(msg)) => { + DbError::NotFound(msg) + } + sea_orm::TransactionError::Connection(e) + | sea_orm::TransactionError::Transaction(e) => DbError::Database(e), + })?; + match outcome { + None => Ok(()), + Some(holder_row_id) => Err(DbError::Conflict(format!( + "agent session {} is already bound to conversation {holder_row_id} for {}; \ + refusing to hand conversation {conversation_id} off onto it", + requested.1, requested.0 + ))), + } +} + /// What [`bind_external_id`]'s transaction concluded. /// /// A refusal has to leave the closure as a distinct VALUE rather than an early @@ -1794,6 +1886,133 @@ mod tests { .expect("query by external_id") } + #[tokio::test] + async fn rebind_for_handoff_moves_agent_and_session_in_place() { + // A handoff moves the SAME row to a new agent and session: no sibling + // row, no split, the model cleared for the new session to seed. + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-rebind").await; + let row = create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("Retry loop".into()), + Some("main".into()), + ) + .await + .expect("create"); + bind_external_id(&db.conn, row.id, "S1", &[]) + .await + .expect("bind S1"); + seed_model_if_empty(&db.conn, row.id, "claude-sonnet") + .await + .expect("seed model"); + + rebind_for_handoff(&db.conn, row.id, AgentType::Codex, "S2") + .await + .expect("rebind"); + + let current = raw_row(&db.conn, row.id).await; + assert_eq!(current.agent_type, "codex"); + assert_eq!(current.external_id.as_deref(), Some("S2")); + assert!( + current.model.is_none(), + "the model described the Claude session and must not survive onto Codex" + ); + assert_eq!( + current.title.as_deref(), + Some("Retry loop"), + "the row keeps its identity" + ); + assert_eq!(current.git_branch.as_deref(), Some("main")); + let rows = conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()) + .all(&db.conn) + .await + .expect("list"); + assert_eq!(rows.len(), 1, "a handoff never splits a conversation"); + assert!( + rows_holding(&db.conn, "S1").await.is_empty(), + "the outgoing pair is reachable through conversation_handoff, not a row" + ); + + // A native transfer keeps the session id and only changes the agent. + rebind_for_handoff( + &db.conn, + row.id, + AgentType::custom("codex-2").unwrap(), + "S2", + ) + .await + .expect("same-session rebind"); + let current = raw_row(&db.conn, row.id).await; + assert_eq!(current.agent_type, "custom:codex-2"); + assert_eq!(current.external_id.as_deref(), Some("S2")); + } + + #[tokio::test] + async fn rebind_for_handoff_refuses_a_pair_another_row_holds() { + // The unique index is over (external_id, agent_type). Taking a pair + // another row holds would orphan THAT row's history, so the handoff is + // refused and the row left exactly as it was. + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-conflict").await; + let holder = create(&db.conn, folder, AgentType::Codex, None, None) + .await + .expect("holder"); + bind_external_id(&db.conn, holder.id, "S2", &[]) + .await + .expect("bind holder"); + let row = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("row"); + bind_external_id(&db.conn, row.id, "S1", &[]) + .await + .expect("bind row"); + let before = raw_row(&db.conn, row.id).await; + + let err = rebind_for_handoff(&db.conn, row.id, AgentType::Codex, "S2") + .await + .expect_err("must refuse"); + assert!(matches!(err, DbError::Conflict(_)), "got {err:?}"); + let after = raw_row(&db.conn, row.id).await; + assert_eq!(after.agent_type, before.agent_type); + assert_eq!(after.external_id, before.external_id); + assert_eq!(after.updated_at, before.updated_at); + + // The same pair on a DIFFERENT agent is free: the index is per agent. + rebind_for_handoff(&db.conn, row.id, AgentType::Grok, "S2") + .await + .expect("different agent, same id"); + assert_eq!(raw_row(&db.conn, row.id).await.agent_type, "grok"); + } + + #[tokio::test] + async fn rebind_for_handoff_ignores_deleted_and_missing_rows() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-deleted").await; + let row = create(&db.conn, folder, AgentType::ClaudeCode, None, None) + .await + .expect("row"); + bind_external_id(&db.conn, row.id, "S1", &[]) + .await + .expect("bind"); + soft_delete(&db.conn, row.id).await.expect("delete"); + + let err = rebind_for_handoff(&db.conn, row.id, AgentType::Codex, "S2") + .await + .expect_err("a deleted row is not a handoff target"); + assert!(matches!(err, DbError::NotFound(_)), "got {err:?}"); + let after = raw_row(&db.conn, row.id).await; + assert_eq!(after.agent_type, "claude_code"); + assert_eq!(after.external_id.as_deref(), Some("S1")); + + let err = rebind_for_handoff(&db.conn, 999_999, AgentType::Codex, "S3") + .await + .expect_err("unknown row"); + assert!(matches!(err, DbError::NotFound(_)), "got {err:?}"); + } + #[tokio::test] async fn bind_external_id_preserves_the_outgoing_session_on_a_new_row() { // codeg#500 in miniature: a row bound to S1 is handed S2 (a fresh diff --git a/src-tauri/src/db/service/handoff_service.rs b/src-tauri/src/db/service/handoff_service.rs new file mode 100644 index 0000000000..2e5440976e --- /dev/null +++ b/src-tauri/src/db/service/handoff_service.rs @@ -0,0 +1,156 @@ +//! Persistence for in-place agent handoffs (`acp::handoff`). +//! +//! A conversation row names only its current agent and session; these rows +//! are the chain of earlier segments. Append-only: a handoff is history the +//! moment it happens, and nothing edits it afterwards. + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::conversation_handoff; +use crate::db::error::DbError; +use crate::models::AgentType; + +/// Everything a handoff record needs. `seq` is assigned here, never by the +/// caller, so two records can never claim the same slot. +#[derive(Debug, Clone)] +pub struct NewHandoff { + pub conversation_id: i32, + pub from_agent_type: AgentType, + pub from_external_id: Option, + pub to_agent_type: AgentType, + pub to_external_id: String, + /// `acp::handoff::HandoffPath::as_str()`. + pub path: &'static str, + pub carried: bool, + pub user_turns_before: u32, + pub note: Option, + pub briefing: Option, + pub truncated: bool, +} + +/// Append one handoff to the conversation's chain and return the stored row. +pub async fn record( + conn: &DatabaseConnection, + new: NewHandoff, +) -> Result { + let seq = conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::ConversationId.eq(new.conversation_id)) + .order_by_desc(conversation_handoff::Column::Seq) + .one(conn) + .await? + .map(|last| last.seq + 1) + .unwrap_or(0); + let row = conversation_handoff::ActiveModel { + id: NotSet, + conversation_id: Set(new.conversation_id), + seq: Set(seq), + from_agent_type: Set(new.from_agent_type.as_wire().into_owned()), + from_external_id: Set(new.from_external_id), + to_agent_type: Set(new.to_agent_type.as_wire().into_owned()), + to_external_id: Set(new.to_external_id), + path: Set(new.path.to_string()), + carried: Set(new.carried), + user_turns_before: Set(i32::try_from(new.user_turns_before).unwrap_or(i32::MAX)), + note: Set(new.note), + briefing: Set(new.briefing), + truncated: Set(new.truncated), + created_at: Set(Utc::now()), + }; + Ok(row.insert(conn).await?) +} + +/// The conversation's handoff chain, oldest first. Empty for a conversation +/// that never changed agents, which is the overwhelmingly common answer. +pub async fn list_for_conversation( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + Ok(conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::ConversationId.eq(conversation_id)) + .order_by_asc(conversation_handoff::Column::Seq) + .all(conn) + .await?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + + fn new_handoff(conversation_id: i32, to: AgentType, to_ext: &str) -> NewHandoff { + NewHandoff { + conversation_id, + from_agent_type: AgentType::ClaudeCode, + from_external_id: Some("S1".into()), + to_agent_type: to, + to_external_id: to_ext.into(), + path: "summary", + carried: false, + user_turns_before: 3, + note: Some("finish the tests".into()), + briefing: Some("briefing".into()), + truncated: false, + } + } + + #[tokio::test] + async fn records_append_in_seq_order_per_conversation() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-seq").await; + let a = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + let b = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + + let first = record(&db.conn, new_handoff(a, AgentType::Codex, "S2")) + .await + .expect("first"); + let second = record(&db.conn, new_handoff(a, AgentType::Grok, "S3")) + .await + .expect("second"); + // A second conversation starts its own chain at 0. + let other = record(&db.conn, new_handoff(b, AgentType::Codex, "S9")) + .await + .expect("other"); + + assert_eq!(first.seq, 0); + assert_eq!(second.seq, 1); + assert_eq!(other.seq, 0); + + let chain = list_for_conversation(&db.conn, a).await.expect("list"); + assert_eq!( + chain.iter().map(|h| h.to_external_id.as_str()).collect::>(), + vec!["S2", "S3"] + ); + assert_eq!(chain[0].from_agent_type, "claude_code"); + assert_eq!(chain[0].to_agent_type, "codex"); + assert_eq!(chain[0].note.as_deref(), Some("finish the tests")); + assert_eq!(chain[0].user_turns_before, 3); + assert!(!chain[0].carried); + + assert!(list_for_conversation(&db.conn, 999_999) + .await + .expect("unknown") + .is_empty()); + } + + #[tokio::test] + async fn custom_agents_round_trip_their_wire_form() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-wire").await; + let id = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + let mut new = new_handoff(id, AgentType::custom("claude-code-2").unwrap(), "S1"); + new.carried = true; + new.path = "native"; + let row = record(&db.conn, new).await.expect("record"); + assert_eq!(row.to_agent_type, "custom:claude-code-2"); + assert_eq!( + AgentType::from_wire(&row.to_agent_type), + Some(AgentType::custom("claude-code-2").unwrap()) + ); + assert!(row.carried); + assert_eq!(row.path, "native"); + } +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index 49cbe4e877..cfe33ffd4a 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -10,6 +10,7 @@ pub mod folder_command_service; pub mod folder_group_service; pub mod folder_link_service; pub mod folder_service; +pub mod handoff_service; pub mod import_service; pub mod model_provider_service; pub mod quick_message_service; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f55afce4aa..0c09a7df22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,7 +69,7 @@ mod tauri_app { conversations, custom_skills as custom_skills_commands, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folder_links, office_tools as office_tools_commands, open_in, + folder_links, handoff as handoff_commands, office_tools as office_tools_commands, open_in, folders, logging as logging_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, @@ -1431,6 +1431,8 @@ mod tauri_app { acp_commands::acp_describe_agent_options, acp_commands::acp_cancel, acp_commands::acp_fork, + handoff_commands::acp_handoff_plan, + handoff_commands::acp_handoff, acp_commands::acp_stop_async_task, acp_commands::acp_respond_permission, acp_commands::acp_answer_question, diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index e4cfeac718..baae1b7ad0 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -1115,7 +1115,7 @@ pub(crate) fn resolve_claude_config_dir() -> PathBuf { resolve_claude_config_dir_from(std::env::var_os("CLAUDE_CONFIG_DIR"), dirs::home_dir()) } -fn resolve_claude_config_dir_from( +pub(crate) fn resolve_claude_config_dir_from( claude_config_dir_env: Option, home_dir: Option, ) -> PathBuf { diff --git a/src-tauri/src/web/handlers/handoff.rs b/src-tauri/src/web/handlers/handoff.rs new file mode 100644 index 0000000000..e4450f84dd --- /dev/null +++ b/src-tauri/src/web/handlers/handoff.rs @@ -0,0 +1,70 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::handoff::{ + handoff_core, handoff_plan_core, HandoffPlan, HandoffRequest, HandoffResult, +}; +use crate::models::AgentType; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpHandoffPlanParams { + pub conversation_id: i32, + pub target_agent_type: AgentType, +} + +pub async fn acp_handoff_plan( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + handoff_plan_core( + &state.db, + &state.data_dir, + params.conversation_id, + params.target_agent_type, + ) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpHandoffParams { + pub conversation_id: i32, + pub target_agent_type: AgentType, + #[serde(default)] + pub note: Option, + #[serde(default)] + pub preferred_mode_id: Option, + #[serde(default)] + pub preferred_config_values: Option>, +} + +pub async fn acp_handoff( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + handoff_core( + &state.db, + &state.connection_manager, + &state.emitter, + &state.data_dir, + "web".to_string(), + HandoffRequest { + conversation_id: params.conversation_id, + target_agent_type: params.target_agent_type, + note: params.note, + preferred_mode_id: params.preferred_mode_id, + preferred_config_values: params.preferred_config_values.unwrap_or_default(), + }, + ) + .await?, + )) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 6a38e353f5..1614de8582 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -19,6 +19,7 @@ pub mod folder_links; pub mod folders; pub mod forge; pub mod git; +pub mod handoff; pub mod logging; pub mod mcp; pub mod mcp_service; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index b6e8a9dbbd..d77fa49af9 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -769,6 +769,11 @@ pub fn build_router( ) .route("/acp_cancel", post(handlers::acp::acp_cancel)) .route("/acp_fork", post(handlers::acp::acp_fork)) + .route( + "/acp_handoff_plan", + post(handlers::handoff::acp_handoff_plan), + ) + .route("/acp_handoff", post(handlers::handoff::acp_handoff)) .route( "/acp_stop_async_task", post(handlers::acp::acp_stop_async_task), diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index a8ce461e35..c9c839833c 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -74,6 +74,9 @@ interface ChatInputProps { steerChannel?: "native" | "pull" onAddFeedback?: () => void feedbackAddDisabled?: boolean + /** Open the "hand off to another agent" dialog. Present only for a + * persisted conversation; the composer's agent control offers it. */ + onHandoff?: () => void /** * Keep the composer usable even while disconnected. Set for a folderless chat * draft: it has no working dir yet (so it never auto-connects), and the FIRST @@ -131,6 +134,7 @@ export const ChatInput = memo(function ChatInput({ steerChannel, onAddFeedback, feedbackAddDisabled, + onHandoff, allowOfflineCompose = false, injectContent, onInjectConsumed, @@ -227,6 +231,7 @@ export const ChatInput = memo(function ChatInput({ steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} + onHandoff={onHandoff} injectContent={injectContent} onInjectConsumed={onInjectConsumed} placeholder={ diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index 2fb3a52869..35585615da 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -109,6 +109,9 @@ interface ConversationShellProps { onAddFeedback?: () => void /** Grey out the live-feedback "+" entry when a note can't be sent right now. */ feedbackAddDisabled?: boolean + /** Opens the handoff dialog from the composer; threaded straight through + * to `ChatInput`. Omitted for drafts, which have nothing to hand off. */ + onHandoff?: () => void isActive?: boolean /** Show the composer's flowing active-session border (tiled multi-session * active tab only). Threaded straight through to the composer. */ @@ -189,6 +192,7 @@ export function ConversationShell({ feedbackList, onAddFeedback, feedbackAddDisabled, + onHandoff, isActive, showActiveFlow, queue, @@ -377,6 +381,7 @@ export function ConversationShell({ steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} + onHandoff={onHandoff} injectContent={injectContent} onInjectConsumed={onInjectConsumed} /> diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 7679ab868f..716699b543 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl" import { isImeCompositionKey } from "@/lib/ime-composition" import { Button } from "@/components/ui/button" import { + ArrowRightLeft, BookOpenText, Check, ChevronUp, @@ -240,6 +241,9 @@ interface MessageInputProps { /** Grey out the live-feedback "+" entry when a note can't be sent right now * (no active turn / agent lacks the tool). */ feedbackAddDisabled?: boolean + /** Open the "hand off to another agent" dialog from the composer's agent + * control. Present only for a persisted conversation. */ + onHandoff?: () => void injectContent?: ComposerInjectContent | null onInjectConsumed?: () => void } @@ -342,10 +346,12 @@ export function MessageInput({ steerChannel = "pull", onAddFeedback, feedbackAddDisabled, + onHandoff, injectContent, onInjectConsumed, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") + const tHandoff = useTranslations("Folder.chat.agentHandoff") const tQueue = useTranslations("Folder.chat.messageQueue") // Kept as a separate binding from `t` so its call sites — exclusively // upload / attachment toasts — read as a single coherent group when @@ -1971,11 +1977,13 @@ export function MessageInput({ {inlineSelectorItems} )} - {hasAnySelector && ( + {(hasAnySelector || onHandoff) && (
)} + {onHandoff && ( + // The agent control of a persisted conversation: + // the agent itself cannot be flipped here (the row + // and its history belong to it), so the way to + // change agents is a handoff. + + )}
diff --git a/src/components/conversations/agent-handoff-dialog.test.tsx b/src/components/conversations/agent-handoff-dialog.test.tsx new file mode 100644 index 0000000000..41899f1b61 --- /dev/null +++ b/src/components/conversations/agent-handoff-dialog.test.tsx @@ -0,0 +1,309 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import type { HandoffPlan, HandoffResult } from "@/lib/api" +import { getAgentLabel } from "@/lib/custom-agents" +import { TurnBusyError } from "@/lib/turn-busy" +import type { AcpAgentInfo, AgentType } from "@/lib/types" + +const h = vi.hoisted(() => ({ + plan: vi.fn(), + handoff: vi.fn(), + openTab: vi.fn(), + closeConversationTab: vi.fn(), + refreshConversations: vi.fn(async () => {}), + setExternalId: vi.fn(), + refetchDetail: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), + prefs: vi.fn(() => ({ modeId: "code", configValues: { model: "gpt-x" } })), + agents: [] as AcpAgentInfo[], +})) + +// Inline SVG marks carry a that would duplicate the agent label. +vi.mock("@/components/agent-icon", () => ({ AgentIcon: () => null })) +vi.mock("sonner", () => ({ + toast: { success: h.toastSuccess, error: h.toastError }, +})) +vi.mock("@/lib/api", () => ({ + acpHandoffPlan: h.plan, + acpHandoff: h.handoff, +})) +vi.mock("@/hooks/use-acp-agents", () => ({ + useAcpAgents: () => ({ agents: h.agents, fresh: true, refresh: vi.fn() }), +})) +vi.mock("@/contexts/tab-context", () => ({ + useTabActions: () => ({ + openTab: h.openTab, + closeConversationTab: h.closeConversationTab, + }), +})) +vi.mock("@/stores/app-workspace-store", () => ({ + useAppWorkspaceStore: (selector: (s: unknown) => unknown) => + selector({ refreshConversations: h.refreshConversations }), +})) +vi.mock("@/stores/conversation-runtime-store", () => ({ + useConversationRuntimeActions: () => ({ + setExternalId: h.setExternalId, + refetchDetail: h.refetchDetail, + }), +})) +vi.mock("@/lib/selector-prefs-storage", () => ({ + getSavedPrefsForConnect: h.prefs, +})) + +import { AgentHandoffDialog } from "./agent-handoff-dialog" + +function agent(agentType: AgentType): AcpAgentInfo { + return { + agent_type: agentType, + skills_capable: true, + registry_id: `${agentType}-registry`, + registry_version: null, + supports_custom_version: false, + name: agentType, + description: "", + available: true, + distribution_type: "npx", + is_acp_adapter: true, + custom_source: null, + enabled: true, + sort_order: 0, + installed_version: "1.0.0", + host_tools_agent_mode: false, + env: {}, + config_json: null, + config_file_path: null, + opencode_auth_json: null, + codex_auth_json: null, + codex_config_toml: null, + codex_model_catalog: null, + codex_sandbox_settings: null, + grok_config_toml: null, + grok_settings: null, + cline_secrets_json: null, + hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, + model_provider_id: null, + icon_url: null, + } +} + +function plan(overrides: Partial<HandoffPlan> = {}): HandoffPlan { + return { + sourceAgentType: "claude_code", + targetAgentType: "codex", + path: "summary", + turnCount: 12, + briefingChars: 4_000, + briefingTruncated: false, + verbatimTurns: 6, + ...overrides, + } +} + +function result(overrides: Partial<HandoffResult> = {}): HandoffResult { + return { + conversationId: 7, + folderId: 3, + fromAgentType: "claude_code", + toAgentType: "codex", + externalId: "S2", + path: "summary", + connectionId: "conn-9", + briefingTruncated: false, + ...overrides, + } +} + +function renderDialog(onOpenChange = vi.fn()) { + render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <AgentHandoffDialog + open + onOpenChange={onOpenChange} + conversationId={7} + folderId={3} + sourceAgentType="claude_code" + title="Retry loop" + /> + </NextIntlClientProvider> + ) + return { onOpenChange } +} + +/** Pick an agent through the selector's pills (labelled by display name). */ +function pick(agentType: AgentType) { + const label = getAgentLabel(agentType) + const pill = screen + .getAllByRole("button") + .find( + (b) => + b.getAttribute("data-slot") === "agent-pill" && + b.textContent?.includes(label) + ) + if (!pill) throw new Error(`no pill for ${agentType}`) + fireEvent.click(pill) +} + +describe("AgentHandoffDialog", () => { + beforeEach(() => { + vi.clearAllMocks() + h.agents = [agent("claude_code"), agent("codex"), agent("grok")] + h.plan.mockResolvedValue(plan()) + h.handoff.mockResolvedValue(result()) + }) + + it("explains the summary path for the chosen target and hands off with the note", async () => { + const { onOpenChange } = renderDialog() + pick("codex") + await waitFor(() => expect(h.plan).toHaveBeenCalledWith(7, "codex")) + expect( + await screen.findByText( + "Codex starts a new session seeded with a briefing: 12 turns summarized, the last 6 in full." + ) + ).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText(/focus on/), { + target: { value: " finish the tests " }, + }) + const confirm = screen.getByRole("button", { name: "Hand off" }) + expect(confirm).toBeEnabled() + fireEvent.click(confirm) + + await waitFor(() => expect(h.handoff).toHaveBeenCalledTimes(1)) + // The note is trimmed and the TARGET's own saved prefs travel, never the + // source agent's model. + expect(h.prefs).toHaveBeenCalledWith("codex") + expect(h.handoff).toHaveBeenCalledWith( + 7, + "codex", + "finish the tests", + "code", + { + model: "gpt-x", + } + ) + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + // Runtime re-pointed at the new session, sidebar refreshed, tab swapped: + // the new tab opens BEFORE the old one closes. + expect(h.setExternalId).toHaveBeenCalledWith(7, "S2") + expect(h.refreshConversations).toHaveBeenCalled() + expect(h.openTab).toHaveBeenCalledWith(3, 7, "codex", false, "Retry loop") + expect(h.closeConversationTab).toHaveBeenCalledWith(3, 7, "claude_code") + expect(h.openTab.mock.invocationCallOrder[0]).toBeLessThan( + h.closeConversationTab.mock.invocationCallOrder[0] + ) + expect(h.refetchDetail).toHaveBeenCalledWith(7) + expect(h.toastSuccess).toHaveBeenCalledWith("Handed off to Codex") + }) + + it("states a native transfer plainly", async () => { + h.plan.mockResolvedValue( + plan({ targetAgentType: "grok", path: "native", verbatimTurns: 0 }) + ) + renderDialog() + pick("grok") + expect( + await screen.findByText( + "Grok will load the full session natively. Nothing is summarized." + ) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Hand off" })).toBeEnabled() + }) + + it("says when the briefing was shortened and when native was demoted", async () => { + h.plan.mockResolvedValue( + plan({ briefingTruncated: true, nativeReason: "transcript_missing" }) + ) + renderDialog() + pick("codex") + const statement = await screen.findByText( + /Codex starts a new session seeded with a briefing\. The conversation is long/ + ) + expect(statement.textContent).toContain( + "The original transcript is no longer in Claude Code's store" + ) + }) + + it("refuses the current agent and a blocked target without calling the backend", async () => { + renderDialog() + pick("claude_code") + expect( + await screen.findByText("This conversation already runs on Claude Code.") + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Hand off" })).toBeDisabled() + expect(h.plan).not.toHaveBeenCalledWith(7, "claude_code") + + h.plan.mockResolvedValue( + plan({ targetAgentType: "grok", blocked: "not_installed" }) + ) + pick("grok") + expect( + await screen.findByText( + "Grok is not installed. Install it in Agent Settings first." + ) + ).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Hand off" })).toBeDisabled() + expect(h.handoff).not.toHaveBeenCalled() + }) + + it("keeps the tab where it was when the backend fails, and names a busy turn", async () => { + h.handoff.mockRejectedValueOnce(new Error("codex did not open a session")) + const { onOpenChange } = renderDialog() + pick("codex") + await screen.findByText(/starts a new session seeded/) + fireEvent.click(screen.getByRole("button", { name: "Hand off" })) + expect( + await screen.findByText("Handoff failed: codex did not open a session") + ).toBeInTheDocument() + expect(h.openTab).not.toHaveBeenCalled() + expect(h.closeConversationTab).not.toHaveBeenCalled() + expect(h.setExternalId).not.toHaveBeenCalled() + expect(onOpenChange).not.toHaveBeenCalledWith(false) + // The dialog is usable again: a second try goes out. + h.handoff.mockRejectedValueOnce(new TurnBusyError()) + fireEvent.click(screen.getByRole("button", { name: "Hand off" })) + expect( + await screen.findByText( + "A turn is still running. Stop it or wait for it to finish, then hand off." + ) + ).toBeInTheDocument() + expect(h.handoff).toHaveBeenCalledTimes(2) + }) + + it("ignores a plan that arrives for an agent the user already moved away from", async () => { + let resolveCodex: (p: HandoffPlan) => void = () => {} + h.plan.mockImplementation((_id: number, target: AgentType) => { + if (target === "codex") { + return new Promise<HandoffPlan>((resolve) => { + resolveCodex = resolve + }) + } + return Promise.resolve( + plan({ targetAgentType: "grok", path: "native", verbatimTurns: 0 }) + ) + }) + renderDialog() + pick("codex") + pick("grok") + expect( + await screen.findByText( + "Grok will load the full session natively. Nothing is summarized." + ) + ).toBeInTheDocument() + resolveCodex(plan({ briefingTruncated: true })) + await new Promise((r) => setTimeout(r, 0)) + expect( + screen.queryByText(/The conversation is long/) + ).not.toBeInTheDocument() + expect( + screen.getByText( + "Grok will load the full session natively. Nothing is summarized." + ) + ).toBeInTheDocument() + }) +}) diff --git a/src/components/conversations/agent-handoff-dialog.tsx b/src/components/conversations/agent-handoff-dialog.tsx new file mode 100644 index 0000000000..fa093f4efc --- /dev/null +++ b/src/components/conversations/agent-handoff-dialog.tsx @@ -0,0 +1,307 @@ +"use client" + +/** + * Hand a conversation to another agent in place. + * + * One dialog, one action. The user picks the agent, optionally says what it + * should focus on, reads which path the backend will take, and confirms. The + * backend (`acp::handoff`) stops the current agent, moves the transcript or + * seeds a briefing, spawns the target and moves the conversation row; this + * dialog then swaps the open tab onto the new agent so the same conversation + * reopens under it. A failure leaves the row and the tab exactly where they + * were, which is why the tab swap only runs after the backend returned. + * + * The path statement is fetched from the backend for the chosen target rather + * than guessed here: only the backend knows which agent homes exist, whether + * the source transcript is still on disk, and how long the briefing would be. + */ + +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { AgentSelector } from "@/components/chat/agent-selector" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { useTabActions } from "@/contexts/tab-context" +import { acpHandoff, acpHandoffPlan, type HandoffPlan } from "@/lib/api" +import { getAgentLabel } from "@/lib/custom-agents" +import { getSavedPrefsForConnect } from "@/lib/selector-prefs-storage" +import { TurnBusyError } from "@/lib/turn-busy" +import type { AgentType } from "@/lib/types" +import { useAppWorkspaceStore } from "@/stores/app-workspace-store" +import { useConversationRuntimeActions } from "@/stores/conversation-runtime-store" + +interface AgentHandoffDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + conversationId: number + folderId: number + sourceAgentType: AgentType + title?: string | null +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === "string") return err + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message + if (typeof message === "string") return message + } + return String(err) +} + +export function AgentHandoffDialog({ + open, + onOpenChange, + conversationId, + folderId, + sourceAgentType, + title, +}: AgentHandoffDialogProps) { + const t = useTranslations("Folder.chat.agentHandoff") + const { openTab, closeConversationTab } = useTabActions() + const refreshConversations = useAppWorkspaceStore( + (s) => s.refreshConversations + ) + const { setExternalId, refetchDetail } = useConversationRuntimeActions() + + const [target, setTarget] = useState<AgentType | null>(null) + const [note, setNote] = useState("") + const [plan, setPlan] = useState<HandoffPlan | null>(null) + const [planLoading, setPlanLoading] = useState(false) + const [planError, setPlanError] = useState<string | null>(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState<string | null>(null) + // A plan request that lands after the target moved on must not describe + // the wrong agent. + const planRequestRef = useRef(0) + + useEffect(() => { + if (!open) { + setTarget(null) + setNote("") + setPlan(null) + setPlanError(null) + setError(null) + setBusy(false) + } + }, [open]) + + useEffect(() => { + // No plan for the agent the conversation already runs on: the answer is + // known here, and the backend would only say the same. + if (!open || !target || target === sourceAgentType) { + setPlan(null) + return + } + const requestId = ++planRequestRef.current + setPlanLoading(true) + setPlanError(null) + setPlan(null) + acpHandoffPlan(conversationId, target) + .then((next) => { + if (planRequestRef.current !== requestId) return + setPlan(next) + }) + .catch((err: unknown) => { + if (planRequestRef.current !== requestId) return + setPlanError(errorMessage(err)) + }) + .finally(() => { + if (planRequestRef.current === requestId) setPlanLoading(false) + }) + }, [open, target, conversationId, sourceAgentType]) + + const targetLabel = target ? getAgentLabel(target) : "" + const sourceLabel = getAgentLabel(sourceAgentType) + + const blockedText = (() => { + if (!target) return null + if (target === sourceAgentType) + return t("blockedSameAgent", { target: targetLabel }) + if (!plan?.blocked) return null + switch (plan.blocked) { + case "same_agent": + return t("blockedSameAgent", { target: targetLabel }) + case "no_session": + return t("blockedNoSession") + case "not_installed": + return t("blockedNotInstalled", { target: targetLabel }) + case "disabled": + return t("blockedDisabled", { target: targetLabel }) + default: + return t("blockedGeneric", { + target: targetLabel, + message: plan.blockedMessage ?? plan.blocked, + }) + } + })() + + const planText = (() => { + if (!target || target === sourceAgentType) return null + if (planLoading) return t("planLoading") + if (planError) + return t("blockedGeneric", { target: targetLabel, message: planError }) + if (!plan || plan.blocked) return null + if (plan.path === "native") return t("planNative", { target: targetLabel }) + if (plan.briefingTruncated) { + return t("planSummaryTruncated", { target: targetLabel }) + } + return t("planSummary", { + target: targetLabel, + turns: plan.turnCount, + verbatim: plan.verbatimTurns, + }) + })() + + const canConfirm = + !!target && + target !== sourceAgentType && + !!plan && + !plan.blocked && + !planLoading && + !planError && + !busy + + const handleConfirm = useCallback(async () => { + if (!target || !canConfirm) return + setBusy(true) + setError(null) + try { + // The target agent's own saved selector preferences, read from the + // same store a normal connect uses: the source agent's model never + // travels with the conversation. + const prefs = getSavedPrefsForConnect(target) + const trimmed = note.trim() + const result = await acpHandoff( + conversationId, + target, + trimmed.length > 0 ? trimmed : null, + prefs.modeId, + prefs.configValues + ) + // The row now names the new agent + session. Point the runtime at the + // new session before any tab reconnects, then swap the tab: open the + // new one first so closing the old cannot spawn a replacement draft. + setExternalId(conversationId, result.externalId) + await refreshConversations() + openTab( + result.folderId, + conversationId, + result.toAgentType, + false, + title ?? undefined + ) + closeConversationTab(folderId, conversationId, sourceAgentType) + refetchDetail(conversationId) + toast.success(t("success", { target: getAgentLabel(result.toAgentType) })) + onOpenChange(false) + } catch (err: unknown) { + const message = + err instanceof TurnBusyError + ? t("busy") + : t("failed", { message: errorMessage(err) }) + setError(message) + toast.error(message) + } finally { + setBusy(false) + } + }, [ + target, + canConfirm, + note, + conversationId, + folderId, + sourceAgentType, + title, + setExternalId, + refreshConversations, + openTab, + closeConversationTab, + refetchDetail, + t, + onOpenChange, + ]) + + return ( + <Dialog + open={open} + onOpenChange={(next) => { + if (busy) return + onOpenChange(next) + }} + > + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>{t("dialogTitle")}</DialogTitle> + <DialogDescription>{t("dialogDescription")}</DialogDescription> + </DialogHeader> + <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-1.5"> + <span className="text-sm font-medium">{t("targetLabel")}</span> + <AgentSelector + defaultAgentType={target ?? undefined} + onSelect={setTarget} + onFallback={setTarget} + disabled={busy} + /> + </div> + <div className="flex flex-col gap-1.5"> + <label className="text-sm font-medium" htmlFor="agent-handoff-note"> + {t("noteLabel")} + </label> + <Textarea + id="agent-handoff-note" + value={note} + onChange={(e) => setNote(e.target.value)} + placeholder={t("notePlaceholder")} + disabled={busy} + rows={3} + /> + </div> + {blockedText ? ( + <p role="alert" className="text-sm text-destructive"> + {blockedText} + </p> + ) : planText ? ( + <p + data-slot="agent-handoff-plan" + className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm text-muted-foreground" + > + {planText} + {plan?.nativeReason === "transcript_missing" ? ( + <> {t("planNativeUnavailable", { source: sourceLabel })}</> + ) : null} + </p> + ) : null} + {error ? ( + <p role="alert" className="text-sm text-destructive"> + {error} + </p> + ) : null} + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={busy} + > + {t("cancel")} + </Button> + <Button onClick={() => void handleConfirm()} disabled={!canConfirm}> + {busy ? t("working") : t("confirm")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/src/components/conversations/conversation-detail-header.tsx b/src/components/conversations/conversation-detail-header.tsx index 0f7a6e8f2b..b9f3a50cf9 100644 --- a/src/components/conversations/conversation-detail-header.tsx +++ b/src/components/conversations/conversation-detail-header.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useState } from "react" import { + ArrowRightLeft, ChevronRight, Circle, EllipsisVertical, @@ -25,7 +26,7 @@ import { ConversationHeaderFolderPicker } from "@/components/chat/conversation-c import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useTabActions } from "@/contexts/tab-context" import { getRuntimeSession } from "@/stores/conversation-runtime-store" -import type { ConversationStatus } from "@/lib/types" +import type { AgentType, ConversationStatus } from "@/lib/types" import { STATUS_ORDER } from "@/lib/types" import { ConversationStatusDot } from "@/components/conversations/conversation-status-dot" import { @@ -62,6 +63,7 @@ import { type ActiveSessionDetails, } from "./active-session-details" import { SessionDetailsDialog } from "./session-details-dialog" +import { AgentHandoffDialog } from "./agent-handoff-dialog" interface ConversationDetailHeaderProps { tabId: string @@ -75,6 +77,9 @@ interface ConversationDetailHeaderProps { folderPath: string | undefined title: string status: ConversationStatus | undefined + /** The agent this tab runs on; names the source of a handoff. Absent on + * surfaces that do not know it, which simply hides that entry. */ + agentType?: AgentType | null } /** @@ -99,12 +104,14 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ folderPath, title, status, + agentType, }: ConversationDetailHeaderProps) { const t = useTranslations("Folder.conversationCard") const ime = useImeGuard() const tConv = useTranslations("Folder.conversation") const tStatus = useTranslations("Folder.statusLabels") const tDetails = useTranslations("Folder.sessionDetails") + const tHandoff = useTranslations("Folder.chat.agentHandoff") const { closeTab, openNewConversationTab } = useTabActions() const updateConversationLocal = useAppWorkspaceStore( (s) => s.updateConversationLocal @@ -138,6 +145,8 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ title: string } | null>(null) const [renameValue, setRenameValue] = useState("") + const [handoffOpen, setHandoffOpen] = useState(false) + const tabAgentType = agentType ?? null const [deleteTarget, setDeleteTarget] = useState<{ id: number tabId: string @@ -300,6 +309,13 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ <Info className="h-4 w-4" /> {tDetails("menuLabel")} </DropdownMenuItem> + <DropdownMenuItem + disabled={!persisted || tabAgentType == null} + onSelect={() => setHandoffOpen(true)} + > + <ArrowRightLeft className="h-4 w-4" /> + {tHandoff("menuLabel")} + </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger disabled={!persisted}> @@ -384,6 +400,17 @@ export const ConversationDetailHeader = memo(function ConversationDetailHeader({ </AlertDialogContent> </AlertDialog> + {handoffOpen && conversationId != null && tabAgentType != null && ( + <AgentHandoffDialog + open + onOpenChange={setHandoffOpen} + conversationId={conversationId} + folderId={folderId} + sourceAgentType={tabAgentType} + title={title} + /> + )} + {details?.summary && ( <SessionDetailsDialog open diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 2e9d0b36e8..4e0ed40437 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -51,6 +51,8 @@ import { SessionConfigStaleBanner } from "@/components/chat/session-config-stale import { PiProjectTrustBanner } from "@/components/chat/pi-project-trust-banner" import { FeedbackNotesDisplay } from "@/components/chat/feedback-notes-display" import { FeedbackDialog } from "@/components/chat/feedback-dialog" +import { AgentHandoffDialog } from "@/components/conversations/agent-handoff-dialog" +import { isHandoffBriefingText } from "@/lib/agent-handoff" import { AgentDiagnosticsDialog } from "@/components/settings/agent-diagnostics-dialog" import { useFeedbackEnabled } from "@/hooks/use-feedback-enabled" import { useSessionFeedback } from "@/hooks/use-session-feedback" @@ -203,6 +205,13 @@ function buildOptimisticUserTurnFromDraft( } } +/** True for the backend-seeded briefing that opens a summary handoff: its + * first text block starts with the handoff marker. Never a real user prompt. */ +function isSeededBriefing(blocks: UserMessageBlock[]): boolean { + const first = blocks.find((b) => b.type === "text") + return first?.type === "text" && isHandoffBriefingText(first.text) +} + /** Build a user `MessageTurn` from a broadcast `user_message` (event or * snapshot `pending_user_message`). Used by cross-client VIEWERS to render the * sender's prompt. The turn `id` is the broadcast `message_id` so the runtime @@ -322,6 +331,7 @@ const ConversationTabView = memo(function ConversationTabView({ getSavedModeId(agentType) ) const [sendSignal, setSendSignal] = useState(0) + const [handoffOpen, setHandoffOpen] = useState(false) const [agentsLoaded, setAgentsLoaded] = useState(false) const [usableAgentCount, setUsableAgentCount] = useState(0) const [composerDiagnosticsOpen, setComposerDiagnosticsOpen] = useState(false) @@ -867,6 +877,10 @@ const ConversationTabView = memo(function ConversationTabView({ useEffect(() => { const pending = conn.pendingUserMessage if (!pending) return + // The prompt that seeds a summary handoff is folded into the divider on + // every detail read; mirroring it here would flash a screen of briefing + // as a user bubble until the refetch lands. + if (isSeededBriefing(pending.blocks)) return appendViewerUserTurn( effectiveConversationId, buildUserTurnFromMessageBlocks(pending.messageId, pending.blocks) @@ -883,6 +897,7 @@ const ConversationTabView = memo(function ConversationTabView({ (envelope: EventEnvelope) => { if (envelope.type !== "user_message") return if (envelope.connection_id !== conn.connectionId) return + if (isSeededBriefing(envelope.blocks)) return appendViewerUserTurn( effectiveConversationId, buildUserTurnFromMessageBlocks(envelope.message_id, envelope.blocks) @@ -2132,6 +2147,13 @@ const ConversationTabView = memo(function ConversationTabView({ : undefined } steerChannel={feedback.channel} + // A persisted conversation's agent control offers the handoff; a draft + // still picks its agent through the selector and has nothing to move. + onHandoff={ + hasPersistedConversation && dbConversationId != null + ? () => setHandoffOpen(true) + : undefined + } > {isWelcomeMode ? ( // Same overlay scrollbar as the sidebar / file lists (os-theme-codeg) @@ -2277,6 +2299,16 @@ const ConversationTabView = memo(function ConversationTabView({ ) : ( messageListNode )} + {handoffOpen && dbConversationId != null && ( + <AgentHandoffDialog + open + onOpenChange={setHandoffOpen} + conversationId={dbConversationId} + folderId={folderId} + sourceAgentType={selectedAgent} + title={ownTab?.title} + /> + )} <FeedbackDialog open={feedback.dialogOpen} onOpenChange={(open) => { @@ -2804,6 +2836,7 @@ export function ConversationDetailPanel() { > <ConversationDetailHeader tabId={selTab.id} + agentType={selTab.agentType} conversationId={selTab.conversationId} runtimeConversationId={selTab.runtimeConversationId ?? null} folderId={selTab.folderId} @@ -2852,6 +2885,7 @@ export function ConversationDetailPanel() { {!isSplit && activeTab && ( <ConversationDetailHeader tabId={activeTab.id} + agentType={activeTab.agentType} conversationId={activeTab.conversationId} runtimeConversationId={activeTab.runtimeConversationId ?? null} folderId={activeTab.folderId} diff --git a/src/components/conversations/sidebar-conversation-card.tsx b/src/components/conversations/sidebar-conversation-card.tsx index 3dc7fc8332..41b04a9aa5 100644 --- a/src/components/conversations/sidebar-conversation-card.tsx +++ b/src/components/conversations/sidebar-conversation-card.tsx @@ -8,6 +8,7 @@ import { type FocusEvent, } from "react" import { + ArrowRightLeft, AtSign, Pencil, Trash2, @@ -66,6 +67,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { ConversationStatusDot } from "./conversation-status-dot" import { SessionDetailsDialog } from "./session-details-dialog" +import { AgentHandoffDialog } from "./agent-handoff-dialog" import { SidebarConversationHoverDetails } from "./sidebar-conversation-hover-details" import { AgentIcon } from "@/components/agent-icon" @@ -214,9 +216,11 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ const tSidebar = useTranslations("Folder.sidebar") const tStatus = useTranslations("Folder.statusLabels") const tDetails = useTranslations("Folder.sessionDetails") + const tHandoff = useTranslations("Folder.chat.agentHandoff") const [renameOpen, setRenameOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false) + const [handoffOpen, setHandoffOpen] = useState(false) const [renameValue, setRenameValue] = useState("") const [attachTabId, setAttachTabId] = useState<string | null>(null) const [hoverOpen, setHoverOpen] = useState(false) @@ -670,6 +674,10 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ <AtSign className="h-4 w-4" /> {t("attachToCurrentSession")} </ContextMenuItem> + <ContextMenuItem onSelect={() => setHandoffOpen(true)}> + <ArrowRightLeft className="h-4 w-4" /> + {tHandoff("menuLabel")} + </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuSub> <ContextMenuSubTrigger> @@ -771,6 +779,16 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({ summary={conversation} /> )} + {handoffOpen && ( + <AgentHandoffDialog + open + onOpenChange={setHandoffOpen} + conversationId={conversation.id} + folderId={conversation.folder_id} + sourceAgentType={conversation.agent_type} + title={conversation.title} + /> + )} </> ) }) diff --git a/src/components/message/agent-handoff-card.test.tsx b/src/components/message/agent-handoff-card.test.tsx new file mode 100644 index 0000000000..94c1d72abc --- /dev/null +++ b/src/components/message/agent-handoff-card.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" + +// Inline SVG marks carry a <title> that would duplicate the agent label in +// text queries. +vi.mock("@/components/agent-icon", () => ({ AgentIcon: () => null })) + +import { AgentHandoffCard } from "./agent-handoff-card" +import enMessages from "@/i18n/messages/en.json" + +function renderCard(meta: Record<string, unknown> | null) { + return render( + <NextIntlClientProvider locale="en" messages={enMessages}> + <AgentHandoffCard meta={meta} /> + </NextIntlClientProvider> + ) +} + +describe("AgentHandoffCard", () => { + it("names both agents and says the context moved natively", () => { + renderCard({ + "codeg.handoff": { + version: 1, + from: "claude_code", + to: "codex", + path: "native", + carried: true, + truncated: false, + }, + }) + expect( + screen.getByText("Handed off from Claude Code to Codex") + ).toBeInTheDocument() + expect(screen.getByText("· Full context carried over")).toBeInTheDocument() + expect(screen.queryByText("Show briefing")).not.toBeInTheDocument() + }) + + it("shows the note and folds the briefing behind a toggle", () => { + renderCard({ + "codeg.handoff": { + version: 1, + from: "claude_code", + to: "grok", + path: "summary", + carried: false, + truncated: false, + note: "finish the tests", + briefing: "<!-- codeg:handoff-briefing -->\nthe whole story", + }, + }) + expect(screen.getByText("· Continued from a briefing")).toBeInTheDocument() + expect(screen.getByText("Focus: finish the tests")).toBeInTheDocument() + // Collapsed by default: the text is in the DOM but hidden, so it never + // takes a screen of space unasked. + const briefing = screen.getByText(/the whole story/) + expect(briefing).toHaveClass("hidden") + fireEvent.click(screen.getByText("Show briefing")) + expect(briefing).not.toHaveClass("hidden") + expect(screen.getByText("Hide briefing")).toBeInTheDocument() + fireEvent.click(screen.getByText("Hide briefing")) + expect(briefing).toHaveClass("hidden") + }) + + it("says when the briefing had to be shortened", () => { + renderCard({ + "codeg.handoff": { + version: 1, + from: "codex", + to: "claude_code", + path: "summary", + carried: false, + truncated: true, + }, + }) + expect(screen.getByText("· Briefing shortened to fit")).toBeInTheDocument() + }) + + it("renders nothing for a non-handoff meta", () => { + const { container } = renderCard({ contextCompaction: true }) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/message/agent-handoff-card.tsx b/src/components/message/agent-handoff-card.tsx new file mode 100644 index 0000000000..abea2c9a8c --- /dev/null +++ b/src/components/message/agent-handoff-card.tsx @@ -0,0 +1,99 @@ +"use client" + +/** + * The divider between two agents in one conversation. + * + * A handoff (`acp::handoff`) leaves a tool call tagged `_meta["codeg.handoff"]` + * at the seam between the segment the previous agent ran and the session the + * new one continues in. Rendered like the context-compaction divider: a + * centered, chrome-less rule with a label, so it reads as a boundary marker + * ("a different agent took over here"), not as a tool call. + * + * Two facts ride on it, both worth a glance rather than a click: which agents + * were involved, and whether the context moved natively or as a briefing. The + * briefing itself (summary path) folds away behind a toggle: it is the first + * prompt the new agent read, useful to audit, too long to keep open. + */ + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { ArrowRightLeft, ChevronDown, ChevronRight } from "lucide-react" + +import { AgentIcon } from "@/components/agent-icon" +import { agentHandoffPayload } from "@/lib/agent-handoff" +import { getAgentLabel } from "@/lib/custom-agents" +import { cn } from "@/lib/utils" + +interface Props { + /** The tool call's `_meta`; anything but a handoff marker renders nothing. */ + meta?: Record<string, unknown> | null +} + +export function AgentHandoffCard({ meta }: Props) { + const t = useTranslations("Folder.chat.agentHandoff") + const [briefingOpen, setBriefingOpen] = useState(false) + const payload = agentHandoffPayload(meta) + if (!payload) return null + + const from = payload.from ? getAgentLabel(payload.from) : "?" + const to = payload.to ? getAgentLabel(payload.to) : "?" + const detail = + payload.path === "native" + ? t("cardNative") + : payload.truncated + ? t("cardTruncated") + : t("cardSummary") + + return ( + <div + data-slot="agent-handoff-card" + className="flex flex-col items-center gap-1 py-1 text-xs text-muted-foreground/80 select-none" + > + <div className="flex w-full items-center gap-3"> + <div className="h-px flex-1 bg-gradient-to-r from-transparent to-border/70" /> + <div className="flex shrink-0 items-center gap-1.5" title={detail}> + {payload.from ? ( + <AgentIcon agentType={payload.from} className="size-3.5" /> + ) : null} + <ArrowRightLeft className="size-3.5" aria-hidden /> + {payload.to ? ( + <AgentIcon agentType={payload.to} className="size-3.5" /> + ) : null} + <span>{t("cardTitle", { from, to })}</span> + <span className="text-muted-foreground/60">· {detail}</span> + </div> + <div className="h-px flex-1 bg-gradient-to-l from-transparent to-border/70" /> + </div> + {payload.note ? ( + <div className="max-w-prose text-center text-muted-foreground/70"> + {t("cardNote", { note: payload.note })} + </div> + ) : null} + {payload.briefing ? ( + <div className="flex w-full max-w-prose flex-col items-center"> + <button + type="button" + onClick={() => setBriefingOpen((open) => !open)} + aria-expanded={briefingOpen} + className="inline-flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground" + > + {briefingOpen ? ( + <ChevronDown className="size-3" aria-hidden /> + ) : ( + <ChevronRight className="size-3" aria-hidden /> + )} + {briefingOpen ? t("hideBriefing") : t("showBriefing")} + </button> + <pre + className={cn( + "mt-1 w-full overflow-x-auto rounded-md border border-border/50 bg-muted/30 p-2 text-left font-mono text-[11px] whitespace-pre-wrap text-muted-foreground select-text", + !briefingOpen && "hidden" + )} + > + {payload.briefing} + </pre> + </div> + ) : null} + </div> + ) +} diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 1383cbfdfc..c4130b093d 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -56,6 +56,8 @@ import { ContextCompactionCard, isContextCompactionMeta, } from "./context-compaction-card" +import { AgentHandoffCard } from "./agent-handoff-card" +import { isAgentHandoffMeta } from "@/lib/agent-handoff" import { FeedbackCheckResultCard } from "./feedback-check-result-card" import { SearchResultsOutput } from "./search-results-output" import { @@ -2644,6 +2646,11 @@ const ToolCallPart = memo(function ToolCallPart({ if (isContextCompactionMeta(part.meta)) { return <ContextCompactionCard state={part.state} meta={part.meta} /> } + // The other between-turns marker: a conversation handed to a different + // agent (`_meta["codeg.handoff"]`, see `acp::handoff`). + if (isAgentHandoffMeta(part.meta)) { + return <AgentHandoffCard meta={part.meta} /> + } // Agent/subagent tools get a dedicated container rendering if (toolNameLower === "agent") { diff --git a/src/components/message/message-list-view.test.tsx b/src/components/message/message-list-view.test.tsx index 36afd24f69..f468ba733d 100644 --- a/src/components/message/message-list-view.test.tsx +++ b/src/components/message/message-list-view.test.tsx @@ -4,6 +4,7 @@ import { advanceReplyFold, dedupeCompactionItems, extractDelegationSources, + handoffOnlyMeta, isForkPointUnnamed, markThreadTail, mergeConsecutiveAssistantTurns, @@ -311,6 +312,94 @@ describe("mergeConsecutiveAssistantTurns", () => { ]) expect(merged.map((it) => it.kind)).toEqual(["turn", "compaction", "turn"]) }) + + it("does not fold an agent-handoff divider into the surrounding replies", () => { + // The previous agent's last reply, the seam, the new agent's first reply: + // two assistant turns that would otherwise merge into one bubble, with + // the handoff read as part of the old agent's answer. + const handoff: ThreadItem = { + key: "persisted-handoff-0", + kind: "handoff", + meta: { + "codeg.handoff": { version: 1, from: "claude_code", to: "codex" }, + }, + } + const merged = mergeConsecutiveAssistantTurns([ + assistantItem("a"), + handoff, + assistantItem("b"), + ]) + expect(merged.map((it) => it.kind)).toEqual(["turn", "handoff", "turn"]) + }) +}) + +describe("handoffOnlyMeta", () => { + const handoffMeta = { + "codeg.handoff": { version: 1, from: "claude_code", to: "codex" }, + } + + it("hoists a group that is nothing but the handoff tool call", () => { + const group: ResolvedMessageGroup = { + id: "handoff-0", + role: "assistant", + parts: [ + { type: "text", text: " " }, + { + type: "tool-call", + toolCallId: "handoff-0", + toolName: "agent_handoff", + state: "output-available", + input: null, + output: null, + meta: handoffMeta, + }, + ], + resources: [], + images: [], + } + expect(handoffOnlyMeta(group)).toBe(handoffMeta) + }) + + it("leaves real replies, user turns and compaction dividers alone", () => { + const withText: ResolvedMessageGroup = { + id: "a", + role: "assistant", + parts: [ + { type: "text", text: "done" }, + { + type: "tool-call", + toolCallId: "handoff-0", + toolName: "agent_handoff", + state: "output-available", + input: null, + output: null, + meta: handoffMeta, + }, + ], + resources: [], + images: [], + } + expect(handoffOnlyMeta(withText)).toBeNull() + expect(handoffOnlyMeta({ ...withText, role: "user" })).toBeNull() + const compaction: ResolvedMessageGroup = { + id: "c", + role: "assistant", + parts: [ + { + type: "tool-call", + toolCallId: "cb1", + toolName: "context_compaction", + state: "output-available", + input: null, + output: null, + meta: { contextCompaction: { version: 1 } }, + }, + ], + resources: [], + images: [], + } + expect(handoffOnlyMeta(compaction)).toBeNull() + }) }) function makeGroup( diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 7b8825ab5b..45fab2b570 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -16,6 +16,8 @@ import { contextCompactionPayload, isContextCompactionMeta, } from "@/lib/context-compaction" +import { AgentHandoffCard } from "./agent-handoff-card" +import { isAgentHandoffMeta } from "@/lib/agent-handoff" import { createMessageTurnAdapter, groupGoalRuns, @@ -205,6 +207,15 @@ export type ThreadRenderItem = kind: "compaction" meta: Record<string, unknown> | null } + | { + // The seam where a conversation was handed to a different agent + // (`_meta["codeg.handoff"]`, see `acp::handoff`). Same treatment as the + // compaction divider: hoisted out of the assistant run so it renders + // BETWEEN the previous agent's last reply and the new agent's first. + key: string + kind: "handoff" + meta: Record<string, unknown> | null + } /** * Fold state for a thread's assistant replies, owned by the view rather than by @@ -587,6 +598,27 @@ export function dedupeCompactionItems( return dropped ? kept : items } +/** + * `compactionOnlyMeta`'s twin for the agent-handoff divider: the `_meta` of a + * group whose only meaningful content is one tool-call tagged + * `codeg.handoff`, else `null`. Exported for the timeline tests. + */ +export function handoffOnlyMeta( + group: ResolvedMessageGroup +): Record<string, unknown> | null { + if (group.role !== "assistant") return null + if (group.resources.length > 0 || group.images.length > 0) return null + const meaningful = group.parts.filter( + (p) => !(p.type === "text" && p.text.trim().length === 0) + ) + if (meaningful.length !== 1) return null + const only = meaningful[0] + if (only.type !== "tool-call" || !isAgentHandoffMeta(only.meta)) { + return null + } + return only.meta ?? null +} + /** * Collapse runs of consecutive assistant turn render items into a single * synthetic turn so tool-groups straddling a turn boundary fold into one @@ -1163,6 +1195,10 @@ export function MessageListView({ if (compactionMeta !== null) { return { key, kind: "compaction" as const, meta: compactionMeta } } + const handoffMeta = handoffOnlyMeta(group) + if (handoffMeta !== null) { + return { key, kind: "handoff" as const, meta: handoffMeta } + } return { key, kind: "turn" as const, @@ -1352,6 +1388,12 @@ export function MessageListView({ <ContextCompactionCard meta={item.meta} /> </div> ) + case "handoff": + return ( + <div className="px-1 py-2"> + <AgentHandoffCard meta={item.meta} /> + </div> + ) default: return null } diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 1d315c5074..69c74b8c4e 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -3690,6 +3690,37 @@ "triggerManual": "تم التشغيل يدويًا", "triggerAutomatic": "تم التشغيل تلقائيًا" }, + "agentHandoff": { + "menuLabel": "تسليم المحادثة إلى وكيل آخر", + "dialogTitle": "تسليم هذه المحادثة", + "dialogDescription": "يتابع وكيل آخر هذه المحادثة. كل ما قيل حتى الآن يبقى في هذه المحادثة.", + "targetLabel": "المتابعة مع", + "noteLabel": "على ماذا ينبغي أن يركّز الوكيل التالي؟ (اختياري)", + "notePlaceholder": "مثال: إنهاء الترحيل وتشغيل الاختبارات", + "planLoading": "جارٍ التحقق من طريقة نقل السياق…", + "planNative": "سيحمّل {target} الجلسة كاملة بشكل أصلي. لا يُلخَّص أي شيء.", + "planSummary": "يبدأ {target} جلسة جديدة انطلاقًا من ملخّص تمهيدي: تلخيص {turns} دورًا، وآخر {verbatim} دورًا بالكامل.", + "planSummaryTruncated": "يبدأ {target} جلسة جديدة انطلاقًا من ملخّص تمهيدي. المحادثة طويلة، لذا اختُصرت الأدوار الأقدم؛ ويبقى السجل الكامل ظاهرًا فوق الفاصل.", + "planNativeUnavailable": "لم يعد النص الأصلي موجودًا في مخزن {source}، لذا يُنقل السياق كملخّص تمهيدي بدلًا من ذلك.", + "blockedSameAgent": "هذه المحادثة تعمل بالفعل على {target}.", + "blockedNoSession": "لا توجد جلسة في هذه المحادثة يمكن تسليمها بعد.", + "blockedNotInstalled": "{target} غير مثبّت. ثبّته أولًا من إعدادات الوكلاء.", + "blockedDisabled": "{target} معطّل في إعدادات الوكلاء.", + "blockedGeneric": "تعذّر التسليم إلى {target}: {message}", + "confirm": "تسليم", + "working": "جارٍ التسليم…", + "cancel": "إلغاء", + "success": "تم التسليم إلى {target}", + "failed": "فشل التسليم: {message}", + "busy": "لا يزال هناك دور قيد التنفيذ. أوقفه أو انتظر اكتماله ثم سلّم المحادثة.", + "cardTitle": "تم التسليم من {from} إلى {to}", + "cardNative": "نُقل السياق كاملًا", + "cardSummary": "استُكملت انطلاقًا من ملخّص تمهيدي", + "cardTruncated": "اختُصر الملخّص التمهيدي", + "cardNote": "التركيز: {note}", + "showBriefing": "عرض الملخّص التمهيدي", + "hideBriefing": "إخفاء الملخّص التمهيدي" + }, "sessionFailure": { "category": { "connection": "مشكلة في الاتصال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 2a9049c21b..a0c5c84939 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -3690,6 +3690,37 @@ "triggerManual": "Manuell ausgelöst", "triggerAutomatic": "Automatisch ausgelöst" }, + "agentHandoff": { + "menuLabel": "An einen anderen Agenten übergeben", + "dialogTitle": "Diese Konversation übergeben", + "dialogDescription": "Ein anderer Agent führt diese Konversation fort. Alles Bisherige bleibt in dieser Konversation.", + "targetLabel": "Fortsetzen mit", + "noteLabel": "Worauf soll sich der nächste Agent konzentrieren? (optional)", + "notePlaceholder": "z. B. die Migration abschließen und die Tests ausführen", + "planLoading": "Prüfe, wie der Kontext übernommen werden kann…", + "planNative": "{target} lädt die vollständige Sitzung nativ. Nichts wird zusammengefasst.", + "planSummary": "{target} startet eine neue Sitzung mit einem Briefing: {turns} Beiträge zusammengefasst, die letzten {verbatim} vollständig.", + "planSummaryTruncated": "{target} startet eine neue Sitzung mit einem Briefing. Die Konversation ist lang, daher wurden frühere Beiträge gekürzt; der vollständige Verlauf bleibt über der Trennlinie sichtbar.", + "planNativeUnavailable": "Das ursprüngliche Transkript liegt nicht mehr im Speicher von {source}, daher wird der Kontext stattdessen als Briefing übergeben.", + "blockedSameAgent": "Diese Konversation läuft bereits auf {target}.", + "blockedNoSession": "Diese Konversation hat noch keine Sitzung, die übergeben werden könnte.", + "blockedNotInstalled": "{target} ist nicht installiert. Installiere ihn zuerst in den Agent-Einstellungen.", + "blockedDisabled": "{target} ist in den Agent-Einstellungen deaktiviert.", + "blockedGeneric": "Übergabe an {target} nicht möglich: {message}", + "confirm": "Übergeben", + "working": "Wird übergeben…", + "cancel": "Abbrechen", + "success": "An {target} übergeben", + "failed": "Übergabe fehlgeschlagen: {message}", + "busy": "Ein Beitrag läuft noch. Stoppe ihn oder warte, bis er fertig ist, und übergib dann.", + "cardTitle": "Von {from} an {to} übergeben", + "cardNative": "Vollständiger Kontext übernommen", + "cardSummary": "Mit einem Briefing fortgesetzt", + "cardTruncated": "Briefing gekürzt", + "cardNote": "Fokus: {note}", + "showBriefing": "Briefing anzeigen", + "hideBriefing": "Briefing ausblenden" + }, "sessionFailure": { "category": { "connection": "Verbindungsproblem", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3c5e9bcfe2..b4161c3715 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3690,6 +3690,37 @@ "triggerManual": "Manually triggered", "triggerAutomatic": "Automatically triggered" }, + "agentHandoff": { + "menuLabel": "Hand off to another agent", + "dialogTitle": "Hand off this conversation", + "dialogDescription": "Continue this conversation with a different agent. Everything said so far stays in this conversation.", + "targetLabel": "Continue with", + "noteLabel": "What should the next agent focus on? (optional)", + "notePlaceholder": "e.g. finish the migration and run the tests", + "planLoading": "Checking how the context can be carried over…", + "planNative": "{target} will load the full session natively. Nothing is summarized.", + "planSummary": "{target} starts a new session seeded with a briefing: {turns} turns summarized, the last {verbatim} in full.", + "planSummaryTruncated": "{target} starts a new session seeded with a briefing. The conversation is long, so earlier turns were shortened to fit; the full history stays visible above the divider.", + "planNativeUnavailable": "The original transcript is no longer in {source}'s store, so the context is carried as a briefing instead.", + "blockedSameAgent": "This conversation already runs on {target}.", + "blockedNoSession": "This conversation has no session to hand off yet.", + "blockedNotInstalled": "{target} is not installed. Install it in Agent Settings first.", + "blockedDisabled": "{target} is disabled in Agent Settings.", + "blockedGeneric": "Cannot hand off to {target}: {message}", + "confirm": "Hand off", + "working": "Handing off…", + "cancel": "Cancel", + "success": "Handed off to {target}", + "failed": "Handoff failed: {message}", + "busy": "A turn is still running. Stop it or wait for it to finish, then hand off.", + "cardTitle": "Handed off from {from} to {to}", + "cardNative": "Full context carried over", + "cardSummary": "Continued from a briefing", + "cardTruncated": "Briefing shortened to fit", + "cardNote": "Focus: {note}", + "showBriefing": "Show briefing", + "hideBriefing": "Hide briefing" + }, "sessionFailure": { "category": { "connection": "Connection issue", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 769e5c165f..9b815f1e0a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -3690,6 +3690,37 @@ "triggerManual": "Activada manualmente", "triggerAutomatic": "Activada automáticamente" }, + "agentHandoff": { + "menuLabel": "Pasar a otro agente", + "dialogTitle": "Pasar esta conversación", + "dialogDescription": "Otro agente continúa esta conversación. Todo lo dicho hasta ahora se mantiene en esta conversación.", + "targetLabel": "Continuar con", + "noteLabel": "¿En qué debe centrarse el siguiente agente? (opcional)", + "notePlaceholder": "p. ej. terminar la migración y ejecutar las pruebas", + "planLoading": "Comprobando cómo se puede trasladar el contexto…", + "planNative": "{target} cargará la sesión completa de forma nativa. No se resume nada.", + "planSummary": "{target} inicia una sesión nueva a partir de un informe: {turns} turnos resumidos y los últimos {verbatim} íntegros.", + "planSummaryTruncated": "{target} inicia una sesión nueva a partir de un informe. La conversación es larga, así que los turnos anteriores se acortaron; el historial completo sigue visible sobre el separador.", + "planNativeUnavailable": "La transcripción original ya no está en el almacenamiento de {source}, así que el contexto se traslada como informe.", + "blockedSameAgent": "Esta conversación ya se ejecuta en {target}.", + "blockedNoSession": "Esta conversación aún no tiene una sesión que pasar.", + "blockedNotInstalled": "{target} no está instalado. Instálalo primero en los ajustes de agentes.", + "blockedDisabled": "{target} está deshabilitado en los ajustes de agentes.", + "blockedGeneric": "No se puede pasar a {target}: {message}", + "confirm": "Pasar", + "working": "Pasando…", + "cancel": "Cancelar", + "success": "Conversación pasada a {target}", + "failed": "Error al pasar la conversación: {message}", + "busy": "Todavía hay un turno en curso. Detenlo o espera a que termine y luego pásala.", + "cardTitle": "Pasada de {from} a {to}", + "cardNative": "Contexto completo trasladado", + "cardSummary": "Continuada a partir de un informe", + "cardTruncated": "Informe acortado", + "cardNote": "Enfoque: {note}", + "showBriefing": "Mostrar informe", + "hideBriefing": "Ocultar informe" + }, "sessionFailure": { "category": { "connection": "Problema de conexión", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1555a7eadf..5b8226d5da 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -3690,6 +3690,37 @@ "triggerManual": "Déclenchée manuellement", "triggerAutomatic": "Déclenchée automatiquement" }, + "agentHandoff": { + "menuLabel": "Passer le relais à un autre agent", + "dialogTitle": "Passer le relais pour cette conversation", + "dialogDescription": "Un autre agent poursuit cette conversation. Tout ce qui a été dit reste dans cette conversation.", + "targetLabel": "Continuer avec", + "noteLabel": "Sur quoi le prochain agent doit-il se concentrer ? (facultatif)", + "notePlaceholder": "ex. terminer la migration et lancer les tests", + "planLoading": "Vérification de la façon dont le contexte peut être transmis…", + "planNative": "{target} chargera la session complète en natif. Rien n'est résumé.", + "planSummary": "{target} démarre une nouvelle session amorcée par un briefing : {turns} tours résumés, les {verbatim} derniers en intégralité.", + "planSummaryTruncated": "{target} démarre une nouvelle session amorcée par un briefing. La conversation est longue, les tours les plus anciens ont donc été raccourcis ; l'historique complet reste visible au-dessus du séparateur.", + "planNativeUnavailable": "La transcription d'origine n'est plus dans le stockage de {source} ; le contexte est donc transmis sous forme de briefing.", + "blockedSameAgent": "Cette conversation tourne déjà sur {target}.", + "blockedNoSession": "Cette conversation n'a pas encore de session à transmettre.", + "blockedNotInstalled": "{target} n'est pas installé. Installez-le d'abord dans les paramètres des agents.", + "blockedDisabled": "{target} est désactivé dans les paramètres des agents.", + "blockedGeneric": "Impossible de passer le relais à {target} : {message}", + "confirm": "Passer le relais", + "working": "Passage de relais…", + "cancel": "Annuler", + "success": "Relais passé à {target}", + "failed": "Échec du passage de relais : {message}", + "busy": "Un tour est encore en cours. Arrêtez-le ou attendez sa fin, puis passez le relais.", + "cardTitle": "Relais passé de {from} à {to}", + "cardNative": "Contexte intégralement transmis", + "cardSummary": "Poursuite à partir d'un briefing", + "cardTruncated": "Briefing raccourci", + "cardNote": "Priorité : {note}", + "showBriefing": "Afficher le briefing", + "hideBriefing": "Masquer le briefing" + }, "sessionFailure": { "category": { "connection": "Problème de connexion", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 3ec4c8e361..fa64cafe7a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -3690,6 +3690,37 @@ "triggerManual": "手動で実行", "triggerAutomatic": "自動で実行" }, + "agentHandoff": { + "menuLabel": "別のエージェントに引き継ぐ", + "dialogTitle": "この会話を引き継ぐ", + "dialogDescription": "この会話を別のエージェントで続けます。これまでの内容はすべてこの会話に残ります。", + "targetLabel": "引き継ぎ先", + "noteLabel": "次のエージェントに重点的に取り組んでほしいこと(任意)", + "notePlaceholder": "例:移行を完了してテストを実行する", + "planLoading": "コンテキストの引き継ぎ方法を確認しています…", + "planNative": "{target} がセッション全体をそのまま読み込みます。要約はしません。", + "planSummary": "{target} はブリーフィングを起点に新しいセッションを開始します:{turns} ターンを要約し、最後の {verbatim} ターンは全文を渡します。", + "planSummaryTruncated": "{target} はブリーフィングを起点に新しいセッションを開始します。会話が長いため、以前のターンは短縮されています。履歴全体は区切り線の上にそのまま表示されます。", + "planNativeUnavailable": "元の記録が {source} のストアに見つからないため、代わりにブリーフィングとしてコンテキストを引き継ぎます。", + "blockedSameAgent": "この会話はすでに {target} で実行されています。", + "blockedNoSession": "この会話には引き継げるセッションがまだありません。", + "blockedNotInstalled": "{target} がインストールされていません。先にエージェント設定でインストールしてください。", + "blockedDisabled": "{target} はエージェント設定で無効になっています。", + "blockedGeneric": "{target} に引き継げません: {message}", + "confirm": "引き継ぐ", + "working": "引き継いでいます…", + "cancel": "キャンセル", + "success": "{target} に引き継ぎました", + "failed": "引き継ぎに失敗しました: {message}", + "busy": "ターンがまだ実行中です。停止するか完了を待ってから引き継いでください。", + "cardTitle": "{from} から {to} に引き継ぎました", + "cardNative": "コンテキスト全体を引き継ぎ", + "cardSummary": "ブリーフィングから続行", + "cardTruncated": "ブリーフィングは短縮されています", + "cardNote": "重点: {note}", + "showBriefing": "ブリーフィングを表示", + "hideBriefing": "ブリーフィングを隠す" + }, "sessionFailure": { "category": { "connection": "接続の問題", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 6f8745b17b..dabd4d2417 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -3690,6 +3690,37 @@ "triggerManual": "수동 실행", "triggerAutomatic": "자동 실행" }, + "agentHandoff": { + "menuLabel": "다른 에이전트에게 넘기기", + "dialogTitle": "이 대화 넘기기", + "dialogDescription": "다른 에이전트가 이 대화를 이어갑니다. 지금까지의 내용은 모두 이 대화에 남습니다.", + "targetLabel": "이어갈 에이전트", + "noteLabel": "다음 에이전트가 집중해야 할 내용 (선택)", + "notePlaceholder": "예: 마이그레이션을 끝내고 테스트 실행", + "planLoading": "컨텍스트를 넘기는 방식을 확인하는 중…", + "planNative": "{target}이(가) 세션 전체를 그대로 불러옵니다. 요약하지 않습니다.", + "planSummary": "{target}이(가) 브리핑으로 시작하는 새 세션을 엽니다: {turns}개 턴 요약, 마지막 {verbatim}개 턴은 전문 전달.", + "planSummaryTruncated": "{target}이(가) 브리핑으로 시작하는 새 세션을 엽니다. 대화가 길어 이전 턴은 줄여서 담았습니다. 전체 기록은 구분선 위에 그대로 표시됩니다.", + "planNativeUnavailable": "원본 기록이 {source} 저장소에 더 이상 없어 컨텍스트를 브리핑으로 대신 넘깁니다.", + "blockedSameAgent": "이 대화는 이미 {target}에서 실행 중입니다.", + "blockedNoSession": "이 대화에는 아직 넘길 세션이 없습니다.", + "blockedNotInstalled": "{target}이(가) 설치되어 있지 않습니다. 먼저 에이전트 설정에서 설치하세요.", + "blockedDisabled": "{target}이(가) 에이전트 설정에서 비활성화되어 있습니다.", + "blockedGeneric": "{target}에게 넘길 수 없습니다: {message}", + "confirm": "넘기기", + "working": "넘기는 중…", + "cancel": "취소", + "success": "{target}에게 넘겼습니다", + "failed": "넘기기 실패: {message}", + "busy": "턴이 아직 실행 중입니다. 중지하거나 완료될 때까지 기다린 뒤 넘기세요.", + "cardTitle": "{from}에서 {to}(으)로 넘김", + "cardNative": "전체 컨텍스트 그대로 전달", + "cardSummary": "브리핑에서 이어짐", + "cardTruncated": "브리핑이 줄여졌습니다", + "cardNote": "집중 사항: {note}", + "showBriefing": "브리핑 보기", + "hideBriefing": "브리핑 숨기기" + }, "sessionFailure": { "category": { "connection": "연결 문제", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 1872ac35f8..448f31926c 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -3690,6 +3690,37 @@ "triggerManual": "Acionada manualmente", "triggerAutomatic": "Acionada automaticamente" }, + "agentHandoff": { + "menuLabel": "Passar para outro agente", + "dialogTitle": "Passar esta conversa", + "dialogDescription": "Outro agente continua esta conversa. Tudo o que foi dito até agora permanece nesta conversa.", + "targetLabel": "Continuar com", + "noteLabel": "No que o próximo agente deve se concentrar? (opcional)", + "notePlaceholder": "ex.: concluir a migração e rodar os testes", + "planLoading": "Verificando como o contexto pode ser transferido…", + "planNative": "{target} vai carregar a sessão completa nativamente. Nada é resumido.", + "planSummary": "{target} inicia uma nova sessão a partir de um briefing: {turns} turnos resumidos e os últimos {verbatim} na íntegra.", + "planSummaryTruncated": "{target} inicia uma nova sessão a partir de um briefing. A conversa é longa, então os turnos anteriores foram encurtados; o histórico completo continua visível acima do divisor.", + "planNativeUnavailable": "A transcrição original não está mais no armazenamento de {source}, então o contexto é transferido como briefing.", + "blockedSameAgent": "Esta conversa já está em {target}.", + "blockedNoSession": "Esta conversa ainda não tem uma sessão para passar.", + "blockedNotInstalled": "{target} não está instalado. Instale-o primeiro nas configurações de agentes.", + "blockedDisabled": "{target} está desabilitado nas configurações de agentes.", + "blockedGeneric": "Não foi possível passar para {target}: {message}", + "confirm": "Passar", + "working": "Passando…", + "cancel": "Cancelar", + "success": "Conversa passada para {target}", + "failed": "Falha ao passar a conversa: {message}", + "busy": "Ainda há um turno em andamento. Pare-o ou aguarde terminar e então passe a conversa.", + "cardTitle": "Passada de {from} para {to}", + "cardNative": "Contexto completo transferido", + "cardSummary": "Continuada a partir de um briefing", + "cardTruncated": "Briefing encurtado", + "cardNote": "Foco: {note}", + "showBriefing": "Mostrar briefing", + "hideBriefing": "Ocultar briefing" + }, "sessionFailure": { "category": { "connection": "Problema de conexão", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0e17ed7a77..0dcbbeb5b1 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -3690,6 +3690,37 @@ "triggerManual": "手动触发", "triggerAutomatic": "自动触发" }, + "agentHandoff": { + "menuLabel": "交接给其他智能体", + "dialogTitle": "交接此会话", + "dialogDescription": "让另一个智能体继续这个会话,之前的所有内容都保留在本会话中。", + "targetLabel": "由谁继续", + "noteLabel": "下一个智能体应关注什么?(可选)", + "notePlaceholder": "例如:完成迁移并运行测试", + "planLoading": "正在检查上下文的传递方式…", + "planNative": "{target} 将原生加载完整会话,不做任何摘要。", + "planSummary": "{target} 将开启新会话并以简报开场:摘要 {turns} 轮,最后 {verbatim} 轮完整保留。", + "planSummaryTruncated": "{target} 将开启新会话并以简报开场。会话较长,较早的轮次已被精简以适应长度;完整历史仍显示在分隔线上方。", + "planNativeUnavailable": "{source} 的存储中已找不到原始记录,因此改为以简报方式传递上下文。", + "blockedSameAgent": "此会话已经在 {target} 上运行。", + "blockedNoSession": "此会话尚无可交接的会话记录。", + "blockedNotInstalled": "{target} 尚未安装,请先在智能体设置中安装。", + "blockedDisabled": "{target} 已在智能体设置中禁用。", + "blockedGeneric": "无法交接给 {target}:{message}", + "confirm": "交接", + "working": "交接中…", + "cancel": "取消", + "success": "已交接给 {target}", + "failed": "交接失败:{message}", + "busy": "仍有回合正在进行。请先停止或等待其完成,再进行交接。", + "cardTitle": "已从 {from} 交接给 {to}", + "cardNative": "完整上下文已原样带入", + "cardSummary": "基于简报继续", + "cardTruncated": "简报已精简以适应长度", + "cardNote": "重点:{note}", + "showBriefing": "显示简报", + "hideBriefing": "隐藏简报" + }, "sessionFailure": { "category": { "connection": "连接异常", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 89ec62bf0b..4a8d709281 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -3690,6 +3690,37 @@ "triggerManual": "手動觸發", "triggerAutomatic": "自動觸發" }, + "agentHandoff": { + "menuLabel": "交接給其他智能體", + "dialogTitle": "交接此對話", + "dialogDescription": "讓另一個智能體繼續這段對話,先前的所有內容都會保留在本對話中。", + "targetLabel": "由誰繼續", + "noteLabel": "下一個智能體應著重什麼?(選填)", + "notePlaceholder": "例如:完成遷移並執行測試", + "planLoading": "正在檢查上下文的傳遞方式…", + "planNative": "{target} 將原生載入完整對話,不做任何摘要。", + "planSummary": "{target} 將開啟新對話並以簡報開場:摘要 {turns} 輪,最後 {verbatim} 輪完整保留。", + "planSummaryTruncated": "{target} 將開啟新對話並以簡報開場。對話較長,較早的輪次已被精簡以符合長度;完整歷史仍顯示在分隔線上方。", + "planNativeUnavailable": "{source} 的儲存中已找不到原始記錄,因此改以簡報方式傳遞上下文。", + "blockedSameAgent": "此對話已經在 {target} 上執行。", + "blockedNoSession": "此對話尚無可交接的工作階段。", + "blockedNotInstalled": "{target} 尚未安裝,請先在智能體設定中安裝。", + "blockedDisabled": "{target} 已在智能體設定中停用。", + "blockedGeneric": "無法交接給 {target}:{message}", + "confirm": "交接", + "working": "交接中…", + "cancel": "取消", + "success": "已交接給 {target}", + "failed": "交接失敗:{message}", + "busy": "仍有回合正在進行。請先停止或等待其完成,再進行交接。", + "cardTitle": "已從 {from} 交接給 {to}", + "cardNative": "完整上下文已原樣帶入", + "cardSummary": "基於簡報繼續", + "cardTruncated": "簡報已精簡以符合長度", + "cardNote": "重點:{note}", + "showBriefing": "顯示簡報", + "hideBriefing": "隱藏簡報" + }, "sessionFailure": { "category": { "connection": "連線異常", diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 799217f3b4..f6fd11ea6e 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -17,6 +17,7 @@ import { normalizeToolName } from "@/lib/tool-call-normalization" import { isCodexGrepNoMatchEnvelope } from "@/lib/codex-command-action" import { isBackgroundTaskToolCall } from "@/lib/background-task" import { isContextCompactionMeta } from "@/lib/context-compaction" +import { isAgentHandoffMeta } from "@/lib/agent-handoff" import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import { feedbackCheckHasContent } from "@/lib/feedback-check" import { @@ -1403,7 +1404,10 @@ export function groupConsecutiveToolCalls( // synthesized auto_compact card) render through the dedicated subtle // <ContextCompactionCard>, so they break the run and render standalone // instead of being wrapped in a single-item "调用 1 个工具" tool-group. - !isContextCompactionMeta(part.meta) + !isContextCompactionMeta(part.meta) && + // Agent-handoff dividers (`_meta["codeg.handoff"]`) are the other + // between-turns boundary marker and render through <AgentHandoffCard>. + !isAgentHandoffMeta(part.meta) ) { buffer.push(part) continue diff --git a/src/lib/agent-handoff.test.ts b/src/lib/agent-handoff.test.ts new file mode 100644 index 0000000000..5003bcf9a5 --- /dev/null +++ b/src/lib/agent-handoff.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest" + +import { + HANDOFF_BRIEFING_MARKER, + agentHandoffPayload, + isAgentHandoffMeta, + isHandoffBriefingText, +} from "./agent-handoff" + +describe("isAgentHandoffMeta", () => { + it("accepts the versioned marker the backend stamps", () => { + expect(isAgentHandoffMeta({ "codeg.handoff": { version: 1 } })).toBe(true) + expect( + isAgentHandoffMeta({ + "codeg.handoff": { version: 2, from: "claude_code", to: "codex" }, + }) + ).toBe(true) + }) + + it("rejects everything that is not a handoff marker", () => { + expect(isAgentHandoffMeta(null)).toBe(false) + expect(isAgentHandoffMeta(undefined)).toBe(false) + expect(isAgentHandoffMeta("codeg.handoff")).toBe(false) + expect(isAgentHandoffMeta({})).toBe(false) + expect(isAgentHandoffMeta({ "codeg.handoff": true })).toBe(false) + expect(isAgentHandoffMeta({ "codeg.handoff": {} })).toBe(false) + expect(isAgentHandoffMeta({ "codeg.handoff": { version: 0 } })).toBe(false) + expect(isAgentHandoffMeta({ "codeg.handoff": { version: "1" } })).toBe( + false + ) + // The compaction marker is a different divider and must not be claimed. + expect(isAgentHandoffMeta({ contextCompaction: { version: 1 } })).toBe( + false + ) + }) +}) + +describe("agentHandoffPayload", () => { + it("reads every field and defaults the optional ones", () => { + const payload = agentHandoffPayload({ + "codeg.handoff": { + version: 1, + from: "claude_code", + to: "custom:claude-code-2", + path: "native", + carried: true, + truncated: false, + at: "2026-09-06T10:00:00Z", + note: "finish the tests", + }, + }) + expect(payload).toEqual({ + version: 1, + from: "claude_code", + to: "custom:claude-code-2", + path: "native", + carried: true, + truncated: false, + at: "2026-09-06T10:00:00Z", + note: "finish the tests", + briefing: null, + }) + }) + + it("degrades an unknown path to summary and blanks to null", () => { + const payload = agentHandoffPayload({ + "codeg.handoff": { version: 1, path: "teleport", note: "", at: 5 }, + }) + expect(payload?.path).toBe("summary") + expect(payload?.carried).toBe(false) + expect(payload?.note).toBeNull() + expect(payload?.at).toBeNull() + expect(payload?.from).toBe("") + }) + + it("is null for non-handoff meta", () => { + expect(agentHandoffPayload({ contextCompaction: true })).toBeNull() + expect(agentHandoffPayload(null)).toBeNull() + }) +}) + +describe("isHandoffBriefingText", () => { + it("matches the seeded briefing, with or without leading whitespace", () => { + expect(isHandoffBriefingText(`${HANDOFF_BRIEFING_MARKER}\nhello`)).toBe( + true + ) + expect(isHandoffBriefingText(` \n${HANDOFF_BRIEFING_MARKER}`)).toBe(true) + }) + + it("leaves ordinary prompts alone, even ones mentioning the marker", () => { + expect(isHandoffBriefingText("hello")).toBe(false) + expect( + isHandoffBriefingText(`what is ${HANDOFF_BRIEFING_MARKER} for?`) + ).toBe(false) + expect(isHandoffBriefingText("")).toBe(false) + }) +}) diff --git a/src/lib/agent-handoff.ts b/src/lib/agent-handoff.ts new file mode 100644 index 0000000000..7992f891bc --- /dev/null +++ b/src/lib/agent-handoff.ts @@ -0,0 +1,84 @@ +/** + * Mid-conversation agent handoff: the frontend half of `acp::handoff`. + * + * When a conversation is handed to another agent, the backend splices the + * earlier segment in front of the new session and marks the seam with a tool + * call tagged `_meta["codeg.handoff"]` (same shape as the context-compaction + * divider). Recognition is by `_meta` key, never by agent or tool name, so the + * card renders for history reads and live snapshots alike. + * + * Kept dependency-free, like `context-compaction.ts`, so the grouping pass in + * the adapter and the card can share it without an import cycle. + */ + +export const HANDOFF_META_KEY = "codeg.handoff" + +/** + * First line of the prompt that seeds a summary handoff. The backend folds + * that turn into the divider on every detail read; this marker lets the live + * path (a snapshot or `user_message` echo captured before the refetch) skip + * the same bubble instead of flashing a screen of briefing as a user message. + */ +export const HANDOFF_BRIEFING_MARKER = "<!-- codeg:handoff-briefing -->" + +export type AgentHandoffPath = "native" | "summary" + +export interface AgentHandoffPayload { + version: number + from: string + to: string + path: AgentHandoffPath + carried: boolean + truncated: boolean + at: string | null + note: string | null + briefing: string | null +} + +export function isAgentHandoffMeta(meta: unknown): boolean { + if (!meta || typeof meta !== "object") return false + const marker = (meta as Record<string, unknown>)[HANDOFF_META_KEY] + if (!marker || typeof marker !== "object") return false + const version = (marker as Record<string, unknown>).version + return ( + typeof version === "number" && Number.isInteger(version) && version >= 1 + ) +} + +function readString( + source: Record<string, unknown>, + key: string +): string | null { + const value = source[key] + return typeof value === "string" && value.length > 0 ? value : null +} + +/** + * The typed payload, or `null` for anything that is not a handoff marker. + * Fields are read leniently: an older row may lack `note`/`briefing`, and an + * unknown `path` value degrades to `summary` (the honest default: nothing was + * carried natively unless the backend said so). + */ +export function agentHandoffPayload(meta: unknown): AgentHandoffPayload | null { + if (!isAgentHandoffMeta(meta)) return null + const marker = (meta as Record<string, unknown>)[HANDOFF_META_KEY] as Record< + string, + unknown + > + return { + version: marker.version as number, + from: readString(marker, "from") ?? "", + to: readString(marker, "to") ?? "", + path: marker.path === "native" ? "native" : "summary", + carried: marker.carried === true, + truncated: marker.truncated === true, + at: readString(marker, "at"), + note: readString(marker, "note"), + briefing: readString(marker, "briefing"), + } +} + +/** True for text the backend seeded as a handoff briefing. */ +export function isHandoffBriefingText(text: string): boolean { + return text.trimStart().startsWith(HANDOFF_BRIEFING_MARKER) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index efadb766b8..b1fbe22c51 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -375,6 +375,77 @@ export async function acpFork( } } +export type HandoffPath = "native" | "summary" + +/** What the handoff dialog shows before the user confirms (`acp_handoff_plan`). */ +export interface HandoffPlan { + sourceAgentType: AgentType + targetAgentType: AgentType + path: HandoffPath + /** The same-family path was demoted to a summary: `"transcript_missing"`. */ + nativeReason?: string + /** Stable code when the handoff cannot run: `same_agent`, `no_session`, + * `not_installed`, `disabled`. */ + blocked?: string + blockedMessage?: string + turnCount: number + briefingChars: number + briefingTruncated: boolean + verbatimTurns: number +} + +export interface HandoffResult { + conversationId: number + folderId: number + fromAgentType: AgentType + toAgentType: AgentType + /** The session the conversation is bound to now. */ + externalId: string + path: HandoffPath + connectionId: string + briefingTruncated: boolean +} + +export async function acpHandoffPlan( + conversationId: number, + targetAgentType: AgentType +): Promise<HandoffPlan> { + return getTransport().call("acp_handoff_plan", { + conversationId, + targetAgentType, + }) +} + +/** + * Hand a conversation to another agent in place. The backend stops the current + * agent, moves the transcript (same family) or seeds a briefing (otherwise), + * spawns the target, verifies it took the session, and only then moves the + * conversation row; a failure leaves the row where it was. A turn still in + * flight is rejected the same way a fork is (`TurnBusyError`). + */ +export async function acpHandoff( + conversationId: number, + targetAgentType: AgentType, + note: string | null, + // The TARGET agent's own saved selector preferences, so the handoff never + // carries the source agent's model over. + preferredModeId?: string | null, + preferredConfigValues?: Record<string, string> | null +): Promise<HandoffResult> { + try { + return await getTransport().call("acp_handoff", { + conversationId, + targetAgentType, + note: note ?? null, + preferredModeId: preferredModeId ?? null, + preferredConfigValues: preferredConfigValues ?? null, + }) + } catch (e) { + if (isTurnInProgressRejection(e)) throw new TurnBusyError() + throw e + } +} + /** * Stop one AIR async task (`_session/async_task/stop`). *