diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index 12c078871..1866c859c 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -107,8 +107,6 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: Some(Box::new(AgentApiBackend::new( model.clone(), provider_enum, - false, - styles, ))) } }) diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index 70d4c03ad..dd5f88b2d 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -10,7 +10,6 @@ use arc_agent::{ }; use arc_llm::client::Client; use arc_llm::provider::Provider; -use arc_util::terminal::Styles; use crate::context::Context; use crate::error::ArcError; @@ -25,19 +24,15 @@ use crate::outcome::StageUsage; pub struct AgentApiBackend { model: String, provider: Provider, - verbose: bool, - styles: &'static Styles, sessions: Mutex>, } impl AgentApiBackend { #[must_use] - pub fn new(model: String, provider: Provider, verbose: bool, styles: &'static Styles) -> Self { + pub fn new(model: String, provider: Provider) -> Self { Self { model, provider, - verbose, - styles, sessions: Mutex::new(HashMap::new()), } } @@ -224,10 +219,8 @@ impl CodergenBackend for AgentApiBackend { let pending_clone = Arc::clone(&pending_tool_calls); let files_clone = Arc::clone(&files_touched); - // Subscribe to session events: forward to pipeline emitter and optionally print to stderr. - let verbose = self.verbose; + // Subscribe to session events: forward to pipeline emitter. let node_id = node.id.clone(); - let styles = self.styles; let pipeline_emitter = Arc::clone(emitter); let mut rx = session.subscribe(); tokio::spawn(async move { @@ -282,34 +275,6 @@ impl CodergenBackend for AgentApiBackend { }); } - // Verbose stderr printing (gated on verbosity) - if verbose { - match &event.event { - AgentEvent::ToolCallStarted { - tool_name, - arguments, - .. - } => { - eprintln!( - "{} {} {}{}", - styles.dim.apply_to(format!("[{node_id}]")), - styles.dim.apply_to("\u{25cf}"), - styles.bold_cyan.apply_to(tool_name), - styles - .dim - .apply_to(format!("({})", format_tool_args(arguments))), - ); - } - AgentEvent::Error { error } => { - eprintln!( - "{} {}", - styles.dim.apply_to(format!("[{node_id}]")), - styles.red.apply_to(format!("\u{2717} {error}")), - ); - } - _ => {} - } - } } }); @@ -339,15 +304,9 @@ impl CodergenBackend for AgentApiBackend { result?; // Aggregate token usage only from new turns (prevents double-counting on reuse). - let (mut turn_count, mut tool_call_count) = (0usize, 0usize); let mut total_usage = arc_llm::types::Usage::default(); for turn in &session.history().turns()[turns_before..] { - if let Turn::Assistant { - tool_calls, usage, .. - } = turn - { - turn_count += 1; - tool_call_count += tool_calls.len(); + if let Turn::Assistant { usage, .. } = turn { total_usage = total_usage + usage.clone(); } } @@ -363,20 +322,6 @@ impl CodergenBackend for AgentApiBackend { }; stage_usage.cost = super::compute_stage_cost(&stage_usage); - // Print session summary to stderr. - if self.verbose { - let total_tokens = total_usage.input_tokens + total_usage.output_tokens; - let token_str = super::format_tokens_human(total_tokens); - let reuse_label = if is_reused { " (reused session)" } else { "" }; - eprintln!( - "{}", - self.styles.dim.apply_to(format!( - "[{}] Done ({turn_count} turns, {tool_call_count} tool calls, {token_str} tokens{reuse_label})", - node.id, - )), - ); - } - // Extract last assistant response from the session history. let response = session .history() @@ -423,26 +368,6 @@ impl CodergenBackend for AgentApiBackend { } } -fn format_tool_args(args: &serde_json::Value) -> String { - let Some(obj) = args.as_object() else { - return args.to_string(); - }; - obj.iter() - .map(|(k, v)| match v { - serde_json::Value::String(s) => { - let display = if s.len() > 80 { - format!("{}...", &s[..77]) - } else { - s.clone() - }; - format!("{k}={display:?}") - } - other => format!("{k}={other}"), - }) - .collect::>() - .join(", ") -} - #[cfg(test)] mod tests { use super::*; @@ -450,39 +375,20 @@ mod tests { #[test] fn agent_backend_stores_config() { - let styles = Box::leak(Box::new(Styles::new(false))); - let backend = AgentApiBackend::new( - "claude-opus-4-6".to_string(), - Provider::OpenAi, - true, - styles, - ); + let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::OpenAi); assert_eq!(backend.model, "claude-opus-4-6"); assert_eq!(backend.provider, Provider::OpenAi); - assert!(backend.verbose); } #[test] fn agent_backend_initializes_empty_sessions() { - let styles = Box::leak(Box::new(Styles::new(false))); - let backend = AgentApiBackend::new( - "claude-opus-4-6".to_string(), - Provider::Anthropic, - false, - styles, - ); + let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::Anthropic); assert!(backend.sessions.lock().unwrap().is_empty()); } #[test] fn build_profile_can_register_subagent_tools() { - let styles = Box::leak(Box::new(Styles::new(false))); - let backend = AgentApiBackend::new( - "claude-opus-4-6".to_string(), - Provider::Anthropic, - false, - styles, - ); + let backend = AgentApiBackend::new("claude-opus-4-6".to_string(), Provider::Anthropic); let mut profile = backend.build_profile(); let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(1))); let factory: SessionFactory = Arc::new(|| { diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 986f43875..6221e5a71 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -10,15 +10,12 @@ use std::path::Path; use arc_util::terminal::Styles; use clap::{Args, Parser, Subcommand, ValueEnum}; -use indicatif::HumanBytes; use std::fmt; use std::path::PathBuf; use std::str::FromStr; -use crate::event::WorkflowRunEvent; use crate::outcome::StageUsage; use crate::validation::{Diagnostic, Severity}; -use arc_agent::AgentEvent; /// Sandbox provider for agent tool operations. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)] @@ -184,399 +181,6 @@ pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { } } -/// One-line summary of a workflow run event for `-v` output (dimmed). -#[must_use] -pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String { - let body = match event { - WorkflowRunEvent::WorkflowRunStarted { name, run_id, .. } => { - format!("[WORKFLOW_RUN_STARTED] name={name} id={run_id}") - } - WorkflowRunEvent::WorkflowRunCompleted { - duration_ms, - artifact_count, - total_cost, - .. - } => { - let mut s = format!( - "[WORKFLOW_RUN_COMPLETED] duration={duration_ms}ms artifacts={artifact_count}" - ); - if let Some(cost) = total_cost { - s.push_str(&format!(" total_cost={}", format_cost(*cost))); - } - s - } - WorkflowRunEvent::WorkflowRunFailed { - error, duration_ms, .. - } => { - format!("[WORKFLOW_RUN_FAILED] error=\"{error}\" duration={duration_ms}ms") - } - WorkflowRunEvent::StageStarted { - node_id, - name, - index, - handler_type, - attempt, - max_attempts, - .. - } => { - let mut s = format!("[STAGE_STARTED] node_id={node_id} name={name} index={index}"); - if let Some(ht) = handler_type { - s.push_str(&format!(" handler_type={ht}")); - } - s.push_str(&format!(" attempt={attempt}/{max_attempts}")); - s - } - WorkflowRunEvent::StageCompleted { - node_id, - name, - index, - duration_ms, - status, - preferred_label, - suggested_next_ids, - usage, - failure, - notes, - files_touched, - attempt, - max_attempts, - } => { - let mut s = format!("[STAGE_COMPLETED] node_id={node_id} name={name} index={index} duration={duration_ms}ms status={status}"); - if let Some(label) = preferred_label { - s.push_str(&format!(" preferred_label=\"{label}\"")); - } - if !suggested_next_ids.is_empty() { - s.push_str(&format!( - " suggested_next_ids={}", - suggested_next_ids.join(",") - )); - } - if let Some(u) = usage { - let total = u.input_tokens + u.output_tokens; - let tokens_str = format_tokens_human(total); - if let Some(cost) = compute_stage_cost(u) { - s.push_str(&format!(" tokens={tokens_str} cost={}", format_cost(cost))); - } else { - s.push_str(&format!(" tokens={tokens_str}")); - } - } - if let Some(ref f) = failure { - s.push_str(&format!(" failure_reason=\"{}\"", f.message)); - s.push_str(&format!(" failure_class={}", f.failure_class)); - } - if let Some(n) = notes { - s.push_str(&format!(" notes=\"{n}\"")); - } - if !files_touched.is_empty() { - s.push_str(&format!(" files_touched={}", files_touched.len())); - } - s.push_str(&format!(" attempt={attempt}/{max_attempts}")); - s - } - WorkflowRunEvent::StageFailed { - node_id, - name, - index, - failure, - will_retry, - } => { - format!( - "[STAGE_FAILED] node_id={node_id} name={name} index={index} error=\"{}\" will_retry={will_retry} failure_class={}", - failure.message, failure.failure_class - ) - } - WorkflowRunEvent::StageRetrying { - node_id, - name, - index, - attempt, - max_attempts, - delay_ms, - } => { - format!( - "[STAGE_RETRYING] node_id={node_id} name={name} index={index} attempt={attempt}/{max_attempts} delay={delay_ms}ms" - ) - } - WorkflowRunEvent::ParallelStarted { - branch_count, - join_policy, - error_policy, - } => { - format!("[PARALLEL_STARTED] branches={branch_count} join_policy={join_policy} error_policy={error_policy}") - } - WorkflowRunEvent::ParallelBranchStarted { branch, index } => { - format!("[PARALLEL_BRANCH_STARTED] branch={branch} index={index}") - } - WorkflowRunEvent::ParallelBranchCompleted { - branch, - index, - duration_ms, - status, - } => { - format!("[PARALLEL_BRANCH_COMPLETED] branch={branch} index={index} duration={duration_ms}ms status={status}") - } - WorkflowRunEvent::ParallelCompleted { - duration_ms, - success_count, - failure_count, - } => { - format!("[PARALLEL_COMPLETED] duration={duration_ms}ms succeeded={success_count} failed={failure_count}") - } - WorkflowRunEvent::InterviewStarted { - question, - stage, - question_type, - } => { - format!("[INTERVIEW_STARTED] stage={stage} question=\"{question}\" question_type={question_type}") - } - WorkflowRunEvent::InterviewCompleted { - question, - answer, - duration_ms, - } => { - format!( - "[INTERVIEW_COMPLETED] question=\"{question}\" answer=\"{answer}\" duration={duration_ms}ms" - ) - } - WorkflowRunEvent::InterviewTimeout { - stage, duration_ms, .. - } => { - format!("[INTERVIEW_TIMEOUT] stage={stage} duration={duration_ms}ms") - } - WorkflowRunEvent::CheckpointSaved { node_id } => { - format!("[CHECKPOINT_SAVED] node={node_id}") - } - WorkflowRunEvent::GitCheckpoint { - node_id, - git_commit_sha, - status, - .. - } => { - format!("[GIT_CHECKPOINT] node={node_id} sha={git_commit_sha} status={status}") - } - WorkflowRunEvent::EdgeSelected { - from_node, - to_node, - label, - condition, - } => { - let mut s = format!("[EDGE_SELECTED] from={from_node} to={to_node}"); - if let Some(l) = label { - s.push_str(&format!(" label=\"{l}\"")); - } - if let Some(c) = condition { - s.push_str(&format!(" condition=\"{c}\"")); - } - s - } - WorkflowRunEvent::LoopRestart { from_node, to_node } => { - format!("[LOOP_RESTART] from={from_node} to={to_node}") - } - WorkflowRunEvent::Prompt { stage, text } => { - let truncated = if text.len() > 80 { &text[..arc_agent::floor_char_boundary(text, 80)] } else { text }; - format!("[PROMPT] stage={stage} text=\"{truncated}\"") - } - WorkflowRunEvent::Agent { stage, event } => match event { - AgentEvent::AssistantMessage { - model, - usage, - tool_call_count, - .. - } => { - let total = usage.input_tokens + usage.output_tokens; - let tokens_str = format_tokens_human(total); - let mut s = format!("[ASSISTANT_MESSAGE] stage={stage} model={model} tokens={tokens_str} tool_calls={tool_call_count}"); - if let Some(cache_read) = usage.cache_read_tokens { - s.push_str(&format!(" cache_read={}", format_tokens_human(cache_read))); - } - if let Some(reasoning) = usage.reasoning_tokens { - s.push_str(&format!(" reasoning={}", format_tokens_human(reasoning))); - } - s - } - AgentEvent::ToolCallStarted { tool_name, .. } => { - format!("[TOOL_CALL_STARTED] stage={stage} tool={tool_name}") - } - AgentEvent::ToolCallCompleted { - tool_name, - is_error, - .. - } => { - format!("[TOOL_CALL_COMPLETED] stage={stage} tool={tool_name} is_error={is_error}") - } - AgentEvent::Error { error } => { - format!("[SESSION_ERROR] stage={stage} error=\"{error}\"") - } - AgentEvent::ContextWindowWarning { usage_percent, .. } => { - format!("[CONTEXT_WINDOW_WARNING] stage={stage} usage={usage_percent}%") - } - AgentEvent::LoopDetected => format!("[LOOP_DETECTED] stage={stage}"), - AgentEvent::TurnLimitReached { max_turns } => { - format!("[TURN_LIMIT_REACHED] stage={stage} max_turns={max_turns}") - } - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size, - } => { - format!("[COMPACTION_STARTED] stage={stage} estimated_tokens={estimated_tokens} context_window={context_window_size}") - } - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count, - summary_token_estimate, - tracked_file_count, - } => { - format!("[COMPACTION_COMPLETED] stage={stage} original_turns={original_turn_count} preserved_turns={preserved_turn_count} summary_tokens={summary_token_estimate} tracked_files={tracked_file_count}") - } - AgentEvent::LlmRetry { - provider, - model, - attempt, - delay_secs, - error, - } => { - let delay_ms = (*delay_secs * 1000.0) as u64; - format!("[LLM_RETRY] stage={stage} provider={provider} model={model} attempt={attempt} delay={delay_ms}ms error=\"{error}\"") - } - AgentEvent::SubAgentSpawned { - agent_id, - depth, - task, - } => { - let short_id = &agent_id[..8.min(agent_id.len())]; - let task_preview = if task.len() > 60 { &task[..arc_agent::floor_char_boundary(task, 60)] } else { task }; - format!("[SUBAGENT_SPAWNED] stage={stage} agent_id={short_id} depth={depth} task=\"{task_preview}\"") - } - AgentEvent::SubAgentCompleted { - agent_id, - depth, - success, - turns_used, - } => { - let short_id = &agent_id[..8.min(agent_id.len())]; - format!("[SUBAGENT_COMPLETED] stage={stage} agent_id={short_id} depth={depth} success={success} turns={turns_used}") - } - AgentEvent::SubAgentFailed { - agent_id, - depth, - error, - } => { - let short_id = &agent_id[..8.min(agent_id.len())]; - format!("[SUBAGENT_FAILED] stage={stage} agent_id={short_id} depth={depth} error=\"{error}\"") - } - AgentEvent::SubAgentClosed { agent_id, depth } => { - let short_id = &agent_id[..8.min(agent_id.len())]; - format!("[SUBAGENT_CLOSED] stage={stage} agent_id={short_id} depth={depth}") - } - AgentEvent::SubAgentEvent { - agent_id, - depth, - event, - } => { - let short_id = &agent_id[..8.min(agent_id.len())]; - format!("[SUBAGENT_EVENT] stage={stage} agent_id={short_id} depth={depth} event={event:?}") - } - other => format!("[AGENT] stage={stage} event={other:?}"), - }, - WorkflowRunEvent::ParallelEarlyTermination { - reason, - completed_count, - pending_count, - } => { - format!("[PARALLEL_EARLY_TERMINATION] reason={reason} completed={completed_count} pending={pending_count}") - } - WorkflowRunEvent::SubgraphStarted { - node_id, - start_node, - } => { - format!("[SUBGRAPH_STARTED] node={node_id} start_node={start_node}") - } - WorkflowRunEvent::SubgraphCompleted { - node_id, - steps_executed, - status, - duration_ms, - } => { - format!("[SUBGRAPH_COMPLETED] node={node_id} steps={steps_executed} status={status} duration={duration_ms}ms") - } - WorkflowRunEvent::Sandbox { event } => { - use arc_agent::SandboxEvent; - match event { - SandboxEvent::Initializing { provider } => format!("[SANDBOX_INITIALIZING] provider={provider}"), - SandboxEvent::Ready { provider, duration_ms, name, cpu, memory, .. } => { - let mut s = format!("[SANDBOX_READY] provider={provider}"); - if let Some(n) = name { s.push_str(&format!(" name={n}")); } - if let Some(c) = cpu { s.push_str(&format!(" cpu={c}")); } - if let Some(m) = memory { s.push_str(&format!(" memory={m}")); } - s.push_str(&format!(" duration={duration_ms}ms")); - s - } - SandboxEvent::InitializeFailed { provider, error, duration_ms } => format!("[SANDBOX_INIT_FAILED] provider={provider} error=\"{error}\" duration={duration_ms}ms"), - SandboxEvent::CleanupStarted { provider } => format!("[SANDBOX_CLEANUP_STARTED] provider={provider}"), - SandboxEvent::CleanupCompleted { provider, duration_ms } => format!("[SANDBOX_CLEANUP_COMPLETED] provider={provider} duration={duration_ms}ms"), - SandboxEvent::CleanupFailed { provider, error } => format!("[SANDBOX_CLEANUP_FAILED] provider={provider} error=\"{error}\""), - SandboxEvent::SnapshotPulling { name } => format!("[SANDBOX_SNAPSHOT_PULLING] name={name}"), - SandboxEvent::SnapshotPulled { name, duration_ms } => format!("[SANDBOX_SNAPSHOT_PULLED] name={name} duration={duration_ms}ms"), - SandboxEvent::SnapshotEnsuring { name } => format!("[SANDBOX_SNAPSHOT_ENSURING] name={name}"), - SandboxEvent::SnapshotCreating { name } => format!("[SANDBOX_SNAPSHOT_CREATING] name={name}"), - SandboxEvent::SnapshotReady { name, duration_ms } => format!("[SANDBOX_SNAPSHOT_READY] name={name} duration={duration_ms}ms"), - SandboxEvent::SnapshotFailed { name, error } => format!("[SANDBOX_SNAPSHOT_FAILED] name={name} error=\"{error}\""), - SandboxEvent::GitCloneStarted { url, branch } => { - let branch_str = branch.as_deref().unwrap_or("(default)"); - format!("[SANDBOX_GIT_CLONE_STARTED] url={url} branch={branch_str}") - } - SandboxEvent::GitCloneCompleted { url, duration_ms } => format!("[SANDBOX_GIT_CLONE_COMPLETED] url={url} duration={duration_ms}ms"), - SandboxEvent::GitCloneFailed { url, error } => format!("[SANDBOX_GIT_CLONE_FAILED] url={url} error=\"{error}\""), - } - } - WorkflowRunEvent::SetupStarted { command_count } => { - format!("[SETUP_STARTED] command_count={command_count}") - } - WorkflowRunEvent::SetupCommandStarted { command, index } => { - format!("[SETUP_COMMAND_STARTED] index={index} command=\"{command}\"") - } - WorkflowRunEvent::SetupCommandCompleted { - command, - index, - exit_code, - duration_ms, - } => { - format!("[SETUP_COMMAND_COMPLETED] index={index} command=\"{command}\" exit_code={exit_code} duration={duration_ms}ms") - } - WorkflowRunEvent::SetupCompleted { duration_ms } => { - format!("[SETUP_COMPLETED] duration={duration_ms}ms") - } - WorkflowRunEvent::SetupFailed { - command, - index, - exit_code, - stderr, - } => { - let truncated = if stderr.len() > 80 { - &stderr[..arc_agent::floor_char_boundary(stderr, 80)] - } else { - stderr - }; - format!("[SETUP_FAILED] index={index} command=\"{command}\" exit_code={exit_code} stderr=\"{truncated}\"") - } - WorkflowRunEvent::StallWatchdogTimeout { node, idle_seconds } => { - format!("[STALL_WATCHDOG_TIMEOUT] node={node} idle_seconds={idle_seconds}") - } - WorkflowRunEvent::AssetsCaptured { - node_id, - files_copied, - total_bytes, - files_skipped, - } => { - format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={} files_skipped={files_skipped}", HumanBytes(*total_bytes)) - } - WorkflowRunEvent::SshAccessReady { ssh_command } => { - format!("[SSH_ACCESS_READY] {ssh_command}") - } - }; - format!("{}", styles.dim.apply_to(body)) -} - /// Compute the dollar cost for a stage's token usage, if pricing is available. #[must_use] pub fn compute_stage_cost(usage: &StageUsage) -> Option { @@ -654,82 +258,6 @@ mod tests { assert_eq!(SandboxProvider::Daytona.to_string(), "daytona"); } - fn test_styles() -> &'static Styles { - static STYLES: std::sync::LazyLock = - std::sync::LazyLock::new(|| Styles::new(false)); - &STYLES - } - - #[test] - fn format_summary_sandbox_initializing() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::Initializing { - provider: "docker".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_INITIALIZING]")); - assert!(s.contains("docker")); - } - - #[test] - fn format_summary_setup_started() { - let event = WorkflowRunEvent::SetupStarted { command_count: 3 }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SETUP_STARTED]")); - assert!(s.contains("3")); - } - - #[test] - fn format_summary_subagent_spawned() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentSpawned { - agent_id: "abcdef12-3456-7890-abcd-ef1234567890".into(), - depth: 1, - task: "list files".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBAGENT_SPAWNED]")); - assert!(s.contains("abcdef12")); - assert!(s.contains("depth=1")); - } - - #[test] - fn format_summary_subagent_completed() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentCompleted { - agent_id: "abcdef12-xxxx".into(), - depth: 1, - success: true, - turns_used: 5, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBAGENT_COMPLETED]")); - assert!(s.contains("success=true")); - assert!(s.contains("turns=5")); - } - - #[test] - fn format_summary_subagent_event() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentEvent { - agent_id: "abcdef12-xxxx".into(), - depth: 1, - event: Box::new(AgentEvent::SessionStarted), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBAGENT_EVENT]")); - assert!(s.contains("abcdef12")); - } - - // ── Helper function tests ────────────────────────────────────────── - #[test] fn format_cost_zero() { assert_eq!(format_cost(0.0), "$0.00"); @@ -775,8 +303,6 @@ mod tests { assert_eq!(format_tokens_human(3_456_789), "3.5m"); } - // ── compute_stage_cost tests ─────────────────────────────────────── - #[test] fn compute_stage_cost_known_model() { let usage = StageUsage { @@ -806,933 +332,4 @@ mod tests { }; assert_eq!(compute_stage_cost(&usage), None); } - - // ── format_event_summary tests ───────────────────────────────────── - - #[test] - fn format_summary_workflow_run_started() { - let event = WorkflowRunEvent::WorkflowRunStarted { - name: "test-wf".into(), - run_id: "run-123".into(), - base_sha: None, - run_branch: None, - worktree_dir: None, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[WORKFLOW_RUN_STARTED]")); - assert!(s.contains("test-wf")); - assert!(s.contains("run-123")); - } - - #[test] - fn format_summary_workflow_run_completed_no_cost() { - let event = WorkflowRunEvent::WorkflowRunCompleted { - duration_ms: 5000, - artifact_count: 3, - total_cost: None, - final_git_commit_sha: None, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[WORKFLOW_RUN_COMPLETED]")); - assert!(s.contains("5000")); - assert!(s.contains("artifacts=3")); - } - - #[test] - fn format_summary_workflow_run_completed_with_cost() { - let event = WorkflowRunEvent::WorkflowRunCompleted { - duration_ms: 5000, - artifact_count: 3, - total_cost: Some(1.5), - final_git_commit_sha: None, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[WORKFLOW_RUN_COMPLETED]")); - assert!(s.contains("total_cost=$1.50")); - } - - #[test] - fn format_summary_workflow_run_failed() { - let event = WorkflowRunEvent::WorkflowRunFailed { - error: crate::error::ArcError::Parse("bad input".into()), - duration_ms: 1000, - git_commit_sha: None, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[WORKFLOW_RUN_FAILED]")); - assert!(s.contains("1000")); - } - - #[test] - fn format_summary_stage_started_no_handler() { - let event = WorkflowRunEvent::StageStarted { - node_id: "n1".into(), - name: "build".into(), - index: 0, - handler_type: None, - script: None, - attempt: 1, - max_attempts: 3, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_STARTED]")); - assert!(s.contains("node_id=n1")); - assert!(s.contains("name=build")); - assert!(s.contains("attempt=1/3")); - } - - #[test] - fn format_summary_stage_started_with_handler() { - let event = WorkflowRunEvent::StageStarted { - node_id: "n1".into(), - name: "build".into(), - index: 0, - handler_type: Some("agent".into()), - script: None, - attempt: 1, - max_attempts: 3, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_STARTED]")); - assert!(s.contains("handler_type=agent")); - } - - #[test] - fn format_summary_stage_completed_with_usage() { - let event = WorkflowRunEvent::StageCompleted { - node_id: "n1".into(), - name: "code".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - preferred_label: None, - suggested_next_ids: vec![], - usage: Some(StageUsage { - model: "nonexistent-model".into(), - input_tokens: 500, - output_tokens: 300, - cache_read_tokens: None, - cache_write_tokens: None, - reasoning_tokens: None, - cost: None, - }), - failure: None, - notes: None, - files_touched: vec![], - attempt: 1, - max_attempts: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_COMPLETED]")); - assert!(s.contains("tokens=800")); - } - - #[test] - fn format_summary_stage_completed_with_failure() { - let event = WorkflowRunEvent::StageCompleted { - node_id: "n1".into(), - name: "code".into(), - index: 0, - duration_ms: 2000, - status: "failure".into(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: Some(crate::outcome::FailureDetail::new( - "tests failed", - crate::error::FailureClass::Deterministic, - )), - notes: None, - files_touched: vec![], - attempt: 1, - max_attempts: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_COMPLETED]")); - assert!(s.contains("failure_reason=\"tests failed\"")); - assert!(s.contains("failure_class=deterministic")); - } - - #[test] - fn format_summary_stage_completed_with_notes() { - let event = WorkflowRunEvent::StageCompleted { - node_id: "n1".into(), - name: "code".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: None, - notes: Some("all good".into()), - files_touched: vec![], - attempt: 1, - max_attempts: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_COMPLETED]")); - assert!(s.contains("notes=\"all good\"")); - } - - #[test] - fn format_summary_stage_completed_with_files() { - let event = WorkflowRunEvent::StageCompleted { - node_id: "n1".into(), - name: "code".into(), - index: 0, - duration_ms: 2000, - status: "success".into(), - preferred_label: None, - suggested_next_ids: vec![], - usage: None, - failure: None, - notes: None, - files_touched: vec!["a.rs".into(), "b.rs".into()], - attempt: 1, - max_attempts: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_COMPLETED]")); - assert!(s.contains("files_touched=2")); - } - - #[test] - fn format_summary_stage_failed() { - let event = WorkflowRunEvent::StageFailed { - node_id: "n1".into(), - name: "build".into(), - index: 0, - failure: crate::outcome::FailureDetail::new( - "timeout", - crate::error::FailureClass::TransientInfra, - ), - will_retry: true, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_FAILED]")); - assert!(s.contains("will_retry=true")); - assert!(s.contains("transient_infra")); - } - - #[test] - fn format_summary_stage_retrying() { - let event = WorkflowRunEvent::StageRetrying { - node_id: "n1".into(), - name: "build".into(), - index: 0, - attempt: 2, - max_attempts: 3, - delay_ms: 5000, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STAGE_RETRYING]")); - assert!(s.contains("attempt=2/3")); - assert!(s.contains("delay=5000ms")); - } - - #[test] - fn format_summary_parallel_started() { - let event = WorkflowRunEvent::ParallelStarted { - branch_count: 3, - join_policy: "all".into(), - error_policy: "fail_fast".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PARALLEL_STARTED]")); - assert!(s.contains("branches=3")); - assert!(s.contains("join_policy=all")); - } - - #[test] - fn format_summary_parallel_branch_started() { - let event = WorkflowRunEvent::ParallelBranchStarted { - branch: "lint".into(), - index: 0, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PARALLEL_BRANCH_STARTED]")); - assert!(s.contains("branch=lint")); - } - - #[test] - fn format_summary_parallel_branch_completed() { - let event = WorkflowRunEvent::ParallelBranchCompleted { - branch: "lint".into(), - index: 0, - duration_ms: 3000, - status: "success".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PARALLEL_BRANCH_COMPLETED]")); - assert!(s.contains("branch=lint")); - assert!(s.contains("status=success")); - } - - #[test] - fn format_summary_parallel_completed() { - let event = WorkflowRunEvent::ParallelCompleted { - duration_ms: 5000, - success_count: 2, - failure_count: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PARALLEL_COMPLETED]")); - assert!(s.contains("succeeded=2")); - assert!(s.contains("failed=1")); - } - - #[test] - fn format_summary_parallel_early_termination() { - let event = WorkflowRunEvent::ParallelEarlyTermination { - reason: "fail_fast".into(), - completed_count: 1, - pending_count: 2, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PARALLEL_EARLY_TERMINATION]")); - assert!(s.contains("reason=fail_fast")); - assert!(s.contains("completed=1")); - assert!(s.contains("pending=2")); - } - - #[test] - fn format_summary_interview_started() { - let event = WorkflowRunEvent::InterviewStarted { - question: "What is the goal?".into(), - stage: "review".into(), - question_type: "free_text".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[INTERVIEW_STARTED]")); - assert!(s.contains("stage=review")); - assert!(s.contains("What is the goal?")); - } - - #[test] - fn format_summary_interview_completed() { - let event = WorkflowRunEvent::InterviewCompleted { - question: "What?".into(), - answer: "Everything".into(), - duration_ms: 1000, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[INTERVIEW_COMPLETED]")); - assert!(s.contains("question=\"What?\"")); - assert!(s.contains("answer=\"Everything\"")); - } - - #[test] - fn format_summary_interview_timeout() { - let event = WorkflowRunEvent::InterviewTimeout { - question: "q".into(), - stage: "review".into(), - duration_ms: 30000, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[INTERVIEW_TIMEOUT]")); - assert!(s.contains("stage=review")); - assert!(s.contains("30000")); - } - - #[test] - fn format_summary_checkpoint_saved() { - let event = WorkflowRunEvent::CheckpointSaved { - node_id: "n1".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[CHECKPOINT_SAVED]")); - assert!(s.contains("node=n1")); - } - - #[test] - fn format_summary_git_checkpoint() { - let event = WorkflowRunEvent::GitCheckpoint { - run_id: "run-1".into(), - node_id: "n1".into(), - git_commit_sha: "abc123".into(), - status: "committed".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[GIT_CHECKPOINT]")); - assert!(s.contains("sha=abc123")); - assert!(s.contains("status=committed")); - } - - #[test] - fn format_summary_edge_selected_no_label() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "a".into(), - to_node: "b".into(), - label: None, - condition: None, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[EDGE_SELECTED]")); - assert!(s.contains("from=a")); - assert!(s.contains("to=b")); - } - - #[test] - fn format_summary_edge_selected_with_label_and_condition() { - let event = WorkflowRunEvent::EdgeSelected { - from_node: "a".into(), - to_node: "b".into(), - label: Some("pass".into()), - condition: Some("tests_pass".into()), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[EDGE_SELECTED]")); - assert!(s.contains("label=\"pass\"")); - assert!(s.contains("condition=\"tests_pass\"")); - } - - #[test] - fn format_summary_loop_restart() { - let event = WorkflowRunEvent::LoopRestart { - from_node: "check".into(), - to_node: "build".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[LOOP_RESTART]")); - assert!(s.contains("from=check")); - assert!(s.contains("to=build")); - } - - #[test] - fn format_summary_prompt_short() { - let event = WorkflowRunEvent::Prompt { - stage: "code".into(), - text: "Fix the bug".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PROMPT]")); - assert!(s.contains("Fix the bug")); - } - - #[test] - fn format_summary_prompt_long_truncates() { - let long_text = "x".repeat(200); - let event = WorkflowRunEvent::Prompt { - stage: "code".into(), - text: long_text, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[PROMPT]")); - assert!(!s.contains(&"x".repeat(200))); - assert!(s.contains(&"x".repeat(80))); - } - - #[test] - fn format_summary_agent_assistant_message() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::AssistantMessage { - text: "hello".into(), - model: "claude-sonnet-4-5".into(), - usage: arc_llm::types::Usage { - input_tokens: 1000, - output_tokens: 500, - total_tokens: 1500, - ..Default::default() - }, - tool_call_count: 2, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[ASSISTANT_MESSAGE]")); - assert!(s.contains("model=claude-sonnet-4-5")); - assert!(s.contains("tool_calls=2")); - } - - #[test] - fn format_summary_agent_assistant_message_with_cache_and_reasoning() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::AssistantMessage { - text: "hello".into(), - model: "claude-sonnet-4-5".into(), - usage: arc_llm::types::Usage { - input_tokens: 1000, - output_tokens: 500, - total_tokens: 1500, - cache_read_tokens: Some(800), - reasoning_tokens: Some(200), - ..Default::default() - }, - tool_call_count: 0, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[ASSISTANT_MESSAGE]")); - assert!(s.contains("cache_read=800")); - assert!(s.contains("reasoning=200")); - } - - #[test] - fn format_summary_agent_tool_call_started() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ToolCallStarted { - tool_name: "read_file".into(), - tool_call_id: "tc-1".into(), - arguments: serde_json::json!({}), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[TOOL_CALL_STARTED]")); - assert!(s.contains("tool=read_file")); - } - - #[test] - fn format_summary_agent_tool_call_completed() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ToolCallCompleted { - tool_name: "read_file".into(), - tool_call_id: "tc-1".into(), - output: serde_json::json!("content"), - is_error: false, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[TOOL_CALL_COMPLETED]")); - assert!(s.contains("tool=read_file")); - assert!(s.contains("is_error=false")); - } - - #[test] - fn format_summary_agent_error() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::Error { - error: arc_agent::error::AgentError::InvalidState("bad state".into()), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SESSION_ERROR]")); - assert!(s.contains("stage=code")); - } - - #[test] - fn format_summary_agent_context_window_warning() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::ContextWindowWarning { - estimated_tokens: 90000, - context_window_size: 100000, - usage_percent: 90, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[CONTEXT_WINDOW_WARNING]")); - assert!(s.contains("usage=90%")); - } - - #[test] - fn format_summary_agent_loop_detected() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::LoopDetected, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[LOOP_DETECTED]")); - assert!(s.contains("stage=code")); - } - - #[test] - fn format_summary_agent_turn_limit_reached() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::TurnLimitReached { max_turns: 25 }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[TURN_LIMIT_REACHED]")); - assert!(s.contains("max_turns=25")); - } - - #[test] - fn format_summary_agent_compaction_started() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::CompactionStarted { - estimated_tokens: 80000, - context_window_size: 100000, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[COMPACTION_STARTED]")); - assert!(s.contains("estimated_tokens=80000")); - assert!(s.contains("context_window=100000")); - } - - #[test] - fn format_summary_agent_compaction_completed() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::CompactionCompleted { - original_turn_count: 50, - preserved_turn_count: 10, - summary_token_estimate: 2000, - tracked_file_count: 5, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[COMPACTION_COMPLETED]")); - assert!(s.contains("original_turns=50")); - assert!(s.contains("preserved_turns=10")); - assert!(s.contains("tracked_files=5")); - } - - #[test] - fn format_summary_agent_llm_retry() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::LlmRetry { - provider: "anthropic".into(), - model: "claude-sonnet-4-5".into(), - attempt: 2, - delay_secs: 1.5, - error: arc_llm::error::SdkError::RequestTimeout { - message: "timed out".into(), - }, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[LLM_RETRY]")); - assert!(s.contains("provider=anthropic")); - assert!(s.contains("attempt=2")); - assert!(s.contains("delay=1500ms")); - } - - #[test] - fn format_summary_agent_subagent_failed() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentFailed { - agent_id: "abcdef12-3456".into(), - depth: 1, - error: arc_agent::error::AgentError::ToolExecution("failed".into()), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBAGENT_FAILED]")); - assert!(s.contains("abcdef12")); - assert!(s.contains("depth=1")); - } - - #[test] - fn format_summary_agent_subagent_closed() { - let event = WorkflowRunEvent::Agent { - stage: "code".into(), - event: AgentEvent::SubAgentClosed { - agent_id: "abcdef12-3456".into(), - depth: 2, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBAGENT_CLOSED]")); - assert!(s.contains("abcdef12")); - assert!(s.contains("depth=2")); - } - - #[test] - fn format_summary_subgraph_started() { - let event = WorkflowRunEvent::SubgraphStarted { - node_id: "sg1".into(), - start_node: "inner_start".into(), - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBGRAPH_STARTED]")); - assert!(s.contains("node=sg1")); - assert!(s.contains("start_node=inner_start")); - } - - #[test] - fn format_summary_subgraph_completed() { - let event = WorkflowRunEvent::SubgraphCompleted { - node_id: "sg1".into(), - steps_executed: 5, - status: "success".into(), - duration_ms: 3000, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SUBGRAPH_COMPLETED]")); - assert!(s.contains("node=sg1")); - assert!(s.contains("steps=5")); - assert!(s.contains("status=success")); - } - - #[test] - fn format_summary_sandbox_ready() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::Ready { - provider: "docker".into(), - duration_ms: 1500, - name: None, - cpu: None, - memory: None, - url: None, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_READY]")); - assert!(s.contains("docker")); - assert!(s.contains("1500")); - } - - #[test] - fn format_summary_sandbox_init_failed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::InitializeFailed { - provider: "docker".into(), - error: "daemon not running".into(), - duration_ms: 500, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_INIT_FAILED]")); - assert!(s.contains("daemon not running")); - } - - #[test] - fn format_summary_sandbox_cleanup_started() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::CleanupStarted { - provider: "docker".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_CLEANUP_STARTED]")); - } - - #[test] - fn format_summary_sandbox_cleanup_completed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::CleanupCompleted { - provider: "docker".into(), - duration_ms: 200, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_CLEANUP_COMPLETED]")); - assert!(s.contains("200")); - } - - #[test] - fn format_summary_sandbox_cleanup_failed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::CleanupFailed { - provider: "docker".into(), - error: "busy".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_CLEANUP_FAILED]")); - assert!(s.contains("busy")); - } - - #[test] - fn format_summary_sandbox_snapshot_pulling() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotPulling { - name: "base-img".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_PULLING]")); - assert!(s.contains("base-img")); - } - - #[test] - fn format_summary_sandbox_snapshot_pulled() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotPulled { - name: "base-img".into(), - duration_ms: 3000, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_PULLED]")); - assert!(s.contains("base-img")); - } - - #[test] - fn format_summary_sandbox_snapshot_ensuring() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotEnsuring { - name: "snap1".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_ENSURING]")); - assert!(s.contains("snap1")); - } - - #[test] - fn format_summary_sandbox_snapshot_creating() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotCreating { - name: "snap1".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_CREATING]")); - assert!(s.contains("snap1")); - } - - #[test] - fn format_summary_sandbox_snapshot_ready() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotReady { - name: "snap1".into(), - duration_ms: 2000, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_READY]")); - assert!(s.contains("snap1")); - } - - #[test] - fn format_summary_sandbox_snapshot_failed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::SnapshotFailed { - name: "snap1".into(), - error: "disk full".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_SNAPSHOT_FAILED]")); - assert!(s.contains("disk full")); - } - - #[test] - fn format_summary_sandbox_git_clone_started_with_branch() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::GitCloneStarted { - url: "https://github.com/repo".into(), - branch: Some("main".into()), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_GIT_CLONE_STARTED]")); - assert!(s.contains("branch=main")); - } - - #[test] - fn format_summary_sandbox_git_clone_started_no_branch() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::GitCloneStarted { - url: "https://github.com/repo".into(), - branch: None, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_GIT_CLONE_STARTED]")); - assert!(s.contains("branch=(default)")); - } - - #[test] - fn format_summary_sandbox_git_clone_completed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::GitCloneCompleted { - url: "https://github.com/repo".into(), - duration_ms: 5000, - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_GIT_CLONE_COMPLETED]")); - assert!(s.contains("5000")); - } - - #[test] - fn format_summary_sandbox_git_clone_failed() { - let event = WorkflowRunEvent::Sandbox { - event: arc_agent::SandboxEvent::GitCloneFailed { - url: "https://github.com/repo".into(), - error: "auth failed".into(), - }, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SANDBOX_GIT_CLONE_FAILED]")); - assert!(s.contains("auth failed")); - } - - #[test] - fn format_summary_setup_command_started() { - let event = WorkflowRunEvent::SetupCommandStarted { - command: "npm install".into(), - index: 0, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SETUP_COMMAND_STARTED]")); - assert!(s.contains("npm install")); - assert!(s.contains("index=0")); - } - - #[test] - fn format_summary_setup_command_completed() { - let event = WorkflowRunEvent::SetupCommandCompleted { - command: "npm install".into(), - index: 0, - exit_code: 0, - duration_ms: 3000, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SETUP_COMMAND_COMPLETED]")); - assert!(s.contains("exit_code=0")); - assert!(s.contains("3000")); - } - - #[test] - fn format_summary_setup_completed() { - let event = WorkflowRunEvent::SetupCompleted { duration_ms: 10000 }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SETUP_COMPLETED]")); - assert!(s.contains("10000")); - } - - #[test] - fn format_summary_setup_failed_truncates_long_stderr() { - let long_stderr = "e".repeat(200); - let event = WorkflowRunEvent::SetupFailed { - command: "make".into(), - index: 0, - exit_code: 1, - stderr: long_stderr, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[SETUP_FAILED]")); - assert!(s.contains("exit_code=1")); - assert!(!s.contains(&"e".repeat(200))); - assert!(s.contains(&"e".repeat(80))); - } - - #[test] - fn format_summary_stall_watchdog_timeout() { - let event = WorkflowRunEvent::StallWatchdogTimeout { - node: "build".into(), - idle_seconds: 300, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[STALL_WATCHDOG_TIMEOUT]")); - assert!(s.contains("node=build")); - assert!(s.contains("idle_seconds=300")); - } - - #[test] - fn format_summary_assets_captured() { - let event = WorkflowRunEvent::AssetsCaptured { - node_id: "n1".into(), - files_copied: 5, - total_bytes: 1024, - files_skipped: 1, - }; - let s = format_event_summary(&event, test_styles()); - assert!(s.contains("[ASSETS_CAPTURED]")); - assert!(s.contains("node=n1")); - assert!(s.contains("files_copied=5")); - assert!(s.contains("files_skipped=1")); - } } diff --git a/crates/arc-workflows/src/cli/progress.rs b/crates/arc-workflows/src/cli/progress.rs index 67825060a..8442748c7 100644 --- a/crates/arc-workflows/src/cli/progress.rs +++ b/crates/arc-workflows/src/cli/progress.rs @@ -12,7 +12,7 @@ use crate::interviewer::{Answer, Interviewer, Question}; use crate::outcome::StageStatus; use arc_agent::AgentEvent; -use super::{compute_stage_cost, format_cost}; +use super::{compute_stage_cost, format_cost, format_tokens_human}; // ── Cached styles ─────────────────────────────────────────────────────── @@ -159,6 +159,8 @@ struct ActiveStage { has_model: bool, spinner: ProgressBar, tool_calls: VecDeque, + turn_count: u32, + tool_call_count: u32, } const MAX_TOOL_CALLS: usize = 5; @@ -178,6 +180,7 @@ enum ProgressRenderer { pub struct ProgressUI { renderer: ProgressRenderer, + verbose: bool, active_stages: HashMap, setup_command_count: usize, sandbox_bar: Option, @@ -187,7 +190,7 @@ pub struct ProgressUI { } impl ProgressUI { - pub fn new(is_tty: bool) -> Self { + pub fn new(is_tty: bool, verbose: bool) -> Self { let renderer = if is_tty { ProgressRenderer::Tty(TtyRenderer { multi: MultiProgress::new(), @@ -197,6 +200,7 @@ impl ProgressUI { }; Self { renderer, + verbose, active_stages: HashMap::new(), setup_command_count: 0, sandbox_bar: None, @@ -219,7 +223,7 @@ impl ProgressUI { pub fn finish(&mut self) { for (_id, stage) in self.active_stages.drain() { for entry in &stage.tool_calls { - if entry.is_branch { + if entry.is_branch || self.verbose { entry.bar.abandon(); } else { entry.bar.finish_and_clear(); @@ -277,7 +281,32 @@ impl ProgressUI { .and_then(compute_stage_cost) .map(|c| format!("{} ", format_cost(c))) .unwrap_or_default(); - let prefix = format!("{cost_str}{dur}"); + let stats_str = if self.verbose { + let stage = self.active_stages.get(node_id); + let turn_count = stage.map_or(0, |s| s.turn_count); + let tool_call_count = stage.map_or(0, |s| s.tool_call_count); + let total_tokens = usage + .as_ref() + .map(|u| u.input_tokens + u.output_tokens) + .unwrap_or(0); + if turn_count > 0 || tool_call_count > 0 || total_tokens > 0 { + let dim = Style::new().dim(); + format!( + " {}", + dim.apply_to(format!( + "({} turns, {} tool calls, {} tokens)", + turn_count, + tool_call_count, + format_tokens_human(total_tokens), + )) + ) + } else { + String::new() + } + } else { + String::new() + }; + let prefix = format!("{cost_str}{dur}{stats_str}"); let glyph = if succeeded { green_check() } else { @@ -318,6 +347,74 @@ impl ProgressUI { WorkflowRunEvent::SshAccessReady { ssh_command } => { self.on_ssh_access_ready(ssh_command); } + WorkflowRunEvent::EdgeSelected { + from_node, + to_node, + label, + condition, + } if self.verbose => { + let detail = if let Some(c) = condition { + format!(" [{c}]") + } else if let Some(l) = label { + format!(" \"{l}\"") + } else { + String::new() + }; + self.insert_info_line(&format!("\u{2192} {from_node} \u{2192} {to_node}{detail}")); + } + WorkflowRunEvent::LoopRestart { from_node, to_node } if self.verbose => { + self.insert_info_line(&format!( + "\u{21ba} {from_node} \u{2192} {to_node} (loop restart)" + )); + } + WorkflowRunEvent::SetupCommandCompleted { + command, + index, + exit_code, + duration_ms, + } if self.verbose => { + let total = self.setup_command_count; + let dur = format_duration_ms(*duration_ms); + let glyph = if *exit_code == 0 { + green_check() + } else { + red_cross() + }; + let msg = format!( + "{glyph} [{}/{total}] {}", + index + 1, + truncate(command, 60), + ); + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = if let Some(ref setup_bar) = self.setup_bar { + tty.multi + .insert_before(setup_bar, ProgressBar::new_spinner()) + } else { + tty.multi.add(ProgressBar::new_spinner()) + }; + bar.set_style(style_tool_done()); + bar.set_prefix(dur); + bar.finish_with_message(msg); + } + ProgressRenderer::Plain => { + eprintln!(" {msg} {dur}"); + } + } + } + WorkflowRunEvent::StageRetrying { + node_id: _, + name, + attempt, + max_attempts, + delay_ms, + .. + } if self.verbose => { + let dur = format_duration_ms(*delay_ms); + self.insert_info_line(&format!( + "\u{21bb} {name}: retrying (attempt {attempt}/{max_attempts}, delay {dur})" + )); + } _ => {} } } @@ -474,6 +571,8 @@ impl ProgressUI { has_model: false, spinner: bar, tool_calls: VecDeque::new(), + turn_count: 0, + tool_call_count: 0, }, ); } @@ -484,8 +583,8 @@ impl ProgressUI { ProgressRenderer::Tty(_) => { if let Some(stage) = self.active_stages.remove(node_id) { for entry in &stage.tool_calls { - if entry.is_branch { - // Already finished by on_parallel_branch_completed; keep visible + if entry.is_branch || self.verbose { + // Keep visible: branches always, all entries in verbose mode entry.bar.abandon(); } else { entry.bar.finish_and_clear(); @@ -515,6 +614,7 @@ impl ProgressUI { AgentEvent::AssistantMessage { model, .. } => { if let ProgressRenderer::Tty(_) = &self.renderer { if let Some(stage) = self.active_stages.get_mut(stage_node_id) { + stage.turn_count += 1; if !stage.has_model { stage.has_model = true; let dim = Style::new().dim(); @@ -537,8 +637,89 @@ impl ProgressUI { is_error, .. } => { + if let Some(stage) = self.active_stages.get_mut(stage_node_id) { + stage.tool_call_count += 1; + } self.on_tool_call_completed(stage_node_id, tool_call_id, *is_error); } + AgentEvent::ContextWindowWarning { usage_percent, .. } if self.verbose => { + let yellow = Style::new().yellow(); + self.insert_info_line_for_stage( + stage_node_id, + &format!( + "{} context window: {usage_percent}% used", + yellow.apply_to("\u{26a0}") + ), + ); + } + AgentEvent::CompactionCompleted { + original_turn_count, + preserved_turn_count, + tracked_file_count, + .. + } if self.verbose => { + let dim = Style::new().dim(); + self.insert_info_line_for_stage( + stage_node_id, + &format!( + "{}", + dim.apply_to(format!( + "\u{27f3} compaction: {original_turn_count} \u{2192} {preserved_turn_count} turns, {tracked_file_count} files" + )) + ), + ); + } + AgentEvent::LlmRetry { + model, + attempt, + delay_secs, + error, + .. + } if self.verbose => { + let yellow = Style::new().yellow(); + let delay_ms = (*delay_secs * 1000.0) as u64; + let dur = format_duration_ms(delay_ms); + self.insert_info_line_for_stage( + stage_node_id, + &format!( + "{} retry: {model} attempt {attempt} ({error}, delay {dur})", + yellow.apply_to("\u{26a0}") + ), + ); + } + AgentEvent::SubAgentSpawned { + agent_id, task, .. + } if self.verbose => { + let dim = Style::new().dim(); + let short_id = &agent_id[..agent_id.len().min(8)]; + self.insert_info_line_for_stage( + stage_node_id, + &format!( + "{}", + dim.apply_to(format!( + "\u{25b8} subagent[{short_id}] \"{}\"", + truncate(task, 50) + )) + ), + ); + } + AgentEvent::SubAgentCompleted { + agent_id, + turns_used, + success, + .. + } if self.verbose => { + let short_id = &agent_id[..agent_id.len().min(8)]; + let glyph = if *success { + green_check() + } else { + red_cross() + }; + self.insert_info_line_for_stage( + stage_node_id, + &format!("{glyph} subagent[{short_id}] ({turns_used} turns)"), + ); + } _ => {} } } @@ -554,8 +735,8 @@ impl ProgressUI { if let ProgressRenderer::Tty(tty) = &self.renderer { if let Some(stage) = self.active_stages.get_mut(stage_node_id) { - // Evict oldest if at capacity (prefer completed entries) - if stage.tool_calls.len() >= MAX_TOOL_CALLS { + // Evict oldest if at capacity (prefer completed entries); skip in verbose mode + if !self.verbose && stage.tool_calls.len() >= MAX_TOOL_CALLS { let evict_idx = stage .tool_calls .iter() @@ -649,6 +830,43 @@ impl ProgressUI { } } + /// Insert a static info line (verbose-only) at the current position. + fn insert_info_line(&mut self, message: &str) { + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_static_dim()); + bar.finish_with_message(message.to_string()); + } + ProgressRenderer::Plain => { + eprintln!(" {message}"); + } + } + } + + /// Insert a static info line nested under a stage's tool calls. + fn insert_info_line_for_stage(&mut self, stage_node_id: &str, message: &str) { + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let after = self + .active_stages + .get(stage_node_id) + .map(|s| s.tool_calls.back().map_or(&s.spinner, |e| &e.bar)); + let bar = if let Some(after_bar) = after { + tty.multi + .insert_after(after_bar, ProgressBar::new_spinner()) + } else { + tty.multi.add(ProgressBar::new_spinner()) + }; + bar.set_style(style_tool_done()); + bar.finish_with_message(message.to_string()); + } + ProgressRenderer::Plain => { + eprintln!(" {message}"); + } + } + } + fn on_tool_call_completed(&mut self, stage_node_id: &str, tool_call_id: &str, is_error: bool) { if let ProgressRenderer::Tty(_) = &self.renderer { if let Some(stage) = self.active_stages.get_mut(stage_node_id) { @@ -749,7 +967,7 @@ mod tests { #[test] fn parallel_branches_tracked_as_tool_calls() { - let mut ui = ProgressUI::new(true); + let mut ui = ProgressUI::new(true, false); ui.handle_event(&stage_started("fork1", "Fork Analysis")); assert!(ui.active_stages.contains_key("fork1")); @@ -810,7 +1028,7 @@ mod tests { #[test] fn parallel_branch_failure_tracked() { - let mut ui = ProgressUI::new(true); + let mut ui = ProgressUI::new(true, false); ui.handle_event(&stage_started("fork1", "Fork")); ui.handle_event(&WorkflowRunEvent::ParallelStarted { @@ -838,7 +1056,7 @@ mod tests { #[test] fn plain_mode_sets_parallel_parent() { - let mut ui = ProgressUI::new(false); + let mut ui = ProgressUI::new(false, false); ui.handle_event(&stage_started("fork1", "Fork")); ui.handle_event(&WorkflowRunEvent::ParallelStarted { diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 2415570b4..0c243ec23 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -31,8 +31,8 @@ use indicatif::HumanDuration; use std::time::Duration; use super::{ - compute_stage_cost, format_cost, format_event_summary, format_tokens_human, print_diagnostics, - read_dot_file, RunArgs, SandboxProvider, + compute_stage_cost, format_cost, format_tokens_human, print_diagnostics, read_dot_file, + RunArgs, SandboxProvider, }; /// Return the default model string for a given provider. @@ -287,22 +287,14 @@ pub async fn run_command( } } - // Create progress UI (used for non-verbose mode) + // Create progress UI (used for both normal and verbose modes) let is_tty = std::io::stderr().is_terminal(); - let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty))); + let progress_ui = Arc::new(Mutex::new(progress::ProgressUI::new(is_tty, args.verbose))); - if args.verbose { - eprintln!( - "{} {}", - styles.dim.apply_to("Logs:"), - styles.underline.apply_to(super::tilde_path(&logs_dir)), - ); - } else { - progress_ui - .lock() - .expect("progress lock poisoned") - .show_logs_dir(&logs_dir); - } + progress_ui + .lock() + .expect("progress lock poisoned") + .show_logs_dir(&logs_dir); // 3. Build event emitter let mut emitter = EventEmitter::new(); @@ -385,19 +377,11 @@ pub async fn run_command( }); } - if args.verbose { - emitter.on_event(move |event| { - eprintln!("{}", format_event_summary(event, styles)); - }); - } else { - progress::ProgressUI::register(&progress_ui, &mut emitter); - } + progress::ProgressUI::register(&progress_ui, &mut emitter); // 4. Build interviewer let interviewer: Arc = if args.auto_approve { Arc::new(AutoApproveInterviewer) - } else if args.verbose { - Arc::new(ConsoleInterviewer::new(styles)) } else { Arc::new(progress::ProgressAwareInterviewer::new( ConsoleInterviewer::new(styles), @@ -617,7 +601,7 @@ pub async fn run_command( if dry_run_mode { None } else { - let api = AgentApiBackend::new(model.clone(), provider_enum, args.verbose, styles); + let api = AgentApiBackend::new(model.clone(), provider_enum); let cli = AgentCliBackend::new(model.clone(), provider_enum); Some(Box::new(BackendRouter::new(Box::new(api), cli))) } @@ -700,9 +684,7 @@ pub async fn run_command( } // Finish progress bars before printing summary - if !args.verbose { - progress_ui.lock().expect("progress lock poisoned").finish(); - } + progress_ui.lock().expect("progress lock poisoned").finish(); // Auto-derive retro (always, cheap) and optionally run retro agent if !args.no_retro { @@ -1005,7 +987,7 @@ async fn run_from_branch( if dry_run_mode { None } else { - let api = AgentApiBackend::new(model.clone(), provider_enum, args.verbose, styles); + let api = AgentApiBackend::new(model.clone(), provider_enum); let cli = AgentCliBackend::new(model.clone(), provider_enum); Some(Box::new(BackendRouter::new(Box::new(api), cli))) } diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 42f930741..785657f6b 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -7204,8 +7204,6 @@ async fn arc_e2e_with_real_llm() { Some(Box::new(AgentApiBackend::new( model.clone(), Provider::Anthropic, - false, - &TEST_STYLES, )) as Box) });