diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 82914f3a6f..95aa8ada68 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3323,7 +3323,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f856b43d66..66426f78ef 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15571,7 +15571,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c17efe7a45..e4f05120d5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13457,7 +13457,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 0831483a32..d8fab4e583 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -677,7 +677,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 16bfeece14..9bf7fbcadb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -677,7 +677,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 16abcd7806..599eea37cf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -821,7 +821,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 653c5f2387..1b80b34630 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1647,7 +1647,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 2f5cbb9500..b1ba249739 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index b9ae59708a..eef2ff6395 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index cda474c294..293d839263 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 27cf47f2fc..08835caf77 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1647,7 +1647,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index e5339f4e99..9236ec7103 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 7d93606aa4..cb20704e9b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1647,7 +1647,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 774686e46a..a3aff27e8f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 64179af7e1..86ce2f60a1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1097,7 +1097,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 0739fa31bc..42562066a5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -821,7 +821,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index bc5917ef15..afc1bdcd18 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -821,7 +821,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 22ad85d906..1cc3119fd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -821,7 +821,7 @@ "description": "The command's working directory." }, "durationMs": { - "description": "The duration of the command execution in milliseconds.", + "description": "Elapsed command runtime in milliseconds. Final once the command completes.", "format": "int64", "type": [ "integer", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index f7880c9d32..eee81b7508 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -51,7 +51,7 @@ aggregatedOutput: string | null, */ exitCode: number | null, /** - * The duration of the command execution in milliseconds. + * Elapsed command runtime in milliseconds. Final once the command completes. */ durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 69ba331ce6..47092cb175 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -101,7 +101,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th .collect(), aggregated_output: None, exit_code: None, - duration_ms: None, + duration_ms: Some(0), } } diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 64307c24bf..1ea7f33dfb 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2428,6 +2428,57 @@ mod tests { ); } + #[test] + fn exec_command_begin_updates_active_turn_snapshot_with_elapsed_duration() { + let turn_id = "turn-1"; + let mut builder = ThreadHistoryBuilder::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "exec-call".into(), + process_id: Some("pid-1".into()), + turn_id: turn_id.to_string(), + command: vec!["sleep".into(), "100".into()], + cwd: test_path_buf("/tmp").abs(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "sleep 100".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ]; + + for event in &events { + builder.handle_event(event); + } + + let snapshot = builder + .active_turn_snapshot() + .expect("active turn snapshot"); + assert_eq!( + snapshot.items, + vec![ThreadItem::CommandExecution { + id: "exec-call".into(), + command: "sleep 100".into(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "sleep 100".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: Some(0), + }] + ); + } + #[test] fn patch_apply_begin_updates_active_turn_snapshot_with_file_change() { let turn_id = "turn-1"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 963ac69000..bf9e2e41b1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5913,7 +5913,7 @@ pub enum ThreadItem { aggregated_output: Option, /// The command's exit code. exit_code: Option, - /// The duration of the command execution in milliseconds. + /// Elapsed command runtime in milliseconds. Final once the command completes. #[ts(type = "number | null")] duration_ms: Option, }, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dab47ec3a2..6f17f54a62 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1140,7 +1140,7 @@ Today both notifications carry an empty `items` array even when item events were - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). -- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. +- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. `durationMs` is elapsed runtime while the command is running and the final duration once it completes. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. @@ -1179,7 +1179,7 @@ There are additional item-specific events: #### commandExecution - `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. - Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. + Running `commandExecution` items include elapsed `durationMs`; final items include parsed `commandActions`, `status`, `exitCode`, and final `durationMs` so the UI can summarize what ran and whether it succeeded. #### fileChange diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index e702152356..f6812cbb6b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1376,7 +1376,7 @@ async fn start_command_execution_item( command_actions, aggregated_output: None, exit_code: None, - duration_ms: None, + duration_ms: Some(0), }, }; outgoing @@ -2431,7 +2431,7 @@ mod tests { command_actions: completion_item.command_actions.clone(), aggregated_output: None, exit_code: None, - duration_ms: None, + duration_ms: Some(0), } ); } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index edc142c840..42e19b6d41 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4006,6 +4006,11 @@ impl CodexMessageProcessor { } else { false }; + if include_turns && has_live_in_progress_turn { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let state = thread_state.lock().await; + state.refresh_running_command_durations(&mut thread.turns); + } let thread_status = self .thread_watch_manager @@ -8427,6 +8432,10 @@ async fn handle_pending_thread_resume_request( .await; return; } + { + let state = thread_state.lock().await; + state.refresh_running_command_durations(&mut thread.turns); + } let thread_status = thread_watch_manager .loaded_status_for_thread(&thread.id) diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index dddbcf483b..7ae799a2de 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,8 +1,10 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; +use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; @@ -16,6 +18,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use std::sync::Weak; +use std::time::Instant; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -24,6 +27,10 @@ use tracing::error; type PendingInterruptQueue = Vec; +fn elapsed_duration_ms(duration: std::time::Duration) -> i64 { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, pub(crate) history_items: Vec, @@ -74,6 +81,7 @@ pub(crate) struct ThreadState { pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, pub(crate) listener_generation: u64, + running_command_started_at: HashMap, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -122,12 +130,45 @@ impl ThreadState { } pub(crate) fn active_turn_snapshot(&self) -> Option { - self.current_turn_history.active_turn_snapshot() + let mut turn = self.current_turn_history.active_turn_snapshot()?; + self.refresh_running_command_durations(std::slice::from_mut(&mut turn)); + Some(turn) + } + + pub(crate) fn refresh_running_command_durations(&self, turns: &mut [Turn]) { + for turn in turns { + for item in &mut turn.items { + let ThreadItem::CommandExecution { + id, + status: CommandExecutionStatus::InProgress, + duration_ms, + .. + } = item + else { + continue; + }; + let Some(started_at) = self.running_command_started_at.get(id) else { + continue; + }; + *duration_ms = Some(elapsed_duration_ms(started_at.elapsed())); + } + } } pub(crate) fn track_current_turn_event(&mut self, event_turn_id: &str, event: &EventMsg) { - if let EventMsg::TurnStarted(payload) = event { - self.turn_summary.started_at = payload.started_at; + match event { + EventMsg::TurnStarted(payload) => { + self.turn_summary.started_at = payload.started_at; + } + EventMsg::ExecCommandBegin(payload) => { + self.running_command_started_at + .entry(payload.call_id.clone()) + .or_insert_with(Instant::now); + } + EventMsg::ExecCommandEnd(payload) => { + self.running_command_started_at.remove(&payload.call_id); + } + _ => {} } self.current_turn_history.handle_event(event); if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) @@ -417,3 +458,96 @@ impl ThreadStateManager { .map(|thread_entry| thread_entry.has_connections_watcher.subscribe()) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::CommandAction; + use codex_app_server_protocol::CommandExecutionSource; + use codex_app_server_protocol::TurnStatus; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use std::time::Duration; + + fn test_absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(PathBuf::from(path)).expect("path must be absolute") + } + + #[test] + fn refreshes_running_command_durations_for_live_turns() { + let mut state = ThreadState::default(); + state.running_command_started_at.insert( + "exec-call".into(), + Instant::now() - Duration::from_millis(1_500), + ); + let mut turns = vec![Turn { + id: "turn-1".into(), + items: vec![ThreadItem::CommandExecution { + id: "exec-call".into(), + command: "sleep 100".into(), + cwd: test_absolute_path(if cfg!(windows) { "C:\\tmp" } else { "/tmp" }), + process_id: Some("pid-1".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "sleep 100".into(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: Some(0), + }], + status: TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }]; + + state.refresh_running_command_durations(&mut turns); + + let ThreadItem::CommandExecution { duration_ms, .. } = &turns[0].items[0] else { + panic!("expected command execution item"); + }; + let elapsed = duration_ms.expect("running command duration"); + assert!(elapsed >= 1_500, "elapsed duration should include runtime"); + } + + #[test] + fn leaves_completed_command_duration_unchanged() { + let mut state = ThreadState::default(); + state.running_command_started_at.insert( + "exec-call".into(), + Instant::now() - Duration::from_millis(1_500), + ); + let mut turns = vec![Turn { + id: "turn-1".into(), + items: vec![ThreadItem::CommandExecution { + id: "exec-call".into(), + command: "sleep 100".into(), + cwd: test_absolute_path(if cfg!(windows) { "C:\\tmp" } else { "/tmp" }), + process_id: Some("pid-1".into()), + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::Completed, + command_actions: vec![CommandAction::Unknown { + command: "sleep 100".into(), + }], + aggregated_output: Some(String::new()), + exit_code: Some(0), + duration_ms: Some(42), + }], + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }]; + + state.refresh_running_command_durations(&mut turns); + + let ThreadItem::CommandExecution { duration_ms, .. } = &turns[0].items[0] else { + panic!("expected command execution item"); + }; + assert_eq!(*duration_ms, Some(42)); + } +}