#![cfg_attr(rustfmt, rustfmt::skip)] //! Async effect execution. //! //! This module takes [`Effect`] values produced by [`super::dispatch`] and //! spawns them as async tasks on a [`JoinSet`]. When tasks complete, //! the event loop converts their output into [`TaskResult`] and feeds it //! back through dispatch. mod helpers; use super::actions; use super::session_title_resolve::worktree_resume_failure_message; #[allow(unused_imports)] use super::{agent, dispatch}; pub use helpers::ConversationsPartial; pub(super) use helpers::{ parse_session_load_running_prompt_id, parse_session_scheduler_background_loops, }; pub(crate) use helpers::{ EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify, persist_setting, sanitize_user_error, }; use helpers::*; use std::path::{Path, PathBuf}; use agent_client_protocol as acp; use tokio::task::JoinSet; use xai_acp_lib::{AcpAgentTx, acp_send}; use actions::{ ClipboardPasteTarget, Effect, ProbedAttachment, SubagentKillOutcome, SwitchModelError, TaskResult, }; #[cfg(test)] use actions::PermissionModePersist; #[cfg(test)] use agent::AgentId; use crate::unified_log as ulog; use xai_grok_shell::sampling::error::http_status_from_error; use xai_grok_shell::session::{ExtMethodResult, SessionInfoResponse}; pub(crate) fn execute( effect: Effect, tasks: &mut JoinSet, acp_tx: &AcpAgentTx, cwd: &Path, session_flags: &SessionFlags, progress_tx: &tokio::sync::mpsc::UnboundedSender, ) -> (bool, EffectMeta) { let mut meta = EffectMeta::default(); let effect_is_send_now = matches!(effect, Effect::SendPromptNow { .. }); match effect { Effect::RegisterActiveSession { session_id, cwd } => { crate::app::signal_handler::set_current_session_id(Some(session_id.clone())); if let Err(e) = xai_grok_shell::active_sessions::register(xai_grok_shell::active_sessions::ActiveSession { session_id, pid: std::process::id(), cwd, opened_at: chrono::Utc::now(), }) { tracing::warn!(?e, "Failed to register active session"); } } Effect::UnregisterActiveSession { session_id } => { crate::app::signal_handler::set_current_session_id(None); unregister_active_session_best_effort(&session_id); } Effect::Quit => { ulog::info("pager quit", None, None); return (true, meta); } Effect::SetWorkingDir { path } => { if let Err(e) = std::env::set_current_dir(&path) { tracing::warn!(error = %e, "project picker: failed to set_current_dir"); } } Effect::ScheduleClearAuthCopyFeedback { generation } => { tasks .spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; TaskResult::AuthCopyFeedbackTimeout { generation, } }); } Effect::Logout => { let tx = acp_tx.clone(); tasks .spawn(async move { send_logout(&tx).await; TaskResult::LogoutComplete }); } Effect::CancelAuth { request_seq } => { let tx = acp_tx.clone(); tasks.spawn(async move { send_auth_cancel(&tx, request_seq).await }); } Effect::CheckSubscription { verify } => { let tx = acp_tx.clone(); tasks.spawn(async move { send_check_subscription(&tx, verify).await }); } Effect::CreditLimitRecheck { agent_id } => { let tx = acp_tx.clone(); tasks.spawn(async move { send_credit_limit_recheck(&tx, agent_id).await }); } Effect::SchedulePaywallCheck => { tasks .spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(5)).await; TaskResult::PaywallCheckTick }); } Effect::ScheduleGateVerifyTimeout { generation } => { tasks .spawn(async move { tokio::time::sleep(crate::app::subscription::GATE_VERIFY_TIMEOUT) .await; TaskResult::GateVerifyTimeout { generation, } }); } Effect::SwitchAccount { request_seq, method_id, use_oauth } => { let tx = acp_tx.clone(); let abort_handle = tasks .spawn(async move { send_logout(&tx).await; send_authenticate(&tx, request_seq, method_id, use_oauth, false) .await }); meta.auth_abort_handle = Some((request_seq, abort_handle)); } Effect::CreateSession { agent_id, cwd: session_cwd, model_id, preferred_session_id, chat_kind, } => { let tx = acp_tx.clone(); let compat = xai_grok_tools::types::compat::CompatConfig::default(); let mcp_servers = xai_grok_shell::util::config::load_mcp_servers( &session_cwd, &compat, ); let mcp_count = mcp_servers.len(); #[allow(unused_mut)] let mut meta = session_flags.to_meta(); let is_chat_path = chat_kind || session_flags.chat_mode; if is_chat_path { apply_chat_kind_meta(&mut meta); } if let Some(ref mid) = model_id { meta.get_or_insert_with(acp::Meta::new) .insert("modelId".into(), serde_json::json!(mid.0)); } if let Some(ref sid) = preferred_session_id { meta.get_or_insert_with(acp::Meta::new) .insert("sessionId".into(), serde_json::json!(sid)); } if is_chat_path { scrub_chat_workspace_bind_meta(&mut meta); } let preferred_for_preflight = preferred_session_id.clone(); tasks .spawn(async move { if let Some(ref sid) = preferred_for_preflight { let session_cwd_str = session_cwd.to_string_lossy(); if let Err(e) = crate::app::session_startup::ensure_session_id_available( sid, &session_cwd_str, ) { return TaskResult::SessionFailed { agent_id, error: sanitize_user_error(&e.to_string()), }; } } ulog::info( "session.create.start", None, Some(serde_json::json!({"mcp_server_count": mcp_count})), ); let create_start = std::time::Instant::now(); let result = acp_send( acp::NewSessionRequest::new(session_cwd.clone()) .mcp_servers(mcp_servers) .meta(meta), &tx, ) .await; let create_elapsed_ms = create_start.elapsed().as_millis() as u64; match result { Ok(resp) => { ulog::info( "session.create.done", Some(&resp.session_id.0), Some( serde_json::json!({ "elapsed_ms": create_elapsed_ms, "mcp_server_count": mcp_count, }), ), ); TaskResult::SessionCreated { agent_id, session_id: resp.session_id, models: resp.models, scheduler_background_loops: parse_session_scheduler_background_loops( resp.meta.as_ref(), ), } } Err(e) => { let error = e.to_string(); ulog::error( "session.create.failed", None, Some( serde_json::json!({ "elapsed_ms": create_elapsed_ms, "error": &error, }), ), ); TaskResult::SessionFailed { agent_id, error: sanitize_user_error(&error), } } } }); } Effect::CreateWorktreeSession { agent_id, load_session_id, label, git_ref, model_id, preferred_session_id, chat_kind, } => { let tx = acp_tx.clone(); let cwd = cwd.to_path_buf(); let mut meta = session_flags.to_meta(); if chat_kind || session_flags.chat_mode { meta.get_or_insert_with(acp::Meta::new) .insert( "x.ai/session".into(), serde_json::json!({ "kind": "chat" }), ); } if let Some(ref mid) = model_id { meta.get_or_insert_with(acp::Meta::new) .insert("modelId".into(), serde_json::json!(mid.0)); } if load_session_id.is_none() && let Some(ref sid) = preferred_session_id { meta.get_or_insert_with(acp::Meta::new) .insert("sessionId".into(), serde_json::json!(sid)); } let restore_code = session_flags.restore_code; let resume_local_miss = session_flags.resume_local_miss.clone(); tracing::info!( ?restore_code, ?load_session_id, ?git_ref, "CreateWorktreeSession: restore_code, load_session_id, git_ref" ); tasks .spawn(async move { if let Some(sid) = load_session_id { let local_miss = resume_local_miss .as_deref() .filter(|t| *t == sid); let resume_started = std::time::Instant::now(); let wt_type = xai_grok_shell::util::config::worktree_type(); let copy_mode = if git_ref.is_some() { "clean" } else { "dirty" }; let mut payload = serde_json::json!({ "sessionId": sid, "sourceCwd": cwd.to_string_lossy(), "copyMode": copy_mode, "worktreeType": wt_type, }); if let Some(rc) = restore_code { payload["restoreCode"] = serde_json::Value::Bool(rc); } if let Some(ref r) = git_ref { payload["gitRef"] = serde_json::Value::String(r.clone()); } let ext_req = acp::ExtRequest::new( "x.ai/git/worktree/resume_session", serde_json::value::to_raw_value(&payload) .expect("serialize resume params") .into(), ); let ext_resp = match acp_send(ext_req, &tx).await { Ok(resp) => { tracing::info!( session_id = %sid, elapsed_ms = resume_started.elapsed().as_millis() as u64, "worktree resume_session: ACP call completed" ); resp } Err(e) => { tracing::warn!( session_id = %sid, elapsed_ms = resume_started.elapsed().as_millis() as u64, error = %e, "worktree resume_session: ACP call failed" ); return TaskResult::WorktreeSessionFailed { agent_id, error: worktree_resume_failure_message( local_miss, &sanitize_user_error(&e.to_string()), ), }; } }; let resp_value: serde_json::Value = match serde_json::from_str( ext_resp.0.get(), ) { Ok(v) => v, Err(e) => { return TaskResult::WorktreeSessionFailed { agent_id, error: worktree_resume_failure_message( local_miss, &sanitize_user_error(&e.to_string()), ), }; } }; if let Some(err) = resp_value .get("error") .filter(|v| !v.is_null()) { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| err.to_string()); return TaskResult::WorktreeSessionFailed { agent_id, error: worktree_resume_failure_message( local_miss, &sanitize_user_error(&msg), ), }; } let result_obj = resp_value.get("result").unwrap_or(&resp_value); let new_session_id = result_obj .get("sessionId") .and_then(|v| v.as_str()) .unwrap_or(&sid); let wt_path = result_obj .get("worktreePath") .and_then(|v| v.as_str()) .map(PathBuf::from) .unwrap_or_else(|| cwd.clone()); let eff_cwd = result_obj .get("effectiveCwd") .and_then(|v| v.as_str()) .map(PathBuf::from) .unwrap_or_else(|| wt_path.clone()); let (code_restored, restore_summary, restore_degree) = parse_worktree_restore_payload( result_obj, ); return TaskResult::WorktreeForked { agent_id, session_id: acp::SessionId::new(new_session_id), worktree_path: wt_path, session_cwd: eff_cwd, code_restored, restore_summary, restore_degree, }; } let worktree_id = preferred_session_id .clone() .unwrap_or_else(|| { format!("pager-{}", &uuid::Uuid::new_v4().simple().to_string()[..12]) }); let copy_mode = if git_ref.is_some() { "clean" } else { "dirty" }; let mut params = serde_json::json!({ "sourceWorktreePath": cwd.to_string_lossy(), "newSessionId": worktree_id, "copyMode": copy_mode, }); if let Some(ref lbl) = label { params["label"] = serde_json::Value::String(lbl.clone()); } if let Some(ref r) = git_ref { params["gitRef"] = serde_json::Value::String(r.clone()); } let ext_req = acp::ExtRequest::new( "x.ai/git/worktree/create_from_worktree_sync", serde_json::value::to_raw_value(¶ms) .expect("serialize worktree params") .into(), ); let ext_resp = match acp_send(ext_req, &tx).await { Ok(resp) => resp, Err(e) => { return TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error( &format!("couldn't create worktree: {e}"), ), }; } }; let resp_value: serde_json::Value = match serde_json::from_str( ext_resp.0.get(), ) { Ok(v) => v, Err(e) => { return TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error( &format!("couldn't create worktree: {e}"), ), }; } }; if let Some(err) = resp_value.get("error") { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| err.to_string()); return TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error( &format!("couldn't create worktree: {msg}"), ), }; } let result_obj = resp_value.get("result").unwrap_or(&resp_value); let worktree_root = match result_obj .get("worktreePath") .and_then(|v| v.as_str()) { Some(p) => PathBuf::from(p), None => { return TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error( "couldn't create worktree: response missing worktreePath", ), }; } }; let session_cwd = if let Some(git_root) = result_obj .get("sourceGitRoot") .and_then(|v| v.as_str()) { let cwd_str = cwd.to_string_lossy(); if let Some(relative) = cwd_str.strip_prefix(git_root) { let relative = relative.trim_start_matches('/'); if relative.is_empty() { worktree_root.clone() } else { worktree_root.join(relative) } } else { worktree_root.clone() } } else { worktree_root.clone() }; if let Some(ref sid) = preferred_session_id { let session_cwd_str = session_cwd.to_string_lossy(); if let Err(e) = crate::app::session_startup::ensure_session_id_available( sid, &session_cwd_str, ) { return TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error(&e.to_string()), }; } } let mcp_servers = xai_grok_shell::util::config::load_mcp_servers( &session_cwd, &xai_grok_tools::types::compat::CompatConfig::default(), ); let result = acp_send( acp::NewSessionRequest::new(session_cwd.clone()) .mcp_servers(mcp_servers) .meta(meta), &tx, ) .await; match result { Ok(resp) => { TaskResult::WorktreeSessionCreated { agent_id, session_id: resp.session_id, worktree_path: worktree_root, session_cwd, models: resp.models, scheduler_background_loops: parse_session_scheduler_background_loops( resp.meta.as_ref(), ), } } Err(e) => { TaskResult::WorktreeSessionFailed { agent_id, error: sanitize_user_error( &format!( "couldn't create session in worktree: {e}" ), ), } } } }); } Effect::LoadSession { agent_id, session_id, session_cwd, chat_kind } => { let tx = acp_tx.clone(); let mut meta = session_flags.to_meta(); let is_chat_path = chat_kind || session_flags.chat_mode; if is_chat_path { apply_chat_kind_meta(&mut meta); scrub_chat_workspace_bind_meta(&mut meta); } if let Some(true) = session_flags.restore_code { meta.get_or_insert_with(acp::Meta::new) .insert("x.ai/restore_code".into(), serde_json::Value::Bool(true)); } let cwd = session_cwd.unwrap_or_else(|| cwd.to_path_buf()); let mcp_started = std::time::Instant::now(); let mcp_servers = xai_grok_shell::util::config::load_mcp_servers( &cwd, &xai_grok_tools::types::compat::CompatConfig::default(), ); tracing::info!( elapsed_ms = mcp_started.elapsed().as_millis() as u64, server_count = mcp_servers.len(), "load_session: mcp server discovery" ); let acp_session_id = acp::SessionId::new(session_id); tasks .spawn(async move { ulog::info("session.load.start", Some(&acp_session_id.0), None); let load_started = std::time::Instant::now(); let result = acp_send( acp::LoadSessionRequest::new( acp_session_id.clone(), cwd.clone(), ) .mcp_servers(mcp_servers.clone()) .meta(meta.clone()), &tx, ) .await; let load_elapsed_ms = load_started.elapsed().as_millis() as u64; tracing::info!( session_id = %acp_session_id.0, elapsed_ms = load_elapsed_ms, ok = result.is_ok(), "load_session: acp load_session completed" ); match result { Ok(resp) => { ulog::info( "session.load.done", Some(&acp_session_id.0), Some(serde_json::json!({"elapsed_ms": load_elapsed_ms})), ); let (code_restored, restore_summary, restore_degree) = parse_session_load_restore_meta( resp.meta.as_ref(), ); let running_prompt_id = parse_session_load_running_prompt_id( resp.meta.as_ref(), ); TaskResult::SessionLoaded { agent_id, session_id: acp_session_id, models: resp.models, code_restored, restore_summary, restore_degree, running_prompt_id, scheduler_background_loops: parse_session_scheduler_background_loops( resp.meta.as_ref(), ), } } Err(e) => { let error = e.to_string(); ulog::error( "session.load.failed", Some(&acp_session_id.0), Some( serde_json::json!({"elapsed_ms": load_elapsed_ms, "error": &error}), ), ); TaskResult::SessionLoadFailed { agent_id, session_id: acp_session_id, error: sanitize_user_error(&error), } } } }); } Effect::ScanForeignSessions { cwd, compat, grok_home, coordinator, seq } => { if coordinator.latest_seq() != seq { return (false, meta); } let semaphore = coordinator.semaphore(); let latest_seq = coordinator.latest_seq_handle(); let abort_handle = tasks .spawn(async move { let Ok(permit) = semaphore.acquire_owned().await else { return TaskResult::ForeignSessionsScanned { entries: Vec::new(), seq, }; }; if latest_seq.load(std::sync::atomic::Ordering::Acquire) != seq { return TaskResult::ForeignSessionsScanned { entries: Vec::new(), seq, }; } let enabled = crate::app::foreign_sessions::gated_sources_async( compat, &grok_home, ) .await; if latest_seq.load(std::sync::atomic::Ordering::Acquire) != seq || !(enabled.claude || enabled.codex || enabled.cursor) { return TaskResult::ForeignSessionsScanned { entries: Vec::new(), seq, }; } let summaries = tokio::task::spawn_blocking(move || { let _permit = permit; xai_grok_workspace::foreign_sessions::scan_foreign_sessions( &cwd, enabled, ) }) .await .unwrap_or_else(|error| { tracing::warn!(%error, "foreign session scan task failed"); Vec::new() }); let entries = summaries .into_iter() .map(crate::app::foreign_sessions::map_summary) .collect(); TaskResult::ForeignSessionsScanned { entries, seq, } }); coordinator.install_abort_handle(seq, abort_handle); } Effect::CanonicalizeForeignResumeCwd { requested_cwd, launch_token } => { tasks .spawn(async move { let cwd_for_task = requested_cwd.clone(); let canonical_cwd = tokio::task::spawn_blocking(move || { dunce::canonicalize(cwd_for_task).ok() }) .await .unwrap_or_else(|error| { tracing::warn!(%error, "foreign resume cwd canonicalization task failed"); None }); TaskResult::ForeignResumeCwdCanonicalized { requested_cwd, canonical_cwd, launch_token, } }); } Effect::DetectForeignResumeHint { canonical_cwd, compat, grok_home, launch_token, } => { tasks .spawn(async move { let cwd_for_scan = canonical_cwd.clone(); let recent = crate::app::foreign_sessions::with_gated_sources_async( compat, &grok_home, |enabled| async move { tokio::task::spawn_blocking(move || xai_grok_workspace::foreign_sessions::most_recent_foreign_session( &cwd_for_scan, enabled, crate::app::foreign_sessions::RESUME_HINT_WINDOW, )) .await .unwrap_or_else(|error| { tracing::warn!(%error, "foreign resume detection task failed"); None }) }, ) .await .flatten(); TaskResult::ForeignResumeHintDetected { canonical_cwd, launch_token, hint: recent, } }); } Effect::FetchSessionList { query, seq } => { let tx = acp_tx.clone(); let cwd = cwd.to_path_buf(); tasks .spawn(async move { let mut params = serde_json::json!({ "cwd": cwd.to_string_lossy(), "limit": 30, }); if let Some(q) = &query { params["query"] = serde_json::Value::String(q.clone()); } else { params["allowRelax"] = serde_json::Value::Bool(true); } let request = acp::ExtRequest::new( "x.ai/session/list", serde_json::value::to_raw_value(¶ms) .expect("serialize session list params") .into(), ); let result = acp_send(request, &tx).await; match result { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper.get("error") { return TaskResult::SessionListFailed { error: err.as_str().unwrap_or("unknown error").to_string(), seq, query, }; } let payload = wrapper.get("result").unwrap_or(&wrapper); let sessions = parse_session_picker_entries(payload); let partial = parse_session_list_partial(payload); let scope = parse_session_list_scope(payload); TaskResult::SessionListLoaded { sessions, partial, scope, seq, query, } } Err(e) => { TaskResult::SessionListFailed { error: sanitize_user_error(&format!("{e}")), seq, query, } } } }); } Effect::DebounceSessionSearch { query, seq } => { tasks .spawn(async move { tokio::time::sleep( std::time::Duration::from_millis(SESSION_SEARCH_DEBOUNCE_MS), ) .await; TaskResult::SessionSearchDebounceExpired { query, seq, } }); } Effect::FetchRoster => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/sessions/list", serde_json::value::to_raw_value(&serde_json::json!({})) .expect("serialize roster list params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let parsed = crate::app::roster::parse_roster_list_response( resp.0.get(), ); match parsed { Some(r) => { TaskResult::RosterLoaded { sessions: r.sessions, } } None => { tracing::warn!("failed to parse x.ai/sessions/list response"); TaskResult::RosterFailed { error: "parse error".to_string(), } } } } Err(e) => { TaskResult::RosterFailed { error: sanitize_user_error(&format!("{e}")), } } } }); } Effect::FetchDashboardSessions => { let tx = acp_tx.clone(); let cwd = cwd.to_path_buf(); tasks .spawn(async move { let params = serde_json::json!({ "cwd": cwd.to_string_lossy(), "limit": 30, }); let request = acp::ExtRequest::new( "x.ai/session/list", serde_json::value::to_raw_value(¶ms) .expect("serialize session list params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if wrapper.get("error").is_some() { return TaskResult::DashboardSessionsLoaded { sessions: vec![], }; } let payload = wrapper.get("result").unwrap_or(&wrapper); let sessions = parse_session_picker_entries(payload) .iter() .map(session_picker_entry_to_roster) .collect(); TaskResult::DashboardSessionsLoaded { sessions, } } Err(_) => { TaskResult::DashboardSessionsLoaded { sessions: vec![], } } } }); } Effect::RestoreAndLoadSession { agent_id, session_id, session_cwd: _ } => { use xai_grok_shell::agent::session_registry_client::SessionRegistryClient; use xai_grok_shell::session::restore::restore_session_with_storage; let setup_started = std::time::Instant::now(); let raw_config = xai_grok_shell::config::load_effective_config(); let setup = raw_config .ok() .and_then(|raw| { let cfg = xai_grok_shell::agent::config::Config::new_from_toml_cfg( &raw, ) .ok()?; let proxy_base = cfg.endpoints.proxy_url(); let deployment_key = cfg.endpoints.deployment_key.clone(); let alpha_test_key = cfg.endpoints.alpha_test_key.clone(); let auth_manager = crate::app::session_startup::pre_acp_auth_manager( &cfg, ); let registry = SessionRegistryClient::new(&proxy_base, String::new()) .with_deployment_key(deployment_key.clone()) .with_alpha_test_key(alpha_test_key.clone()) .with_session_id(session_id.clone()) .with_auth(auth_manager.clone()); let storage = xai_grok_shell::auth::credential_provider::build_storage_client_for_proxy( &proxy_base, deployment_key, alpha_test_key, Some(auth_manager.clone()), None, Some(session_id.clone()), "grok-pager", ); Some((auth_manager, registry, storage)) }); tracing::info!( elapsed_ms = setup_started.elapsed().as_millis() as u64, ok = setup.is_some(), "restore: auth/client setup" ); let target_cwd = cwd.to_path_buf(); let ptx = progress_tx.clone(); tasks .spawn(async move { let Some((auth_manager, registry_client, storage_client)) = setup else { return TaskResult::SessionRestoreFailed { agent_id, error: "Failed to load configuration.".into(), }; }; let _ = auth_manager.auth().await; let progress: Option< xai_grok_shell::session::restore::ProgressCallback, > = { use xai_grok_shell::session::restore::{PhaseStep, RestorePhase}; Some( Box::new(move |event| { let msg = match (event.phase, event.step) { (RestorePhase::Download, PhaseStep::Start) => { Some("Downloading session archives...".to_string()) } (RestorePhase::Download, PhaseStep::End) => { Some( format!( "Downloads finished ({}).", format_restore_elapsed(event.elapsed), ), ) } (RestorePhase::Codebase, PhaseStep::Start) => { Some("Restoring code...".to_string()) } (RestorePhase::Codebase, PhaseStep::End) => { event .detail .as_ref() .map(|detail| format!("Code restored ({detail}).")) } (RestorePhase::Memory, PhaseStep::Start) => { Some("Restoring memory...".to_string()) } (RestorePhase::SessionState, PhaseStep::Start) => { Some("Restoring session state...".to_string()) } (RestorePhase::SessionState, PhaseStep::End) => { event .detail .as_ref() .map(|detail| format!("Session state restored ({detail}).")) } (RestorePhase::Finalize, _) => { let elapsed_secs = event.elapsed.as_secs(); let status = if event.incomplete { "Restore incomplete" } else { "Restore complete" }; if elapsed_secs >= 60 { Some( format!( "{status} ({}m{:02}s).", elapsed_secs / 60, elapsed_secs % 60 ), ) } else { Some(format!("{status} ({elapsed_secs}s).")) } } _ => None, }; if let Some(text) = msg { let _ = ptx .send(RestoreProgressMsg { agent_id, message: text, }); } }), ) }; let cwd_str = target_cwd.to_string_lossy().to_string(); match restore_session_with_storage( ®istry_client, &storage_client, &session_id, &cwd_str, None, progress, ) .await { Ok(result) => { let effective_id = if result.local_session_id.is_empty() { session_id } else { result.local_session_id }; TaskResult::SessionRestored { agent_id, local_session_id: effective_id, } } Err(e) => { TaskResult::SessionRestoreFailed { agent_id, error: format!("{e:#}"), } } } }); } Effect::LoadCardDetail { source, session_id, cwd, generation } => { tasks .spawn(async move { use crate::app::app_view::CardDetail; let result_session_id = session_id.clone(); let detail = tokio::task::spawn_blocking(move || { let info = xai_grok_shell::session::info::Info { id: acp::SessionId::new(session_id), cwd, }; let history_path = xai_grok_shell::session::persistence::session_dir( &info, ) .join("chat_history.jsonl"); let first_prompt_preview = extract_first_user_prompt(&info) .unwrap_or_default(); let (turn_count, tool_call_count) = count_chat_history_stats( &history_path, ); CardDetail { turn_count, tool_call_count, first_prompt_preview, } }) .await .unwrap_or(CardDetail { turn_count: 0, tool_call_count: 0, first_prompt_preview: String::new(), }); TaskResult::CardDetailLoaded { source, session_id: result_session_id, generation, detail, } }); } Effect::SendPrompt { agent_id, session_id, text, prompt_id, skill_token_ranges, } => { let tx = acp_tx.clone(); let screen_mode = session_flags.screen_mode_label; let is_api_key_auth = session_flags.is_api_key_auth; tasks .spawn(async move { ulog::info( "prompt.acp_send.start", Some(&session_id.0), Some( serde_json::json!({ "kind": "text", "len": text.len(), "prompt_id": prompt_id, }), ), ); let send_start = std::time::Instant::now(); let prompt = vec![plain_prompt_content_block(text, &skill_token_ranges)]; let req = acp::PromptRequest::new(session_id.clone(), prompt) .meta( prompt_request_meta(&prompt_id, screen_mode) .as_object() .cloned(), ); let result = acp_send(req, &tx).await; let send_elapsed_ms = send_start.elapsed().as_millis() as u64; ulog::info( "prompt.acp_send.done", Some(&session_id.0), Some( serde_json::json!({ "kind": "text", "elapsed_ms": send_elapsed_ms, "ok": result.is_ok(), "prompt_id": prompt_id, }), ), ); log_prompt_result(&session_id, &result); let http_status = result .as_ref() .err() .and_then(http_status_from_error); TaskResult::PromptResponse { agent_id, result: result .map_err(|e| format_acp_error(&e, is_api_key_auth)), http_status, prompt_id: Some(prompt_id), } }); } Effect::SendPromptBlocks { agent_id, session_id, blocks, prompt_id } | Effect::SendPromptNow { agent_id, session_id, blocks, prompt_id } => { let send_now = effect_is_send_now; let tx = acp_tx.clone(); let screen_mode = session_flags.screen_mode_label; let is_api_key_auth = session_flags.is_api_key_auth; tasks .spawn(async move { ulog::info( "prompt.acp_send.start", Some(&session_id.0), Some( serde_json::json!({ "kind": if send_now { "send_now" } else { "blocks" }, "block_count": blocks.len(), "prompt_id": prompt_id, }), ), ); let send_start = std::time::Instant::now(); let mut meta = prompt_request_meta(&prompt_id, screen_mode); if send_now && let Some(map) = meta.as_object_mut() { map.insert("sendNow".into(), serde_json::Value::Bool(true)); } let requeue_blocks = send_now.then(|| blocks.clone()); let req = acp::PromptRequest::new(session_id.clone(), blocks) .meta(meta.as_object().cloned()); let result = acp_send(req, &tx).await; let send_elapsed_ms = send_start.elapsed().as_millis() as u64; ulog::info( "prompt.acp_send.done", Some(&session_id.0), Some( serde_json::json!({ "kind": if send_now { "send_now" } else { "blocks" }, "elapsed_ms": send_elapsed_ms, "ok": result.is_ok(), "prompt_id": prompt_id, }), ), ); log_prompt_result(&session_id, &result); if let (Some(blocks), Err(e)) = (requeue_blocks, &result) { return TaskResult::SendPromptNowFailed { agent_id, session_id, prompt_id, error: format_acp_error(e, is_api_key_auth), blocks, }; } let http_status = result .as_ref() .err() .and_then(http_status_from_error); TaskResult::PromptResponse { agent_id, result: result .map_err(|e| format_acp_error(&e, is_api_key_auth)), http_status, prompt_id: Some(prompt_id), } }); } Effect::SendBashCommand { agent_id, session_id, command, prompt_id } => { let tx = acp_tx.clone(); let screen_mode = session_flags.screen_mode_label; tasks .spawn(async move { use xai_grok_shell::extensions::prompt_meta::PromptBlockMeta; ulog::info( "prompt.acp_send.start", Some(&session_id.0), Some( serde_json::json!({ "kind": "bash", "len": command.len(), "prompt_id": prompt_id, }), ), ); let send_start = std::time::Instant::now(); let meta = PromptBlockMeta::bash(&command); let prompt = vec![acp::ContentBlock::Text( acp::TextContent::new(command).meta( serde_json::to_value(&meta) .expect("PromptBlockMeta serializes") .as_object() .cloned(), ), )]; let req = acp::PromptRequest::new(session_id.clone(), prompt) .meta( prompt_request_meta(&prompt_id, screen_mode) .as_object() .cloned(), ); let result = acp_send(req, &tx).await; let send_elapsed_ms = send_start.elapsed().as_millis() as u64; ulog::info( "prompt.acp_send.done", Some(&session_id.0), Some( serde_json::json!({ "kind": "bash", "elapsed_ms": send_elapsed_ms, "ok": result.is_ok(), "prompt_id": prompt_id, }), ), ); log_prompt_result(&session_id, &result); let http_status = result .as_ref() .err() .and_then(http_status_from_error); TaskResult::PromptResponse { agent_id, result: result.map_err(|e| e.to_string()), http_status, prompt_id: Some(prompt_id), } }); } Effect::CancelTurn { session_id, cancel_subagents, trigger, rewind_if_pristine, } => { let tx = acp_tx.clone(); let trigger_str = trigger.map(|t| t.as_wire_str()); tasks .spawn(async move { ulog::info( "cancel.acp_send.start", Some(&session_id.0), Some( serde_json::json!({ "cancel_subagents": cancel_subagents, "trigger": trigger_str, "rewind_if_pristine": rewind_if_pristine, }), ), ); let send_start = std::time::Instant::now(); let mut meta = serde_json::json!({ "cancelSubagents": cancel_subagents }); if let Some(t) = trigger_str { meta["cancelTrigger"] = t.into(); } if rewind_if_pristine { meta["rewindIfPristine"] = true.into(); } let req = acp::CancelNotification::new(session_id.clone()) .meta(meta.as_object().cloned()); let result = acp_send(req, &tx).await; ulog::info( "cancel.acp_send.done", Some(&session_id.0), Some( serde_json::json!({ "ok": result.is_ok(), "elapsed_ms": send_start.elapsed().as_millis() as u64, }), ), ); if let Err(e) = result { tracing::warn!("Failed to send cancel notification: {e}"); } TaskResult::CancelComplete }); } Effect::TogglePlanMode { session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let notification = acp::ExtNotification::new( "x.ai/toggle_plan_mode", serde_json::value::to_raw_value(¶ms) .expect("serialize toggle_plan_mode params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send toggle_plan_mode notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueRemove { session_id, id, expected_version } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "id": id, "expectedVersion": expected_version, }); let notification = acp::ExtNotification::new( "x.ai/queue/remove", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/remove params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/remove notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueReorder { session_id, ordered_ids } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "orderedIds": ordered_ids, }); let notification = acp::ExtNotification::new( "x.ai/queue/reorder", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/reorder params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/reorder notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueClear { session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let notification = acp::ExtNotification::new( "x.ai/queue/clear", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/clear params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/clear notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueEdit { session_id, id, new_text } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "id": id, "newText": new_text, }); let notification = acp::ExtNotification::new( "x.ai/queue/edit", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/edit params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/edit notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueHoldEdit { session_id, id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "id": id, }); let notification = acp::ExtNotification::new( "x.ai/queue/hold_edit", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/hold_edit params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/hold_edit notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueReleaseEdit { session_id, id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "id": id, }); let notification = acp::ExtNotification::new( "x.ai/queue/release_edit", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/release_edit params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/release_edit notification: {e}"); } TaskResult::CancelComplete }); } Effect::QueueInterject { session_id, id, expected_version, new_text } => { let tx = acp_tx.clone(); tasks .spawn(async move { let mut params = serde_json::json!({ "sessionId": session_id.0.to_string(), "id": id, "expectedVersion": expected_version, }); if let Some(new_text) = new_text { params["newText"] = serde_json::Value::String(new_text); } let notification = acp::ExtNotification::new( "x.ai/queue/interject", serde_json::value::to_raw_value(¶ms) .expect("serialize queue/interject params") .into(), ); if let Err(e) = acp_send(notification, &tx).await { tracing::warn!("Failed to send queue/interject notification: {e}"); } TaskResult::CancelComplete }); } Effect::SetSessionMode { session_id, mode_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let req = acp::SetSessionModeRequest::new(session_id, mode_id); if let Err(e) = acp_send(req, &tx).await { tracing::warn!("Failed to set session mode: {e}"); } TaskResult::CancelComplete }); } Effect::SetModeThenPrompt { session_id, mode_id, agent_id, text, prompt_id, skill_token_ranges, } => { let tx = acp_tx.clone(); let screen_mode = session_flags.screen_mode_label; let is_api_key_auth = session_flags.is_api_key_auth; tasks .spawn(async move { let mode_req = acp::SetSessionModeRequest::new( session_id.clone(), mode_id, ); if let Err(e) = acp_send(mode_req, &tx).await { tracing::warn!("Failed to set session mode: {e}"); } ulog::info( "prompt submitted", Some(&session_id.0), Some(serde_json::json!({"len": text.len()})), ); let prompt = vec![plain_prompt_content_block(text, &skill_token_ranges)]; let req = acp::PromptRequest::new(session_id.clone(), prompt) .meta( prompt_request_meta(&prompt_id, screen_mode) .as_object() .cloned(), ); let result = acp_send(req, &tx).await; log_prompt_result(&session_id, &result); let http_status = result .as_ref() .err() .and_then(http_status_from_error); TaskResult::PromptResponse { agent_id, result: result .map_err(|e| format_acp_error(&e, is_api_key_auth)), http_status, prompt_id: Some(prompt_id), } }); } Effect::Compact { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let req = acp::ExtRequest::new( "x.ai/compact_conversation", serde_json::value::to_raw_value(¶ms) .expect("serialize compact params") .into(), ); let result = acp_send(req, &tx).await; TaskResult::CompactComplete { agent_id, result: result .map(|_| ()) .map_err(|e| sanitize_user_error(&e.to_string())), } }); } Effect::FetchPromptHistory { agent_id, cwd, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "cwd": cwd.to_string_lossy(), "filter_session_id": session_id, }); let req = acp::ExtRequest::new( "x.ai/prompt_history", serde_json::value::to_raw_value(¶ms) .expect("serialize prompt_history params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { let resp_value: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let prompts = resp_value .get("result") .and_then(|r| r.get("prompts")) .or_else(|| resp_value.get("prompts")) .and_then(|v| v.as_array()) .map(|arr| { arr .iter() .filter_map(|v| v.as_str().map(String::from)) .collect::>() }) .unwrap_or_default(); TaskResult::PromptHistoryLoaded { agent_id, prompts, } } Err(e) => { tracing::warn!("Failed to fetch prompt history: {e}"); TaskResult::PromptHistoryLoaded { agent_id, prompts: Vec::new(), } } } }); } Effect::KillBgTask { session_id, task_id } => { let tx = acp_tx.clone(); let sid = session_id.0.to_string(); tasks .spawn(async move { let params = xai_grok_shell::extensions::task::KillTaskRequest { session_id: sid.clone(), task_id: task_id.clone(), }; let req = acp::ExtRequest::new( "x.ai/task/kill", serde_json::value::to_raw_value(¶ms) .expect("serialize kill params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { let outcome = parse_kill_outcome(resp.0.get()); TaskResult::BgTaskKilled { session_id: sid, task_id, outcome, } } Err(e) => { TaskResult::BgTaskKillFailed { session_id: sid, task_id, error: sanitize_user_error(&e.to_string()), } } } }); } Effect::KillSubagent { session_id, subagent_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "subagentId": &subagent_id, }); let req = acp::ExtRequest::new( "x.ai/subagent/cancel", serde_json::value::to_raw_value(¶ms) .expect("serialize cancel params") .into(), ); let outcome = match acp_send(req, &tx).await { Ok(resp) => parse_subagent_kill_outcome(resp.0.get()), Err(e) => { tracing::warn!("Failed to cancel subagent: {e}"); SubagentKillOutcome::RpcFailed } }; TaskResult::KillSubagentComplete { session_id, subagent_id, outcome, } }); } Effect::DeleteScheduledTask { session_id, task_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "taskId": task_id, }); let req = acp::ExtRequest::new( "x.ai/scheduler/delete", serde_json::value::to_raw_value(¶ms) .expect("serialize scheduler delete params") .into(), ); if let Err(e) = acp_send(req, &tx).await { tracing::warn!(task_id, "Failed to delete scheduled task: {e}"); } TaskResult::CancelComplete }); } Effect::DemoteToBackground { session_id, tool_call_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "terminalId": tool_call_id, }); let req = acp::ExtRequest::new( "x.ai/terminal/background", serde_json::value::to_raw_value(¶ms) .expect("serialize background params") .into(), ); if let Err(e) = acp_send(req, &tx).await { tracing::warn!("Failed to send background request: {e}"); } TaskResult::CancelComplete }); } Effect::SwitchModel { agent_id, session_id, model_id, effort, prev_model_id, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let meta = effort .map(|eff| { use xai_grok_shell::sampling::types::{ REASONING_EFFORT_META_KEY, reasoning_effort_meta_value, }; let mut m = acp::Meta::new(); m.insert( REASONING_EFFORT_META_KEY.to_string(), reasoning_effort_meta_value(eff), ); m }); let req = acp::SetSessionModelRequest::new( session_id, model_id.clone(), ) .meta(meta); let result = acp_send(req, &tx) .await .map(|_| ()) .map_err(|e| { use xai_grok_shell::agent::config::ModelSwitchIncompatibleAgentError; if let Some(typed) = ModelSwitchIncompatibleAgentError::from_acp_error( &e, ) { SwitchModelError::IncompatibleAgent { error: typed, prev_model_id: prev_model_id.clone(), } } else { SwitchModelError::Other(sanitize_user_error(&e.to_string())) } }); TaskResult::SwitchModelComplete { agent_id, model_id, effort, result, prev_model_id, } }); } Effect::ProbeClipboardAttachment { ctx, change_count } => { tasks .spawn(async move { let probe_target = ctx.target.clone(); let probe_text = ctx.source.text().map(str::to_owned); let probe_bracketed = ctx.source.is_bracketed(); let probe = tokio::task::spawn_blocking(move || { if change_count.is_some() && crate::clipboard::clipboard_change_count() != change_count { return (ProbedAttachment::ProbeDropped, None); } if probe_bracketed && crate::terminal::terminal_context() .brand .delivers_ime_as_bracketed_paste() { match crate::clipboard::bracketed_payload_came_from_clipboard_result( probe_text.as_deref().unwrap_or(""), ) { Ok(true) => {} Ok(false) => return (ProbedAttachment::ProbeDropped, None), Err(_) => return (ProbedAttachment::ProbeFailed, None), } } let (image_data, file_urls) = match crate::clipboard::system_clipboard_probe_attachments( probe_text.as_deref(), ) { Ok(probe) => probe, Err(_) => return (ProbedAttachment::ProbeFailed, None), }; let image = match image_data { Some(data) => { let mut pasted = crate::prompt_images::from_clipboard_data( &data, ); pasted.prepare_preview_blocking(); match &probe_target { ClipboardPasteTarget::AgentPrompt { images_dir: Some(dir), .. } => { match crate::prompt_images::persist_to_session( &mut pasted, dir, ) { Ok(()) => ProbedAttachment::Image(pasted), Err(e) => ProbedAttachment::PersistFailed(e.to_string()), } } ClipboardPasteTarget::AgentPrompt { images_dir: None, .. } => ProbedAttachment::Image(pasted), ClipboardPasteTarget::DashboardDispatch | ClipboardPasteTarget::DashboardPeek { .. } => { ProbedAttachment::Image(pasted) } } } None => ProbedAttachment::NoRaster, }; (image, file_urls) }); let (image, file_urls) = match tokio::time::timeout( std::time::Duration::from_secs(CLIPBOARD_PROBE_TIMEOUT_SECS), probe, ) .await { Ok(Ok(pair)) => pair, Ok(Err(e)) => { tracing::warn!(error = %e, "clipboard attachment probe task failed"); (ProbedAttachment::ProbeFailed, None) } Err(_elapsed) => { tracing::warn!("clipboard attachment probe timed out"); (ProbedAttachment::ProbeFailed, None) } }; TaskResult::ClipboardAttachmentProbed { ctx, image, file_urls, } }); } Effect::PreparePromptImagePreview { preparation } => { tasks .spawn(async move { let preview = preparation.preview(); if tokio::task::spawn_blocking(move || preparation.run()) .await .is_err() { preview.mark_failed(); } TaskResult::PromptImagePreviewPrepared }); } Effect::PlanDoctorFix { target, report, terminal, request } => { tasks .spawn(async move { let result = tokio::task::spawn_blocking(move || match request { crate::slash::command::DoctorRequest::ListFixes => { Ok( actions::DoctorPlanningOutcome::Listing( crate::diagnostics::format_applicable_automatic_fixes( &report, &terminal, ), ), ) } crate::slash::command::DoctorRequest::Fix(id) => { match crate::diagnostics::select_fix_plan( id, &report, &terminal, ) { Ok(Some(plan)) => { Ok(actions::DoctorPlanningOutcome::Plan(Box::new(plan))) } Ok(None) => { Ok( actions::DoctorPlanningOutcome::RunLocally( crate::diagnostics::human_fix_command(id) .unwrap_or_else(|| id.to_string()), ), ) } Err(error) => Err(error.to_string()), } } crate::slash::command::DoctorRequest::Report => { unreachable!("report does not enter the planning effect") } }) .await .map_err(|error| format!("Could not prepare the fix: {error}")) .and_then(|result| result); TaskResult::DoctorFixPlanned { target, result, } }); } Effect::ApplyDoctorFix { target, plan } => { tasks .spawn(async move { let result = tokio::task::spawn_blocking(move || crate::diagnostics::apply_fix( *plan, )) .await .map_err(|error| format!("Could not apply the fix: {error}")) .and_then(|result| result.map_err(|error| error.to_string())); TaskResult::DoctorFixApplied { target, result, } }); } Effect::FetchChangelog => { tasks .spawn(async move { let changelog = tokio::task::spawn_blocking(|| { xai_grok_shell::util::changelog::ChangelogManager::new() .fetch() }) .await .unwrap_or_else(|e| { tracing::warn!(error = %e, "changelog fetch task failed"); xai_grok_shell::util::changelog::Changelog { markdown: None, entries: None, } }); TaskResult::ChangelogFetched { markdown: changelog.markdown, entries: changelog.entries.unwrap_or_default(), } }); } Effect::PersistAnnouncementsHidden { hidden_ids } => { tasks .spawn(async move { xai_grok_announcements::write_hidden_announcement_ids(&hidden_ids) .await; TaskResult::AnnouncementsHiddenPersisted { result: Ok(()), } }); } Effect::PersistPrivacyBannerAcked { acked_at } => { tasks .spawn(async move { if let Err(e) = xai_grok_shell::util::config::set_privacy_banner_acked( acked_at, ) .await { tracing::warn!(error = %e, "failed to persist privacy_banner_acked"); } TaskResult::CancelComplete }); } Effect::PersistMemoryFullscreen { fullscreen } => { persist_hint( tasks, "memory_modal_fullscreen", fullscreen, "memory fullscreen", ); } Effect::PersistProjectPickerDisabled { disabled } => { persist_hint( tasks, "project_picker_disabled", disabled, "project picker opt-out", ); } Effect::PersistDashboard(persisted) => { tasks .spawn(async move { let result = tokio::task::spawn_blocking(move || { if let Err(e) = crate::views::dashboard::state::write_persisted( &persisted, ) { tracing::warn!(error = %e, "failed to persist dashboard config"); } }) .await; if let Err(e) = result { tracing::warn!(error = %e, "failed to persist dashboard: join error"); } TaskResult::CancelComplete }); } Effect::PersistWorktreeMode { mode, config_key } => { debug_assert!( config_key == "fork_worktree_mode" || config_key == "new_session_worktree_mode", "unexpected worktree config_key: {config_key}" ); persist_hint(tasks, config_key, mode.as_config_str(), "worktree mode"); } Effect::PersistPreferredModel { model_id, reasoning_effort } => { let model_id_str = model_id.0.to_string(); tasks .spawn(async move { let result = xai_grok_shell::util::config::persist_models_default( Some(model_id_str), reasoning_effort, ) .await .map_err(|e| e.to_string()); if let Err(ref e) = result { tracing::warn!("failed to save default model preference: {e}"); } TaskResult::PreferredModelPersisted { result, } }); } Effect::PersistPermissionMode { canonical, session_id, persist } => { let tx = acp_tx.clone(); tasks .spawn( persist_permission_mode_and_notify( canonical, session_id, persist, tx, ), ); } Effect::PersistSetting { key, value, rollback_value } => { tasks .spawn(async move { match persist_setting(key, value.clone()).await { Ok(()) => { TaskResult::SettingPersisted { key, value, } } Err(error) => { TaskResult::SettingPersistFailed { key, rollback_value, error, } } } }); } Effect::Authenticate { request_seq, method_id, use_oauth, force_interactive, } => { let tx = acp_tx.clone(); let abort_handle = tasks .spawn(async move { send_authenticate( &tx, request_seq, method_id, use_oauth, force_interactive, ) .await }); meta.auth_abort_handle = Some((request_seq, abort_handle)); } Effect::PollAuthUrl { request_seq } => { let tx = acp_tx.clone(); let abort_handle = tasks .spawn(async move { let mut auth_url: Option = None; let mut external = false; let mut mode: Option = None; for i in 0..60 { if i > 0 { tokio::time::sleep(std::time::Duration::from_millis(50)) .await; } let params = serde_json::json!({}); let req = acp::ExtRequest::new( "x.ai/auth/get_url", serde_json::value::to_raw_value(¶ms) .expect("serialize auth_url params") .into(), ); if let Ok(resp) = acp_send(req, &tx).await { let v: serde_json::Value = serde_json::from_str(resp.0.get()) .unwrap_or_default(); external = v .get("external_provider") .and_then(|v| v.as_bool()) .unwrap_or(false); mode = v .get("mode") .and_then(|v| v.as_str()) .map(|s| s.to_string()); auth_url = v .get("auth_url") .and_then(|v| v.as_str()) .map(|s| s.to_string()); } if auth_url.is_some() { break; } } TaskResult::AuthUrlReady { request_seq, auth_url, external, mode, } }); meta.auth_url_poll_handle = Some((request_seq, abort_handle)); } Effect::SubmitAuthCode { request_seq, code } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "code": code }); let req = acp::ExtRequest::new( "x.ai/auth/submit_code", serde_json::value::to_raw_value(¶ms) .expect("serialize auth code params") .into(), ); match acp_send(req, &tx).await { Ok(_) => { TaskResult::AuthCodeSubmitted { request_seq, } } Err(e) => { let error = e.to_string(); ulog::error( "auth failed", None, Some(serde_json::json!({"error": &error})), ); TaskResult::AuthFailed { request_seq, error, } } } }); } Effect::FetchMcpsList { agent_id, session_id, cache } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "cache": cache, }); let req = acp::ExtRequest::new( "x.ai/mcp/list", serde_json::value::to_raw_value(¶ms) .expect("serialize mcp/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< crate::views::mcps_modal::McpsListResponse, >(inner.clone()) .map(crate::views::mcps_modal::convert_list_response) .map_err(|_| "couldn't load server list".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't load server list: {e}" ), ), ) } }; TaskResult::McpsListLoaded { agent_id, result, } }); } Effect::McpAuthTrigger { agent_id, session_id, server_name } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "session_id": session_id.0.to_string(), "server_name": server_name, }); let req = acp::ExtRequest::new( "x.ai/mcp/auth_trigger", serde_json::value::to_raw_value(¶ms) .expect("serialize mcp/auth_trigger params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result_obj = wrapper.get("result"); let status = result_obj .and_then(|r| r.get("status")) .and_then(|s| s.as_str()) .unwrap_or("unknown"); if status == "authenticated" { Ok( crate::app::actions::McpAuthTriggerOutcome::Authenticated, ) } else if status == "setup_required" { let setup = result_obj .and_then(|r| r.get("setup")) .cloned() .and_then(|value| { serde_json::from_value::< crate::views::mcps_modal::McpSetupConfig, >(value) .ok() }) .ok_or_else(|| "setup required".to_string()); setup .map( crate::app::actions::McpAuthTriggerOutcome::SetupRequired, ) } else { let detail = result_obj .and_then(|r| r.get("error")) .and_then(|e| e.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("auth status: {status}")); Err(detail) } } Err(e) => { Err( sanitize_user_error(&format!("authentication failed: {e}")), ) } }; TaskResult::McpAuthTriggerDone { agent_id, server_name, result, } }); } Effect::McpSetupSubmit { agent_id, session_id, server_name, values } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), "serverName": server_name, "values": values, }); let req = acp::ExtRequest::new( "x.ai/mcp/setup", serde_json::value::to_raw_value(¶ms) .expect("serialize mcp/setup params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result_obj = wrapper.get("result"); if result_obj .and_then(|r| r.get("ok")) .and_then(|ok| ok.as_bool()) .unwrap_or(false) { Ok(()) } else { let detail = result_obj .and_then(|r| r.get("error")) .and_then(|e| e.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| "setup failed".to_string()); Err(detail) } } Err(e) => Err(sanitize_user_error(&format!("setup failed: {e}"))), }; TaskResult::McpSetupSubmitDone { agent_id, server_name, result, } }); } Effect::FetchHooksList { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let req = acp::ExtRequest::new( "x.ai/hooks/list", serde_json::value::to_raw_value(¶ms) .expect("serialize hooks/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::HooksListResponse, >(inner.clone()) .map_err(|_| "couldn't load hooks".to_string()) } Err(e) => { Err( sanitize_user_error(&format!("couldn't load hooks: {e}")), ) } }; TaskResult::HooksListLoaded { agent_id, result, } }); } Effect::FetchPluginsList { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let req = acp::ExtRequest::new( "x.ai/plugins/list", serde_json::value::to_raw_value(¶ms) .expect("serialize plugins/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::PluginsListResponse, >(inner.clone()) .map_err(|_| "couldn't load plugins".to_string()) } Err(e) => { Err( sanitize_user_error(&format!("couldn't load plugins: {e}")), ) } }; TaskResult::PluginsListLoaded { agent_id, result, } }); } Effect::HooksAction { agent_id, session_id, action } => { let tx = acp_tx.clone(); tasks .spawn(async move { let req_body = xai_hooks_plugins_types::HooksActionRequest { session_id: session_id.0.to_string(), action, }; let req = acp::ExtRequest::new( "x.ai/hooks/action", serde_json::value::to_raw_value(&req_body) .expect("serialize hooks/action params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .map_err(|_| "couldn't complete hooks action".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't complete hooks action: {e}" ), ), ) } }; TaskResult::HooksActionResult { agent_id, result, } }); } Effect::PluginsAction { agent_id, session_id, action } => { let tx = acp_tx.clone(); tasks .spawn(async move { let req_body = xai_hooks_plugins_types::PluginsActionRequest { session_id: session_id.0.to_string(), action, }; let req = acp::ExtRequest::new( "x.ai/plugins/action", serde_json::value::to_raw_value(&req_body) .expect("serialize plugins/action params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .map_err(|_| "couldn't complete plugins action".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't complete plugins action: {e}" ), ), ) } }; TaskResult::PluginsActionResult { agent_id, result, } }); } Effect::FetchMarketplaceList { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let req = acp::ExtRequest::new( "x.ai/marketplace/list", serde_json::value::to_raw_value(¶ms) .expect("serialize marketplace/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::MarketplaceListResponse, >(inner.clone()) .map_err(|_| "couldn't load marketplace".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't load marketplace: {e}" ), ), ) } }; TaskResult::MarketplaceListLoaded { agent_id, result, } }); } Effect::FetchPluginCtaCatalog { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let req = acp::ExtRequest::new( "x.ai/marketplace/list", serde_json::value::to_raw_value(¶ms) .expect("serialize marketplace/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::MarketplaceListResponse, >(inner.clone()) .map_err(|_| "couldn't load marketplace".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't load marketplace: {e}" ), ), ) } }; TaskResult::PluginCtaCatalogLoaded { agent_id, result, } }); } Effect::FetchSkillsList { agent_id, session_id: _ } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "cwd": "." }); let req = acp::ExtRequest::new( "x.ai/skills/list", serde_json::value::to_raw_value(¶ms) .expect("serialize skills/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< Vec< xai_grok_tools::implementations::skills::types::SkillInfo, >, >(inner.get("skills").cloned().unwrap_or_default()) .map_err(|_| "couldn't load skills".to_string()) } Err(e) => { Err( sanitize_user_error(&format!("couldn't load skills: {e}")), ) } }; TaskResult::SkillsListLoaded { agent_id, result, } }); } Effect::FetchWorkflowsList { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id }); let req = acp::ExtRequest::new( "x.ai/workflows/list", serde_json::value::to_raw_value(¶ms) .expect("serialize workflows/list params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< Vec, >(inner.get("workflows").cloned().unwrap_or_default()) .map_err(|_| "couldn't load workflows".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't load workflows: {e}" ), ), ) } }; TaskResult::WorkflowsListLoaded { agent_id, session_id, result, } }); } Effect::ToggleSkill { agent_id, session_id: _, skill_name, enabled } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "name": skill_name, "enabled": enabled, "cwd": ".", }); let req = acp::ExtRequest::new( "x.ai/skills/toggle", serde_json::value::to_raw_value(¶ms) .expect("serialize skills/toggle params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); let parsed = serde_json::from_value::< Vec< xai_grok_tools::implementations::skills::types::SkillInfo, >, >(inner.get("skills").cloned().unwrap_or_default()) .map_err(|_| "couldn't toggle skill".to_string()); if parsed.is_ok() { let refresh = acp::ExtRequest::new( "x.ai/skills/refresh-baseline", serde_json::value::to_raw_value(&serde_json::json!({})) .expect("serialize empty params") .into(), ); let _ = acp_send(refresh, &tx).await; } parsed } Err(e) => { Err( sanitize_user_error(&format!("couldn't toggle skill: {e}")), ) } }; TaskResult::SkillsToggleDone { agent_id, result, } }); } Effect::CheckMarketplaceUpdates { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id.0.to_string(), }); let list_req = acp::ExtRequest::new( "x.ai/marketplace/list", serde_json::value::to_raw_value(¶ms) .expect("serialize marketplace/list params") .into(), ); let outdated = match acp_send(list_req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::MarketplaceListResponse, >(inner.clone()) .ok() .map(|r| { r .sources .iter() .flat_map(|s| { let source_url = s.source_url_or_path.clone(); s.plugins .iter() .filter_map(move |p| { if p.install_status == "update_available" { Some(( p.name.clone(), p.installed_version.clone().unwrap_or_else(|| "?".into()), p.version.clone().unwrap_or_else(|| "?".into()), source_url.clone(), p.relative_path.clone(), )) } else { None } }) }) .collect::>() }) .unwrap_or_default() } Err(_) => Vec::new(), }; if outdated.is_empty() { return TaskResult::MarketplaceUpdatesAvailable { agent_id, updates: Vec::new(), }; } let mut succeeded = Vec::new(); for (name, old, new, source_url, rel_path) in outdated { let action = xai_hooks_plugins_types::MarketplaceAction::Update { source_url_or_path: source_url.clone(), plugin_relative_path: rel_path.clone(), }; let req_body = xai_hooks_plugins_types::MarketplaceActionRequest { session_id: session_id.0.to_string(), action, }; let update_req = acp::ExtRequest::new( "x.ai/marketplace/action", serde_json::value::to_raw_value(&req_body) .expect("serialize marketplace/action params") .into(), ); let update_succeeded = match acp_send(update_req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .is_ok_and(|outcome| marketplace_outcome_succeeded( &outcome, )) } Err(_) => false, }; if update_succeeded { succeeded.push((name, old, new)); } } if !succeeded.is_empty() { let notify_params = serde_json::json!({ "sessionId": session_id.0.to_string(), "updates": succeeded, }); let notify_req = acp::ExtRequest::new( "x.ai/plugins/notify-updates", serde_json::value::to_raw_value(¬ify_params) .expect("serialize notify-updates params") .into(), ); let _ = acp_send(notify_req, &tx).await; } TaskResult::MarketplaceUpdatesAvailable { agent_id, updates: succeeded, } }); } Effect::MarketplaceAction { agent_id, session_id, action } => { let tx = acp_tx.clone(); tasks .spawn(async move { let req_body = xai_hooks_plugins_types::MarketplaceActionRequest { session_id: session_id.0.to_string(), action, }; let req = acp::ExtRequest::new( "x.ai/marketplace/action", serde_json::value::to_raw_value(&req_body) .expect("serialize marketplace/action params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .map_err(|e| { tracing::debug!("failed to parse marketplace action response: {e}"); "couldn't complete marketplace action".to_string() }) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't complete marketplace action: {e}" ), ), ) } }; TaskResult::MarketplaceActionResult { agent_id, result, } }); } Effect::InstallPluginFromCta { agent_id, session_id, source_url_or_path, plugin_relative_path, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let plugin_name = plugin_relative_path .rsplit('/') .next() .unwrap_or(plugin_relative_path.as_str()) .to_string(); let action = xai_hooks_plugins_types::MarketplaceAction::Install { source_url_or_path, plugin_relative_path, }; let req_body = xai_hooks_plugins_types::MarketplaceActionRequest { session_id: session_id.0.to_string(), action, }; let req = acp::ExtRequest::new( "x.ai/marketplace/action", serde_json::value::to_raw_value(&req_body) .expect("serialize marketplace/action params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .map_err(|e| { tracing::debug!("failed to parse marketplace action response: {e}"); "couldn't complete marketplace action".to_string() }) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't complete marketplace action: {e}" ), ), ) } }; TaskResult::CtaPluginInstallDone { agent_id, plugin_name, result, } }); } Effect::ReloadPluginsForCta { agent_id, session_id, plugin_name } => { let tx = acp_tx.clone(); tasks .spawn(async move { let req_body = xai_hooks_plugins_types::PluginsActionRequest { session_id: session_id.0.to_string(), action: xai_hooks_plugins_types::PluginsAction::Reload, }; let req = acp::ExtRequest::new( "x.ai/plugins/action", serde_json::value::to_raw_value(&req_body) .expect("serialize plugins/action params") .into(), ); let result = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< xai_hooks_plugins_types::ActionOutcome, >(inner.clone()) .map_err(|_| "couldn't complete plugins action".to_string()) } Err(e) => { Err( sanitize_user_error( &format!( "couldn't complete plugins action: {e}" ), ), ) } }; TaskResult::CtaPluginReloadDone { agent_id, plugin_name, result, } }); } Effect::FetchPluginCtaMcps { agent_id, session_id, plugin_name } => { let tx = acp_tx.clone(); tasks.spawn(fetch_plugin_cta_mcps(agent_id, session_id, plugin_name, tx)); } Effect::RetryPluginCtaMcps { agent_id, session_id, plugin_name } => { let tx = acp_tx.clone(); tasks .spawn(async move { tokio::time::sleep( std::time::Duration::from_millis(CTA_MCP_RETRY_DELAY_MS), ) .await; fetch_plugin_cta_mcps(agent_id, session_id, plugin_name, tx).await }); } Effect::DismissCtaInstalled { agent_id, plugin_name } => { tasks .spawn(async move { tokio::time::sleep( std::time::Duration::from_millis(CTA_INSTALLED_DISMISS_MS), ) .await; TaskResult::CtaInstalledDismissTimeout { agent_id, plugin_name, } }); } Effect::UpsertMcpServer { agent_id, session_id, name, config } => { let tx = acp_tx.clone(); tasks .spawn(async move { #[derive(serde::Serialize)] struct McpUpsertRequest { session_id: String, server_name: String, #[serde(flatten)] config: xai_grok_shell::util::config::McpServerConfig, } let req_body = McpUpsertRequest { session_id: session_id.0.to_string(), server_name: name, config: *config, }; let req = acp::ExtRequest::new( "x.ai/mcp/upsert", serde_json::value::to_raw_value(&req_body) .expect("serialize mcp/upsert params") .into(), ); let result = match acp_send(req, &tx).await { Ok(_) => Ok(()), Err(e) => { Err( sanitize_user_error( &format!( "couldn't save server config: {e}" ), ), ) } }; TaskResult::McpToggleDone { agent_id, result, } }); } Effect::DeleteMcpServer { agent_id, session_id, server_name } => { let tx = acp_tx.clone(); tasks .spawn(async move { #[derive(serde::Serialize)] struct McpDeleteRequest { session_id: String, server_name: String, } let req_body = McpDeleteRequest { session_id: session_id.0.to_string(), server_name, }; let req = acp::ExtRequest::new( "x.ai/mcp/delete", serde_json::value::to_raw_value(&req_body) .expect("serialize mcp/delete params") .into(), ); let result = match acp_send(req, &tx).await { Ok(_) => Ok(()), Err(e) => { Err( sanitize_user_error(&format!("couldn't delete server: {e}")), ) } }; TaskResult::McpToggleDone { agent_id, result, } }); } Effect::ToggleMcpServer { agent_id, session_id, server_name, enabled } => { let tx = acp_tx.clone(); let is_api_key_auth = session_flags.is_api_key_auth; tasks .spawn(async move { let params = serde_json::json!({ "session_id": session_id.0.to_string(), "server_name": server_name, "enabled": enabled, }); let req = acp::ExtRequest::new( "x.ai/mcp/toggle", serde_json::value::to_raw_value(¶ms) .expect("serialize mcp/toggle params") .into(), ); let result = match acp_send(req, &tx).await { Ok(_) => Ok(()), Err(e) => Err(format_acp_error(&e, is_api_key_auth)), }; TaskResult::McpToggleDone { agent_id, result, } }); } Effect::ToggleMcpTool { agent_id, session_id, server_name, tool_name, enabled, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "session_id": session_id.0.to_string(), "server_name": server_name, "tool_name": tool_name, "enabled": enabled, }); let req = acp::ExtRequest::new( "x.ai/mcp/toggle_tool", serde_json::value::to_raw_value(¶ms) .expect("serialize mcp/toggle_tool params") .into(), ); let result = match acp_send(req, &tx).await { Ok(_) => Ok(()), Err(e) => { Err( sanitize_user_error(&format!("couldn't toggle tool: {e}")), ) } }; TaskResult::McpToggleDone { agent_id, result, } }); } Effect::ShareSession { agent_id, session_id } => { use xai_grok_shell::session::{ShareSessionRequest, ShareSessionResponse}; let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/share_session", serde_json::value::to_raw_value( &ShareSessionRequest { session_id: session_id.0.to_string(), }, ) .expect("serialize share session params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper.get("error") { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| "unknown error".to_string()); return TaskResult::ShareSessionFailed { agent_id, error: msg, }; } let inner = wrapper.get("result").unwrap_or(&wrapper); match serde_json::from_value::< ShareSessionResponse, >(inner.clone()) { Ok(share_resp) => { TaskResult::ShareSessionComplete { agent_id, share_url: share_resp.share_url, } } Err(_) => { TaskResult::ShareSessionFailed { agent_id, error: "couldn't share session".to_string(), } } } } Err(e) => { TaskResult::ShareSessionFailed { agent_id, error: sanitize_user_error( &format!("couldn't share session: {e}"), ), } } } }); } Effect::FetchSessionAgentName { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { match fetch_session_info(&session_id, &tx).await { Ok(info) => { TaskResult::SessionAgentNameResolved { agent_id, agent_name: info.data.agent_name, } } Err(e) => { tracing::debug!("session agent name fetch failed: {e}"); TaskResult::SessionAgentNameResolved { agent_id, agent_name: None, } } } }); } Effect::ShowSessionInfo { agent_id, session_id, show_resolved_model } => { let is_api_key_auth = session_flags.is_api_key_auth; let api_key_env_set = xai_grok_shell::agent::auth_method::has_xai_api_key_env(); let tx = acp_tx.clone(); tasks .spawn(async move { match fetch_session_info(&session_id, &tx).await { Ok(info) => { let title = lookup_session_title(&session_id).await; let text = format_session_info( &info, title.as_deref(), show_resolved_model, is_api_key_auth, api_key_env_set, ); TaskResult::SessionInfoComplete { agent_id, info: Box::new(info), text, } } Err(error) => { TaskResult::SessionInfoFailed { agent_id, error, } } } }); } Effect::RenameSession { agent_id, session_id, title, cwd } => { let tx = acp_tx.clone(); tasks .spawn(async move { #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct RenameRequest { session_id: String, title: String, cwd: String, } let request = acp::ExtRequest::new( "x.ai/session/rename", serde_json::value::to_raw_value( &RenameRequest { session_id: session_id.0.to_string(), title: title.clone(), cwd: cwd.to_string_lossy().to_string(), }, ) .expect("serialize rename params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper .get("error") .filter(|v| !v.is_null()) { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| err.to_string()); return TaskResult::RenameSessionFailed { agent_id, error: msg, }; } TaskResult::RenameSessionComplete { agent_id, title, } } Err(e) => { TaskResult::RenameSessionFailed { agent_id, error: sanitize_user_error( &format!("couldn't rename session: {e}"), ), } } } }); } Effect::DeleteSession { source, session_id, cwd } => { let tx = acp_tx.clone(); tasks .spawn(async move { #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct DeleteRequest { session_id: String, cwd: String, } let request = acp::ExtRequest::new( "x.ai/session/delete", serde_json::value::to_raw_value( &DeleteRequest { session_id: session_id.clone(), cwd, }, ) .expect("serialize delete params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper .get("error") .filter(|v| !v.is_null()) { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| err.to_string()); return TaskResult::DeleteSessionFailed { source, session_id, error: msg, }; } TaskResult::DeleteSessionComplete { source, session_id, } } Err(e) => { TaskResult::DeleteSessionFailed { source, session_id, error: sanitize_user_error( &format!("couldn't delete session: {e}"), ), } } } }); } Effect::SetCodingDataSharing { agent_id, opted_in, rollback_to_opted_in } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/privacy/setCodingDataRetention", serde_json::value::to_raw_value( &serde_json::json!({ "codingDataRetentionOptOut": !opted_in }), ) .expect("serialize params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = match serde_json::from_str( resp.0.get(), ) { Ok(v) => v, Err(e) => { return TaskResult::CodingDataSharingFailed { agent_id, error: format!("malformed response: {e}"), rollback_to_opted_in, }; } }; if let Some(err) = wrapper .get("error") .filter(|v| !v.is_null()) { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| err.to_string()); return TaskResult::CodingDataSharingFailed { agent_id, error: msg, rollback_to_opted_in, }; } let confirmed_opted_in = wrapper .get("codingDataRetentionOptOut") .and_then(|v| v.as_bool()) .map(|opt_out| !opt_out) .unwrap_or(opted_in); TaskResult::CodingDataSharingUpdated { agent_id, opted_in: confirmed_opted_in, } } Err(e) => { TaskResult::CodingDataSharingFailed { agent_id, error: format!("{e}"), rollback_to_opted_in, } } } }); } Effect::ShowContextInfo { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { match fetch_session_info(&session_id, &tx).await { Ok(info) => { TaskResult::ContextInfoComplete { agent_id, info: Box::new(info), } } Err(error) => { TaskResult::ContextInfoFailed { agent_id, error, } } } }); } Effect::FetchSessionUsage { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { match fetch_session_usage(&session_id, &tx).await { Ok(usage) => { TaskResult::SessionUsageComplete { agent_id, session_id, usage: Box::new(usage), } } Err(error) => { TaskResult::SessionUsageFailed { agent_id, session_id, error, } } } }); } Effect::SendFeedback { agent_id, session_id, feedback_text } => { use xai_grok_shell::session::ClientType; use xai_grok_shell::session::acp_types::ClientFeedbackInput; let terminal_info = Some( crate::terminal::terminal_context().feedback_info(), ); let tx = acp_tx.clone(); tasks .spawn(async move { let input = ClientFeedbackInput { session_id: session_id.0.to_string(), client_type: ClientType::Tui, rating_type: None, rating_value: None, feedback_text: Some(feedback_text), feedback_categories: vec![], context_type: None, turn_number: None, request_id: None, client_version: Some(xai_grok_version::VERSION.to_string()), metadata: None, terminal_info, }; let raw_params = match serde_json::value::to_raw_value(&input) { Ok(v) => v, Err(e) => { return TaskResult::FeedbackFailed { agent_id, error: sanitize_user_error( &format!( "couldn't serialize feedback: {e}" ), ), }; } }; let request = acp::ExtRequest::new( "x.ai/feedback", raw_params.into(), ); match acp_send(request, &tx).await { Ok(_) => { TaskResult::FeedbackComplete { agent_id, } } Err(e) => { TaskResult::FeedbackFailed { agent_id, error: sanitize_user_error( &format!("couldn't send feedback: {e}"), ), } } } }); } Effect::RewriteMemoryNote { agent_id, session_id, raw_text, context_summary, nonce, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/memory/rewrite", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string(), "rawText": raw_text, "contextSummary": context_summary, }), ) .expect("serialize memory/rewrite params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let parsed: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let text = parsed .get("result") .and_then(|r| r.get("rewritten")) .or_else(|| parsed.get("rewritten")) .and_then(|v| v.as_str()) .map(String::from) .unwrap_or(raw_text); TaskResult::MemoryNoteRewritten { agent_id, result: Ok(text), nonce, } } Err(_) => { TaskResult::MemoryNoteRewritten { agent_id, result: Ok(raw_text), nonce, } } } }); } Effect::SaveMemoryNote { agent_id, text, cwd } => { tasks .spawn(async move { let result = tokio::task::spawn_blocking(move || { let storage = xai_grok_shell::session::memory::MemoryStorage::new( &cwd, None, ); storage .append_to_memory( xai_grok_shell::session::memory::MemoryScope::Global, &text, ) }) .await .map_err(|e| format!("task join error: {e}")) .and_then(|r| r.map_err(|e| format!("{e}"))); TaskResult::MemoryNoteSaved { agent_id, result, } }); } Effect::SendBtw { agent_id, session_id, question, minimal_request_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/btw", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string(), "question": question, }), ) .expect("serialize btw params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let parsed: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let answer = parsed .get("result") .and_then(|r| r.get("answer")) .and_then(|a| a.as_str()) .unwrap_or("No response") .to_string(); TaskResult::BtwResponse { agent_id, result: Ok(answer), minimal_request_id, } } Err(e) => { TaskResult::BtwResponse { agent_id, result: Err( sanitize_user_error(&format!("side question failed: {e}")), ), minimal_request_id, } } } }); } Effect::SendRecap { session_id, auto } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/recap", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string(), "auto": auto, }), ) .expect("serialize recap params") .into(), ); match acp_send(request, &tx).await { Ok(_) => { TaskResult::RecapRequested { session_id, auto, error: None, } } Err(e) => { TaskResult::RecapRequested { session_id, auto, error: Some(format!("recap request failed: {e}")), } } } }); } Effect::SendInterject { agent_id, session_id, text, interjection_id, blocks, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = build_interject_params( &session_id, &text, &interjection_id, blocks.as_deref(), ); let request = acp::ExtRequest::new( "x.ai/interject", serde_json::value::to_raw_value(¶ms) .expect("serialize interject params") .into(), ); match acp_send(request, &tx).await { Ok(_) => { TaskResult::InterjectQueued { agent_id, } } Err(e) => { TaskResult::InterjectFailed { agent_id, error: sanitize_user_error( &format!("couldn't send interjection: {e}"), ), text, blocks, } } } }); } Effect::FetchCatalogEntry { kind, name } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "kind": kind, "name": name }); let request = acp::ExtRequest::new( "x.ai/bundle/entry/get", serde_json::value::to_raw_value(¶ms) .expect("serialize bundle/entry/get params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper.get("error") { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| "unknown error".to_string()); return TaskResult::CatalogEntryFailed { error: msg, }; } let inner = wrapper.get("result").unwrap_or(&wrapper); match serde_json::from_value::< super::bundle::EntryGetResult, >(inner.clone()) { Ok(r) => { TaskResult::CatalogEntryReady { kind: r.kind, name: r.name, content: r.content, } } Err(e) => { tracing::debug!("failed to parse catalog entry response: {e}"); TaskResult::CatalogEntryFailed { error: "couldn't load entry".to_string(), } } } } Err(e) => { TaskResult::CatalogEntryFailed { error: sanitize_user_error( &format!("couldn't load entry: {e}"), ), } } } }); } Effect::FetchBundleStatus => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/bundle/status", serde_json::value::to_raw_value(&serde_json::json!({})) .expect("serialize bundle/status params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper.get("error") { let msg = err .as_str() .map(String::from) .unwrap_or_else(|| "unknown error".to_string()); return TaskResult::BundleStatusFailed { error: msg, }; } let inner = wrapper.get("result").unwrap_or(&wrapper); match serde_json::from_value::< super::bundle::BundleStatusResult, >(inner.clone()) { Ok(r) => { TaskResult::BundleStatusReady { has_cache: r.has_cache, version: r.version, personas: r.personas, roles: r.roles, agents: r.agents, skills: r.skills, persona_details: r.persona_details, role_details: r.role_details, } } Err(_) => { TaskResult::BundleStatusFailed { error: "couldn't fetch bundle status".to_string(), } } } } Err(e) => { TaskResult::BundleStatusFailed { error: sanitize_user_error( &format!("couldn't fetch bundle status: {e}"), ), } } } }); } Effect::RefreshAvailableCommands { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "sessionId": session_id }); let req = acp::ExtRequest::new( "x.ai/commands/list", serde_json::value::to_raw_value(¶ms) .expect("serialize commands/list params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let inner = wrapper.get("result").unwrap_or(&wrapper); let commands: Vec = inner .get("commands") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); TaskResult::AvailableCommandsRefreshed { agent_id, commands, } } Err(e) => { tracing::warn!("commands/list refresh failed: {e}"); TaskResult::AvailableCommandsRefreshed { agent_id, commands: vec![], } } } }); } Effect::FetchRewindPoints { agent_id, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/rewind/points", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string() }), ) .expect("serialize rewind/points params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); if let Some(err) = wrapper .get("error") .filter(|v| !v.is_null()) { return TaskResult::RewindPointsFailed { agent_id, error: err.as_str().unwrap_or("unknown error").to_string(), }; } let result_val = wrapper .get("result") .cloned() .unwrap_or(wrapper.clone()); match serde_json::from_value::< crate::views::rewind::RewindPointsResponse, >(result_val) { Ok(r) => { TaskResult::RewindPointsLoaded { agent_id, points: r.rewind_points, } } Err(e) => { TaskResult::RewindPointsFailed { agent_id, error: format!("invalid response: {e}"), } } } } Err(e) => { TaskResult::RewindPointsFailed { agent_id, error: sanitize_user_error(&e.to_string()), } } } }); } Effect::RewindPreview { agent_id, session_id, target_prompt_index, mode } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/rewind/execute", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string(), "targetPromptIndex": target_prompt_index, "force": false, "mode": mode.wire_value(), }), ) .expect("serialize rewind/execute preview params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result_val = wrapper .get("result") .cloned() .unwrap_or(wrapper.clone()); match serde_json::from_value::< crate::views::rewind::RewindResponse, >(result_val) { Ok(r) => { TaskResult::RewindPreviewComplete { agent_id, response: r, target_prompt_index, mode, } } Err(e) => { TaskResult::RewindPreviewFailed { agent_id, error: format!("invalid response: {e}"), } } } } Err(e) => { TaskResult::RewindPreviewFailed { agent_id, error: sanitize_user_error(&e.to_string()), } } } }); } Effect::RewindExecute { agent_id, session_id, target_prompt_index, mode } => { let tx = acp_tx.clone(); tasks .spawn(async move { let request = acp::ExtRequest::new( "x.ai/rewind/execute", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string(), "targetPromptIndex": target_prompt_index, "force": true, "mode": mode.wire_value(), }), ) .expect("serialize rewind/execute params") .into(), ); match acp_send(request, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result_val = wrapper .get("result") .cloned() .unwrap_or(wrapper.clone()); match serde_json::from_value::< crate::views::rewind::RewindResponse, >(result_val) { Ok(r) => { TaskResult::RewindExecuteComplete { agent_id, response: r, } } Err(e) => { TaskResult::RewindExecuteFailed { agent_id, error: format!("invalid response: {e}"), } } } } Err(e) => { TaskResult::RewindExecuteFailed { agent_id, error: sanitize_user_error(&e.to_string()), } } } }); } Effect::DeepSearchSessions { query, seq } => { let tx = acp_tx.clone(); tasks .spawn(async move { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); let retry_interval = std::time::Duration::from_secs(3); let mut results = Vec::new(); loop { let params = serde_json::json!({ "query": query, "limit": 20, "includeContent": true, }); let request = acp::ExtRequest::new( "x.ai/session/search", serde_json::value::to_raw_value(¶ms) .expect("serialize deep search params") .into(), ); let remaining = deadline .saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { break; } let result = tokio::time::timeout( remaining, acp_send(request, &tx), ) .await; match result { Ok(Ok(resp)) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let payload = wrapper.get("result").unwrap_or(&wrapper); if let Some(hits) = payload.get("results") { results = serde_json::from_value::< Vec< xai_grok_shell::extensions::session_search::SearchSessionHit, >, >(hits.clone()) .unwrap_or_default(); } let bootstrapping = payload .get("bootstrapping") .and_then(|v| v.as_bool()) .unwrap_or(false); if !bootstrapping { break; } } Ok(Err(e)) => { tracing::warn!("deep search failed: {e}"); break; } Err(_) => { tracing::warn!("deep search timed out"); break; } } tokio::time::sleep(retry_interval).await; } TaskResult::DeepSearchResults { results, seq, } }); } Effect::ForkSession { agent_id, parent_session_id, parent_cwd, parent_is_worktree, new_session_id, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let sid_str = parent_session_id.0.to_string(); let parent_cwd_str = parent_cwd.to_string_lossy().into_owned(); if let Some(ref nid) = new_session_id && let Err(e) = crate::app::session_startup::ensure_session_id_available( nid, &parent_cwd_str, ) { return TaskResult::ForkSessionFailed { agent_id, error: sanitize_user_error(&e.to_string()), }; } let payload = crate::app::session_startup::fork_session_params( &sid_str, &parent_cwd, new_session_id.as_deref(), parent_is_worktree, ); let req = acp::ExtRequest::new( "x.ai/session/fork", serde_json::value::to_raw_value(&payload) .expect("serialize fork params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { if let Some(err) = crate::app::session_startup::fork_response_error( resp.0.get(), ) { let msg = err.trim_matches('"').to_string(); return TaskResult::ForkSessionFailed { agent_id, error: sanitize_user_error(&format!("fork failed: {msg}")), }; } match crate::app::session_startup::fork_response_new_session_id( resp.0.get(), ) { Some(sid) => { TaskResult::ForkSessionReady { agent_id, new_session_id: acp::SessionId::new(sid), cwd: parent_cwd, } } None => { TaskResult::ForkSessionFailed { agent_id, error: "fork response missing newSessionId".into(), } } } } Err(e) => { TaskResult::ForkSessionFailed { agent_id, error: sanitize_user_error(&format!("fork failed: {e}")), } } } }); } Effect::HydrateSessionTitleFromDisk { agent_id, session_id, cwd } => { tasks .spawn(async move { let info = xai_grok_shell::session::info::Info { id: session_id, cwd: cwd.to_string_lossy().to_string(), }; let path = xai_grok_shell::session::persistence::session_dir(&info) .join("summary.json"); let title = tokio::task::spawn_blocking(move || -> Option< (String, bool), > { let raw = std::fs::read_to_string(path).ok()?; let summary: xai_grok_shell::session::persistence::Summary = serde_json::from_str( &raw, ) .ok()?; let manual = summary.manual_title_opt(); let is_manual = manual.is_some(); let title = manual.or_else(|| summary.display_title_opt())?; Some((title, is_manual)) }) .await .ok() .flatten(); TaskResult::SessionTitleFromDisk { agent_id, title, } }); } Effect::FetchBilling { agent_id, silent } => { let tx = acp_tx.clone(); tasks .spawn(async move { use xai_grok_shell::extensions::billing::BillingConfigResponse; let req = acp::ExtRequest::new( "x.ai/billing", serde_json::value::to_raw_value(&serde_json::json!({})) .expect("serialize billing params") .into(), ); let parsed = match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result = wrapper.get("result").unwrap_or(&wrapper); serde_json::from_value::< BillingConfigResponse, >(result.clone()) } Err(e) => { return TaskResult::BillingError { agent_id, error: sanitize_user_error(&format!("{e}")), silent, }; } }; let billing = match parsed { Ok(billing) => billing, Err(e) => { return TaskResult::BillingError { agent_id, error: format!("Parse error: {e}"), silent, }; } }; let subscription_tier = billing.subscription_tier; let balance = billing.config.map(credit_balance_from_config); let autotopup = if has_prepaid_credits(balance.as_ref()) { fetch_auto_topup_info(&tx).await } else { crate::views::credit_bar::AutoTopupFetch::Cleared }; TaskResult::BillingFetched { agent_id, balance, silent, subscription_tier, autotopup, } }); } Effect::RefreshGate => { tasks .spawn(async move { let settings = tokio::task::spawn_blocking(|| { if !xai_grok_shell::util::config::resolve_remote_fetch_enabled() { return None; } let grok_home = xai_grok_shell::util::grok_home::grok_home(); let store = xai_grok_shell::auth::read_auth_json( &grok_home.join("auth.json"), ) .ok()?; let scope = xai_grok_shell::auth::GrokComConfig::default() .auth_scope(); let auth = xai_grok_shell::auth::lookup_auth( &store, &scope, )?; let proxy_base = std::env::var( "GROK_CLI_CHAT_PROXY_BASE_URL", ) .unwrap_or_else(|_| { xai_grok_shell::agent::config::CLI_CHAT_PROXY_BASE_URL_DEFAULT .to_owned() }); xai_grok_shell::remote::fetch_settings_blocking( &proxy_base, &auth, None, ) .into_option() }) .await .ok() .flatten(); TaskResult::GateRefreshed { settings, } }); } Effect::FetchAppBilling => { let tx = acp_tx.clone(); tasks .spawn(async move { use xai_grok_shell::extensions::billing::BillingConfigResponse; let req = acp::ExtRequest::new( "x.ai/billing", serde_json::value::to_raw_value(&serde_json::json!({})) .expect("serialize billing params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let result = wrapper.get("result").unwrap_or(&wrapper); match serde_json::from_value::< BillingConfigResponse, >(result.clone()) { Ok(billing) => { let balance = billing .config .map(|c| crate::views::credit_bar::CreditBalance { period_end_display: None, ..credit_balance_from_config(c) }); let autotopup = if has_prepaid_credits(balance.as_ref()) { fetch_auto_topup_info(&tx).await } else { crate::views::credit_bar::AutoTopupFetch::Cleared }; TaskResult::AppBillingFetched { balance, autotopup, } } Err(_) => { TaskResult::AppBillingFetched { balance: None, autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged, } } } } Err(_) => { TaskResult::AppBillingFetched { balance: None, autotopup: crate::views::credit_bar::AutoTopupFetch::Unchanged, } } } }); } Effect::DebounceSuggestions { agent_id, generation } => { tasks .spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(50)).await; TaskResult::SuggestionDebounceExpired { agent_id, generation, } }); } Effect::DebouncePluginCta { agent_id, generation } => { tasks .spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(300)).await; TaskResult::PluginCtaDebounceExpired { agent_id, generation, } }); } Effect::FetchShellSuggestions { agent_id, text, cursor, cwd, generation, limit, include_ai, ai_model, session_id, token_only, } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ // By reference: echoed back below as `request_text`. "text": &text, "cursor": cursor, "cwd": cwd, "includeAi": include_ai, "aiModel": ai_model, "sessionId": session_id, "limit": limit, "generation": generation, "tokenOnly": token_only, }); let req = acp::ExtRequest::new( "x.ai/suggest", serde_json::value::to_raw_value(¶ms) .expect("serialize suggest params") .into(), ); match acp_send(req, &tx).await { Ok(resp) => { let wrapper: serde_json::Value = serde_json::from_str( resp.0.get(), ) .unwrap_or_default(); let parsed = crate::views::suggestion_controller::SuggestResponseParsed::from_json( &wrapper, ); match parsed { Some(response) => { TaskResult::ShellSuggestionsLoaded { agent_id, response, request_text: text, request_cursor: cursor, } } None => TaskResult::CancelComplete, } } Err(_) => TaskResult::CancelComplete, } }); } Effect::FetchPromptSuggestion { agent_id, generation, model, session_id } => { let tx = acp_tx.clone(); tasks .spawn(async move { let params = serde_json::json!({ "generation": generation, "model": model, "sessionId": session_id, }); let req = acp::ExtRequest::new( "x.ai/suggestPrompt", serde_json::value::to_raw_value(¶ms) .expect("serialize suggestPrompt params") .into(), ); let suggestion = match acp_send(req, &tx).await { Ok(resp) => { serde_json::from_str::(resp.0.get()) .ok() .as_ref() .map(|v| v.get("result").unwrap_or(v)) .and_then(|r| r.get("suggestion")) .and_then(|s| s.as_str()) .map(str::to_owned) } Err(_) => None, }; TaskResult::PromptSuggestionLoaded { agent_id, suggestion, generation, } }); } } (false, meta) } /// Fetch session info from ACP via `x.ai/session/info`. async fn fetch_session_info( session_id: &acp::SessionId, tx: &AcpAgentTx, ) -> Result { let request = acp::ExtRequest::new( "x.ai/session/info", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string() }), ) .expect("serialize session/info params") .into(), ); let resp = acp_send(request, tx) .await .map_err(|e| sanitize_user_error(&format!("couldn't fetch session info: {e}")))?; let envelope: ExtMethodResult = serde_json::from_str( resp.0.get(), ) .map_err(|e| { tracing::debug!("typed session info deser failed: {e}"); "invalid session info response".to_string() })?; if let Some(err) = envelope.error { let msg = err.as_str().map(String::from).unwrap_or_else(|| err.to_string()); return Err(msg); } envelope.result.ok_or_else(|| "session info response missing result".to_string()) } /// `x.ai/session/usage` → [`PromptUsage`] (bare response, no envelope). async fn fetch_session_usage( session_id: &acp::SessionId, tx: &AcpAgentTx, ) -> Result { let request = acp::ExtRequest::new( "x.ai/session/usage", serde_json::value::to_raw_value( &serde_json::json!({ "sessionId": session_id.0.to_string() }), ) .expect("serialize session/usage params") .into(), ); let resp = acp_send(request, tx) .await .map_err(|e| { if i32::from(e.code) == i32::from(acp::Error::method_not_found().code) { "not supported by this agent version".to_string() } else { sanitize_user_error(&e.to_string()) } })?; let parsed: xai_grok_shell::extensions::usage::SessionUsageResponse = serde_json::from_str( resp.0.get(), ) .map_err(|e| { tracing::debug!("session usage deser failed: {e}"); "invalid session usage response".to_string() })?; Ok(parsed.usage) } /// Look up the session title/summary from local persistence. async fn lookup_session_title(session_id: &acp::SessionId) -> Option { let summaries = xai_grok_shell::session::persistence::list_summaries(None) .await .ok()?; summaries .into_iter() .find(|s| s.info.id == *session_id) .and_then(|s| s.display_title_opt()) } /// Format session info into a human-readable string. /// /// Mirrors the TUI's `render_session_info` for pager display. fn format_session_info( info: &SessionInfoResponse, title: Option<&str>, show_resolved_model: bool, is_api_key_auth: bool, api_key_env_set: bool, ) -> String { let session_id = &info.session_id; let cwd = &info.cwd; let model = info.data.model.as_deref().unwrap_or("unknown"); let model_display = xai_grok_shell::session::model_display_name( info.data.model_display_name.as_deref(), model, info.data.resolved_model_id.as_deref(), show_resolved_model, ); let ctx = &info.data.context; let used = ctx.used; let total = ctx.total; let pct = ctx.usage_pct; let title_line = match title { Some(t) => format!(" Title: {t}\n"), None => String::new(), }; let model_hash_line = if xai_grok_shell::session::should_show_model_fingerprint( info.data.show_model_fingerprint, model, ) { info.data .model_fingerprint .as_deref() .map(|fp| format!("\n Model Hash: {fp}")) .unwrap_or_default() } else { String::new() }; let backend_line = info .data .api_backend .as_deref() .map(|b| format!("\n API Backend: {b}")) .unwrap_or_default(); let sandbox_line = xai_grok_sandbox::profile_name() .map(|profile| format!("\n Sandbox: {profile}")) .unwrap_or_default(); let turn_line = format!("\n Turn: {}", info.data.turn_index); let conversation_line = info .data .conversation_id .as_deref() .filter(|id| !id.is_empty()) .map(|id| format!("\n Conversation ID: {id}")) .unwrap_or_default(); let version_display = xai_grok_version::display_version( xai_grok_update::channel_label(), ); let auth_lines = format_auth_lines(is_api_key_auth, api_key_env_set); format!( "{title_line} Shell version: {version_display}\n{auth_lines} Session ID: {session_id}{conversation_line}\n Working directory: {cwd}\n Model: {model_display}{model_hash_line}{backend_line}{sandbox_line}{turn_line}\n Context: {used} / {total} tokens ({pct}%)" ) } /// Auth section for `/session-info` — login method + where to manage account/credits. /// /// This reflects the process login / ACP auth method, not per-model sampling /// credentials (a model `api_key`/`env_key` can still own the turn). fn format_auth_lines(is_api_key_auth: bool, api_key_env_set: bool) -> String { if is_api_key_auth { let method = if api_key_env_set { " Auth method: API key (XAI_API_KEY)\n" } else { " Auth method: API key\n" }; return format!( "{method} Manage account and credits: console.x.ai\n Run `grok login` to use your SuperGrok subscription instead.\n" ); } String::from( " Auth method: OAuth\n Manage account and credits: https://grok.com/?_s=billing\n", ) } /// Build the single text content block for a plain `Effect::SendPrompt`. /// /// Non-empty `skill_token_ranges` are stamped into the block `_meta` as /// `skillTokenRanges: [[start, end], …]` so session replay restyles the echo /// exactly like the composer highlighted it at submit time. Contract: the /// offsets index this block's `text`, which is displayed verbatim — this /// producer never combines them with a `displayText` override, and the /// tracker ignores them when one is present. Empty ranges keep `meta: None` /// — the legacy wire shape stays byte-identical. Extracted from the spawn /// for testability. fn plain_prompt_content_block( text: String, skill_token_ranges: &[std::ops::Range], ) -> acp::ContentBlock { let meta = if skill_token_ranges.is_empty() { None } else { let ranges: Vec = skill_token_ranges .iter() .map(|r| serde_json::json!([r.start, r.end])) .collect(); let mut map = acp::Meta::new(); map.insert( crate::acp::meta::user_prompt_meta::SKILL_TOKEN_RANGES.into(), serde_json::Value::Array(ranges), ); Some(map) }; acp::ContentBlock::Text(acp::TextContent::new(text).meta(meta)) } /// Build the `PromptRequest._meta` payload: `promptId` for notification / /// response correlation, plus `screenMode` (`fullscreen` | `inline` | /// `minimal`; headless stamps `"headless"` in its own path) so the shell can /// attribute `prompt_submitted` telemetry to minimal vs. regular usage. /// `screen_mode` is `None` only under `SessionFlags::default()` (tests); the /// key is omitted then, keeping the legacy wire shape byte-identical. /// Extracted from the spawns for testability. fn prompt_request_meta( prompt_id: &str, screen_mode: Option<&'static str>, ) -> serde_json::Value { let mut map = serde_json::Map::new(); map.insert("promptId".into(), serde_json::Value::String(prompt_id.into())); if let Some(mode) = screen_mode { map.insert("screenMode".into(), serde_json::Value::String(mode.into())); } serde_json::Value::Object(map) } /// Build the `x.ai/interject` params. The optional structured `content` /// (text + images) is omitted ENTIRELY when `None` so the legacy wire /// shape stays byte-identical. Extracted from the spawn for testability. fn build_interject_params( session_id: &acp::SessionId, text: &str, interjection_id: &str, blocks: Option<&[acp::ContentBlock]>, ) -> serde_json::Value { let mut params = serde_json::json!({ "sessionId": session_id.0.to_string(), "text": text, "interjectionId": interjection_id, }); if let Some(blocks) = blocks { params["content"] = serde_json::to_value(blocks) .expect("serialize interject content"); } params } #[cfg(test)] mod tests;