From b44e3cd40822c129bf965e5e4f683cb5d00dad37 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:07:56 -0700 Subject: [PATCH 1/2] feat(handoff): move a conversation to another agent in place A conversation can now change agents without starting over. Two paths, decided per pair of agents: Native (Claude to Claude, including an isolated custom Claude slot): the session transcript is copied into the target's config dir and the target session/loads it under the same id, so nothing is summarized. The load is verified before the row moves; a target that opens a fresh session instead is disconnected and the copy removed. Summary (any other pair): the target starts a new session seeded with a briefing built from the conversation (a digest of earlier turns, the last turns in full, the user's note), bounded and reported as truncated when earlier turns had to be dropped. Either way the conversation row keeps its id: rebind_for_handoff moves it to the new agent and session, and a conversation_handoff record keeps the earlier segment reachable. The detail read splices each earlier segment in front of the current session behind a divider tagged _meta["codeg.handoff"], placed by user-turn count for a carried session. --- src-tauri/src/acp/handoff.rs | 1275 +++++++++++++++++ src-tauri/src/acp/mod.rs | 1 + src-tauri/src/commands/conversations.rs | 41 +- src-tauri/src/commands/handoff.rs | 628 ++++++++ src-tauri/src/commands/mod.rs | 1 + .../src/db/entities/conversation_handoff.rs | 51 + src-tauri/src/db/entities/mod.rs | 1 + src-tauri/src/db/entities/prelude.rs | 1 + .../m20260906_000001_conversation_handoff.rs | 212 +++ src-tauri/src/db/migration/mod.rs | 2 + .../src/db/service/conversation_service.rs | 219 +++ src-tauri/src/db/service/handoff_service.rs | 156 ++ src-tauri/src/db/service/mod.rs | 1 + src-tauri/src/lib.rs | 4 +- src-tauri/src/parsers/claude.rs | 2 +- src-tauri/src/web/handlers/handoff.rs | 70 + src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/router.rs | 5 + 18 files changed, 2668 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/acp/handoff.rs create mode 100644 src-tauri/src/commands/handoff.rs create mode 100644 src-tauri/src/db/entities/conversation_handoff.rs create mode 100644 src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs create mode 100644 src-tauri/src/db/service/handoff_service.rs create mode 100644 src-tauri/src/web/handlers/handoff.rs 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), From 400571db6ffe7edc594e9eadcdbb8b3ee629746d Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:07:56 -0700 Subject: [PATCH 2/2] feat(handoff): dialog, timeline divider and menu entries One action from three places: the conversation header menu, the sidebar card's context menu, and the composer's agent control of a persisted conversation. The dialog reuses the agent selector, takes an optional note for the next agent, and states which path the backend will take before the user confirms (native load, briefing, or why it cannot run). On success the open tab is swapped onto the new agent so the same conversation reopens under it; a failure leaves the tab where it was. The seam renders like the compaction divider: a chrome-less rule naming both agents, whether the context moved natively or as a briefing, the note, and the briefing folded behind a toggle. The seeded briefing never shows as a user bubble, live or reloaded. --- src/components/chat/chat-input.tsx | 5 + src/components/chat/conversation-shell.tsx | 5 + src/components/chat/message-input.tsx | 37 ++- .../agent-handoff-dialog.test.tsx | 309 ++++++++++++++++++ .../conversations/agent-handoff-dialog.tsx | 307 +++++++++++++++++ .../conversation-detail-header.tsx | 29 +- .../conversation-detail-panel.tsx | 34 ++ .../sidebar-conversation-card.tsx | 18 + .../message/agent-handoff-card.test.tsx | 83 +++++ src/components/message/agent-handoff-card.tsx | 99 ++++++ .../message/content-parts-renderer.tsx | 7 + .../message/message-list-view.test.tsx | 89 +++++ src/components/message/message-list-view.tsx | 42 +++ src/i18n/messages/ar.json | 31 ++ src/i18n/messages/de.json | 31 ++ src/i18n/messages/en.json | 31 ++ src/i18n/messages/es.json | 31 ++ src/i18n/messages/fr.json | 31 ++ src/i18n/messages/ja.json | 31 ++ src/i18n/messages/ko.json | 31 ++ src/i18n/messages/pt.json | 31 ++ src/i18n/messages/zh-CN.json | 31 ++ src/i18n/messages/zh-TW.json | 31 ++ src/lib/adapters/ai-elements-adapter.ts | 6 +- src/lib/agent-handoff.test.ts | 97 ++++++ src/lib/agent-handoff.ts | 84 +++++ src/lib/api.ts | 71 ++++ 27 files changed, 1628 insertions(+), 4 deletions(-) create mode 100644 src/components/conversations/agent-handoff-dialog.test.tsx create mode 100644 src/components/conversations/agent-handoff-dialog.tsx create mode 100644 src/components/message/agent-handoff-card.test.tsx create mode 100644 src/components/message/agent-handoff-card.tsx create mode 100644 src/lib/agent-handoff.test.ts create mode 100644 src/lib/agent-handoff.ts 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`). *