Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -4984,6 +4988,13 @@ pub(super) fn production_input_estimate_with_work_tail(
))
}

fn tool_context_for_call(
context: Option<crate::tools::ToolContext>,
tool_call_id: &str,
) -> Option<crate::tools::ToolContext> {
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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()),
}],
"",
)
Expand All @@ -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] {
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/runtime_handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
34 changes: 34 additions & 0 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ pub struct ShellJobSnapshot {
pub owner_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_agent_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_turn_id: Option<String>,
}

/// Once-only completion event for a tracked background shell job.
Expand All @@ -189,6 +193,10 @@ pub struct ShellCompletionEvent {
pub owner_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_agent_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_turn_id: Option<String>,
}

/// Exact byte evidence captured alongside a bounded completion event.
Expand Down Expand Up @@ -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),
})
Expand Down Expand Up @@ -748,6 +758,8 @@ pub struct BackgroundShell {
pub sandbox_type: SandboxType,
pub linked_task_id: Option<String>,
pub owner_agent: Option<ShellJobOwner>,
origin_tool_call_id: Option<String>,
origin_turn_id: Option<String>,
stdout_buffer: Arc<Mutex<Vec<u8>>>,
stderr_buffer: Option<Arc<Mutex<Vec<u8>>>>,
heavy_permit: Option<HeavyCommandPermit>,
Expand Down Expand Up @@ -839,6 +851,8 @@ struct ShellSpawnIntentGuard {
struct ShellSpawnContext {
owner_agent: Option<ShellJobOwner>,
work_lifecycle: Option<ShellWorkLifecycle>,
origin_tool_call_id: Option<String>,
origin_turn_id: Option<String>,
}

impl ShellSpawnIntentGuard {
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -1551,6 +1569,8 @@ impl ShellManager {
owner_agent,
None,
None,
None,
None,
)
}

Expand All @@ -1569,6 +1589,8 @@ impl ShellManager {
owner_agent: Option<ShellJobOwner>,
work_lifecycle: Option<ShellWorkLifecycle>,
readonly_workspace: Option<&std::path::Path>,
origin_tool_call_id: Option<String>,
origin_turn_id: Option<String>,
) -> Result<ShellResult> {
// Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set.
crate::shell_dispatcher::ShellDispatcher::log_exec(command);
Expand Down Expand Up @@ -1611,6 +1633,8 @@ impl ShellManager {
ShellSpawnContext {
owner_agent,
work_lifecycle,
origin_tool_call_id,
origin_turn_id,
},
)
} else {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
39 changes: 36 additions & 3 deletions crates/tui/src/tools/shell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Expand Down Expand Up @@ -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()]);
}
Expand All @@ -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}),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(),
Expand All @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion crates/tui/src/tools/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,11 @@ pub struct ToolExecutionState {
/// jobs can be attributed in UI surfaces.
pub owner_agent_id: Option<String>,
pub owner_agent_name: Option<String>,
/// 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<String>,
pub(crate) origin_turn_id: Option<String>,
/// Engine turn that owns foreground shell waits created from this
/// context. Detached/background work clears this identity and survives
/// turn cancellation.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) -> 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<String>) -> Self {
self.origin_tool_call_id = Some(tool_call_id.into());
self
}

Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/tui/shell_job_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)"));
Expand Down
Loading
Loading