Synced from monorepo Changes: - Workspace task snapshots only list incomplete backgrounded tasks - Quiet auth, LSP, and config warnings in the shell - Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials - Fail closed when soak metrics are missing - Run plan-mode exit last in mixed tool batches - Allow /loop to store prompts that can terminate the loop - Make subagent maximum nesting depth configurable - Security: apply sandbox profile to the leader process that executes tools Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
4643 lines
205 KiB
Rust
4643 lines
205 KiB
Rust
#![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<TaskResult>,
|
|
acp_tx: &AcpAgentTx,
|
|
cwd: &Path,
|
|
session_flags: &SessionFlags,
|
|
progress_tx: &tokio::sync::mpsc::UnboundedSender<RestoreProgressMsg>,
|
|
) -> (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::<Vec<String>>()
|
|
})
|
|
.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<String> = None;
|
|
let mut external = false;
|
|
let mut mode: Option<String> = 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<crate::views::extensions_modal::WorkflowInfo>,
|
|
>(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::<Vec<_>>()
|
|
})
|
|
.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<acp::AvailableCommand> = 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::<serde_json::Value>(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<SessionInfoResponse, String> {
|
|
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<SessionInfoResponse> = 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<xai_grok_shell::extensions::notification::PromptUsage, String> {
|
|
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<String> {
|
|
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<usize>],
|
|
) -> acp::ContentBlock {
|
|
let meta = if skill_token_ranges.is_empty() {
|
|
None
|
|
} else {
|
|
let ranges: Vec<serde_json::Value> = 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;
|