diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 2ad751196b..6173c0d9fe 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -3762,7 +3762,8 @@ impl Engine { let started_at = Instant::now(); let shell_permits = shell_permits.clone(); let workspace = self.session.workspace.clone(); - let context_override = batch_tool_context.clone(); + let context_override = + tool_context_for_call(batch_tool_context.clone(), &plan.id); let cancel_token = self.cancel_token.clone(); let turn_tool_security = self.active_turn_tool_security.clone(); let restricted_audit = turn_tool_security.is_some(); @@ -3954,7 +3955,7 @@ impl Engine { tool_input.clone(), tool_registry, tool_exec_lock.clone(), - batch_tool_context.clone(), + tool_context_for_call(batch_tool_context.clone(), &tool_id), ) => ToolExecutionOutcome::from_legacy(result), }; let result = terminal.legacy_result(); @@ -4252,7 +4253,10 @@ impl Engine { self.session.workspace.clone(), tool_registry, mcp_pool.clone(), - context_override.or_else(|| batch_tool_context.clone()), + tool_context_for_call( + context_override.or_else(|| batch_tool_context.clone()), + &tool_id, + ), self.active_turn_tool_security.clone(), ) => (result, false), } @@ -4984,6 +4988,13 @@ pub(super) fn production_input_estimate_with_work_tail( )) } +fn tool_context_for_call( + context: Option, + tool_call_id: &str, +) -> Option { + context.map(|context| context.with_origin_tool_call_id(tool_call_id)) +} + pub(super) fn shell_completion_status_text( events: &[crate::tools::shell::ShellCompletionEvent], timing: &str, @@ -5810,6 +5821,18 @@ fn is_turn_metadata_text(text: &str) -> bool { mod tests { use super::*; + #[test] + fn forkguard_tool_context_for_call_preserves_turn_and_sets_call_origin() { + let context = crate::tools::ToolContext::new(".").with_foreground_turn_id("turn-origin"); + + let context = tool_context_for_call(Some(context), "tool-origin") + .expect("tool context remains available"); + + assert_eq!(context.origin_turn_id.as_deref(), Some("turn-origin")); + assert_eq!(context.origin_tool_call_id.as_deref(), Some("tool-origin")); + assert!(tool_context_for_call(None, "tool-origin").is_none()); + } + #[test] fn subagent_completion_handoff_is_internal_user_message() { let message = subagent_completion_runtime_message( @@ -5848,6 +5871,8 @@ mod tests { linked_task_id: Some("task_1".to_string()), owner_agent_id: Some("agent_verifier".to_string()), owner_agent_name: Some("verifier".to_string()), + origin_tool_call_id: Some("tool_abc".to_string()), + origin_turn_id: Some("turn_abc".to_string()), }], "", ) @@ -5871,6 +5896,8 @@ mod tests { linked_task_id: Some("task_1".to_string()), owner_agent_id: Some("agent_verifier".to_string()), owner_agent_name: Some("verifier".to_string()), + origin_tool_call_id: Some("tool_abc".to_string()), + origin_turn_id: Some("turn_abc".to_string()), }, ]); let text = match &message.content[0] { @@ -5887,6 +5914,8 @@ mod tests { assert!(text.contains("art_shell_abc")); assert!(text.contains("cargo test -p codewhale-tui")); assert!(text.contains("test failed")); + assert!(text.contains(r#""origin_tool_call_id":"tool_abc""#)); + assert!(text.contains(r#""origin_turn_id":"turn_abc""#)); } #[test] diff --git a/crates/tui/src/runtime_handoff.rs b/crates/tui/src/runtime_handoff.rs index dc4b0b8752..d72d166a0c 100644 --- a/crates/tui/src/runtime_handoff.rs +++ b/crates/tui/src/runtime_handoff.rs @@ -140,6 +140,8 @@ pub(crate) fn shell_completion_runtime_message( "linked_task_id": event.linked_task_id, "owner_agent_id": event.owner_agent_id, "owner_agent_name": event.owner_agent_name, + "origin_tool_call_id": event.origin_tool_call_id, + "origin_turn_id": event.origin_turn_id, }) .to_string() }) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index ba315cfaf8..2733137bc6 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -166,6 +166,10 @@ pub struct ShellJobSnapshot { pub owner_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_agent_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_turn_id: Option, } /// Once-only completion event for a tracked background shell job. @@ -189,6 +193,10 @@ pub struct ShellCompletionEvent { pub owner_agent_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub owner_agent_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_turn_id: Option, } /// Exact byte evidence captured alongside a bounded completion event. @@ -225,6 +233,8 @@ impl ShellCompletionEvidence { "status": format!("{:?}", self.event.status), "exit_code": self.event.exit_code, "duration_ms": self.event.duration_ms, + "origin_tool_call_id": self.event.origin_tool_call_id, + "origin_turn_id": self.event.origin_turn_id, "stdout": stream(&self.stdout), "stderr": stream(&self.stderr), }) @@ -748,6 +758,8 @@ pub struct BackgroundShell { pub sandbox_type: SandboxType, pub linked_task_id: Option, pub owner_agent: Option, + origin_tool_call_id: Option, + origin_turn_id: Option, stdout_buffer: Arc>>, stderr_buffer: Option>>>, heavy_permit: Option, @@ -839,6 +851,8 @@ struct ShellSpawnIntentGuard { struct ShellSpawnContext { owner_agent: Option, work_lifecycle: Option, + origin_tool_call_id: Option, + origin_turn_id: Option, } impl ShellSpawnIntentGuard { @@ -1194,6 +1208,8 @@ impl BackgroundShell { .owner_agent .as_ref() .map(|owner| owner.agent_name.clone()), + origin_tool_call_id: self.origin_tool_call_id.clone(), + origin_turn_id: self.origin_turn_id.clone(), } } @@ -1224,6 +1240,8 @@ impl BackgroundShell { .owner_agent .as_ref() .map(|owner| owner.agent_name.clone()), + origin_tool_call_id: self.origin_tool_call_id.clone(), + origin_turn_id: self.origin_turn_id.clone(), } } @@ -1551,6 +1569,8 @@ impl ShellManager { owner_agent, None, None, + None, + None, ) } @@ -1569,6 +1589,8 @@ impl ShellManager { owner_agent: Option, work_lifecycle: Option, readonly_workspace: Option<&std::path::Path>, + origin_tool_call_id: Option, + origin_turn_id: Option, ) -> Result { // Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set. crate::shell_dispatcher::ShellDispatcher::log_exec(command); @@ -1611,6 +1633,8 @@ impl ShellManager { ShellSpawnContext { owner_agent, work_lifecycle, + origin_tool_call_id, + origin_turn_id, }, ) } else { @@ -1979,6 +2003,8 @@ impl ShellManager { let ShellSpawnContext { owner_agent, work_lifecycle, + origin_tool_call_id, + origin_turn_id, } = spawn_context; let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]); let mut spawn_guard = @@ -2147,6 +2173,8 @@ impl ShellManager { sandbox_type, linked_task_id: None, owner_agent, + origin_tool_call_id, + origin_turn_id, stdout_buffer, stderr_buffer, heavy_permit, @@ -2561,6 +2589,8 @@ impl ShellManager { linked_task_id, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, ); } @@ -3247,6 +3277,8 @@ async fn execute_foreground_via_background( owner, lifecycle, direct_argv.then_some(context.workspace.as_path()), + context.origin_tool_call_id.clone(), + context.origin_turn_id.clone(), )? }; let task_id = spawned @@ -3956,6 +3988,8 @@ impl ToolSpec for BashTool { shell_job_owner_from_context(context), shell_work_lifecycle_from_context(context), None, + context.origin_tool_call_id.clone(), + context.origin_turn_id.clone(), ); if let (Ok(result), Some(permit)) = (&result, heavy_permit) && let Some(task_id) = result.task_id.as_deref() diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 648f2bf316..6560d86191 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -907,9 +907,12 @@ async fn background_start_advertises_task_status_completion() { } #[tokio::test] -async fn background_shell_job_carries_subagent_owner() { +async fn forkguard_background_shell_job_preserves_origin_identity() { let tmp = tempdir().expect("tempdir"); - let ctx = ToolContext::new(tmp.path()).with_owner_agent("agent_owner", "verifier"); + let ctx = ToolContext::new(tmp.path()) + .with_foreground_turn_id("turn-origin") + .with_origin_tool_call_id("tool-origin") + .with_owner_agent("agent_owner", "verifier"); let result = BashTool::new("Bash") .execute( json!({"command": sleep_command(2), "background": true}), @@ -942,6 +945,16 @@ async fn background_shell_job_carries_subagent_owner() { .expect("owned shell job snapshot"); assert_eq!(snapshot.owner_agent_id.as_deref(), Some("agent_owner")); assert_eq!(snapshot.owner_agent_name.as_deref(), Some("verifier")); + assert_eq!(snapshot.origin_tool_call_id.as_deref(), Some("tool-origin")); + assert_eq!(snapshot.origin_turn_id.as_deref(), Some("turn-origin")); + let mut legacy_json = serde_json::to_value(&snapshot).expect("serialize snapshot"); + let legacy_object = legacy_json.as_object_mut().expect("snapshot object"); + legacy_object.remove("origin_tool_call_id"); + legacy_object.remove("origin_turn_id"); + let legacy_snapshot: ShellJobSnapshot = + serde_json::from_value(legacy_json).expect("deserialize legacy snapshot"); + assert_eq!(legacy_snapshot.origin_tool_call_id, None); + assert_eq!(legacy_snapshot.origin_turn_id, None); let owners = manager.running_owner_agent_ids(); assert_eq!(owners, vec!["agent_owner".to_string()]); } @@ -955,7 +968,9 @@ async fn background_shell_job_carries_subagent_owner() { #[tokio::test] async fn drain_finished_jobs_reports_once() { let tmp = tempdir().expect("tempdir"); - let ctx = ToolContext::new(tmp.path()); + let ctx = ToolContext::new(tmp.path()) + .with_foreground_turn_id("turn-origin") + .with_origin_tool_call_id("tool-origin"); let result = BashTool::new("Bash") .execute( json!({"command": echo_command("drain-finished-once"), "background": true}), @@ -990,6 +1005,16 @@ async fn drain_finished_jobs_reports_once() { assert_eq!(first[0].task_id, task_id); assert_eq!(first[0].status, ShellStatus::Completed); assert!(first[0].stdout_tail.contains("drain-finished-once")); + assert_eq!(first[0].origin_tool_call_id.as_deref(), Some("tool-origin")); + assert_eq!(first[0].origin_turn_id.as_deref(), Some("turn-origin")); + let mut legacy_json = serde_json::to_value(&first[0]).expect("serialize completion"); + let legacy_object = legacy_json.as_object_mut().expect("completion object"); + legacy_object.remove("origin_tool_call_id"); + legacy_object.remove("origin_turn_id"); + let legacy_completion: ShellCompletionEvent = + serde_json::from_value(legacy_json).expect("deserialize legacy completion"); + assert_eq!(legacy_completion.origin_tool_call_id, None); + assert_eq!(legacy_completion.origin_turn_id, None); let second = manager.drain_finished_jobs_with_evidence(); assert!(second.is_empty(), "completion should be reported only once"); @@ -1017,6 +1042,8 @@ fn completion_evidence_preserves_arbitrary_stream_bytes() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: Some("tool-origin".to_string()), + origin_turn_id: Some("turn-origin".to_string()), }, stdout: stdout.clone(), stderr: stderr.clone(), @@ -1026,6 +1053,8 @@ fn completion_evidence_preserves_arbitrary_stream_bytes() { serde_json::from_slice(&evidence.artifact_bytes()).expect("evidence JSON"); assert_eq!(payload["stdout"]["encoding"], "base64"); assert_eq!(payload["stderr"]["encoding"], "base64"); + assert_eq!(payload["origin_tool_call_id"], "tool-origin"); + assert_eq!(payload["origin_turn_id"], "turn-origin"); let decoded_stdout = base64::engine::general_purpose::STANDARD .decode(payload["stdout"]["content"].as_str().expect("stdout data")) .expect("decode stdout"); @@ -1254,6 +1283,8 @@ fn completed_shell_with_reader( sandbox_type: SandboxType::None, linked_task_id: None, owner_agent: None, + origin_tool_call_id: None, + origin_turn_id: None, stdout_buffer: std::sync::Arc::clone(&stdout_buffer), stderr_buffer: None, heavy_permit: None, @@ -2703,6 +2734,8 @@ fn killed_shell_does_not_wait_for_blocked_reader_threads() { sandbox_type: SandboxType::None, linked_task_id: None, owner_agent: None, + origin_tool_call_id: None, + origin_turn_id: None, stdout_buffer: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), stderr_buffer: None, heavy_permit: None, diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 463449e62a..2f2a9d470f 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -540,6 +540,11 @@ pub struct ToolExecutionState { /// jobs can be attributed in UI surfaces. pub owner_agent_id: Option, pub owner_agent_name: Option, + /// Tool call and engine turn that created work through this context. + /// Long-running tools preserve these stable identities so hosts can + /// reconcile later snapshots with the originating transcript position. + pub(crate) origin_tool_call_id: Option, + pub(crate) origin_turn_id: Option, /// Engine turn that owns foreground shell waits created from this /// context. Detached/background work clears this identity and survives /// turn cancellation. @@ -703,6 +708,8 @@ impl ToolContext { file_read_tracker: new_shared_file_read_tracker(), owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, foreground_turn_id: None, tool_authority, trust_mode, @@ -769,7 +776,16 @@ impl ToolContext { /// Bind foreground shell waits to the engine turn that created them. #[must_use] pub(crate) fn with_foreground_turn_id(mut self, turn_id: impl Into) -> Self { - self.foreground_turn_id = Some(turn_id.into()); + let turn_id = turn_id.into(); + self.foreground_turn_id = Some(turn_id.clone()); + self.origin_turn_id = Some(turn_id); + self + } + + /// Bind long-running work to the tool call that created it. + #[must_use] + pub(crate) fn with_origin_tool_call_id(mut self, tool_call_id: impl Into) -> Self { + self.origin_tool_call_id = Some(tool_call_id.into()); self } diff --git a/crates/tui/src/tui/shell_job_routing.rs b/crates/tui/src/tui/shell_job_routing.rs index 4187c88e09..300a5561d9 100644 --- a/crates/tui/src/tui/shell_job_routing.rs +++ b/crates/tui/src/tui/shell_job_routing.rs @@ -169,6 +169,8 @@ mod tests { linked_task_id: Some("task_1".to_string()), owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }]; let formatted = format_shell_job_list(&jobs); assert!(formatted.contains("Bash jobs (1)")); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index d9603de1d6..e88a48e10a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5293,6 +5293,8 @@ fn shell_live_output_update_matches_exact_task_id_only() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, ); jobs.insert( @@ -5315,6 +5317,8 @@ fn shell_live_output_update_matches_exact_task_id_only() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, ); @@ -5378,6 +5382,8 @@ fn shell_live_output_update_marks_stale_running_job_static() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, ); @@ -5455,6 +5461,8 @@ fn shell_live_output_update_finalizes_background_exec_output() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, ); @@ -5539,6 +5547,8 @@ fn shell_live_output_update_skips_finalized_exec_cell() { linked_task_id: None, owner_agent_id: None, owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, }, );