grok-build-upstream-mirror/crates/codegen/xai-grok-shell/src/session/acp_session.rs
grokkybara[bot] a881e6703f Synced from monorepo
Changes:
- Persist submitter identity for /feedback
- Let custom models use rotating tokens from named auth providers
- Template stale tool/param name literals in server-native descriptions
- Minimal mode commits thinking in full, lookups as one-liners
- Tighten durable append internals
- Nudge model to end turn on no-op bash commands
- Per-fetch signing nonce in the managed-config envelope, with a server-side replay probe
- Include working tree in startup status
2026-07-20 18:22:25 +01:00

2066 lines
101 KiB
Rust

#![allow(clippy::await_holding_refcell_ref)]
#![allow(clippy::arc_with_non_send_sync)]
//! Session actor implementation for the MVP ACP agent.
//!
//! Each session runs as an actor with its own chat history and tool context.
//! The agent owns the client connection and routes commands and events via
//! channels:
//! - Agent → Session: `SessionCommand` (prompt, cancel, shutdown)
//! - Session → Client: `session_notification` via a shared gateway handle
//!
use super::commands::{
ParsedPromptInfo, PromptCompletionKind, PromptTurnOk, PromptTurnResult, SessionCommand,
TaskWakeAdmission, TaskWakeFallback, ok_end_turn,
};
use super::handle::SessionHandle;
use super::notifications::NotificationSender;
use crate::agent::update_chunk_merge::{BufferingSettings, ReplayBuffer};
use crate::extensions::notification::SessionUpdate as XaiSessionUpdate;
use crate::extensions::notification::{
RetryState, SessionNotification as XaiSessionNotification, is_reauthable_failure,
};
use crate::sampling::error::map_sampling_err_to_acp;
use crate::sampling::types::{ChatRequestMessage, ToolCallResponse, ToolDefinition};
use crate::sampling::{
ContentPart, ConversationItem, ConversationRequest, ConversationResponse, SamplingError,
SyntheticReason, ToolSpec, conversation_truncate_for_prompt,
};
use crate::session::ClientFsConfig;
use crate::session::feedback_manager::{FeedbackManager, FeedbackManagerConfig};
use crate::session::fs_watch::{self, git_head_dedup_key};
use crate::session::info::Info as SessionInfo;
use crate::session::mcp_servers::McpInitStrategy;
use crate::session::mcp_servers::McpMetaConfigMap;
use crate::session::mcp_servers::McpState;
use crate::session::mcp_servers::OauthInteractivity;
use crate::session::mcp_servers::build_pending_clients;
use crate::session::mcp_servers::mcp_server_name;
use crate::session::mcp_servers::mcp_target_str;
use crate::session::mcp_servers::mcp_transport_str;
use crate::session::mcp_servers::parse_mcp_tool_name;
use crate::session::persistence::{
PersistenceContentChunk, PersistenceHandle, PersistenceMsg, get_prompt_file_path,
};
use crate::session::plan_mode::PromptMode;
use crate::session::prompt_parser::parse_prompt_with_skills;
use crate::session::replay_events::{SessionEvent, SessionNotification};
use crate::session::result::ExtMethodResult;
use crate::session::signals::{SessionSignalsHandle, TurnDeltaSnapshot};
use crate::session::slash_commands::{self, BuiltinAction, SlashCommandOutcome};
use crate::session::storage::SessionUpdate;
use crate::session::user_message::extract_user_query;
use crate::session::user_message::{construct_user_message, construct_user_message_minimal};
use crate::terminal::{DEFAULT_TIMEOUT, TerminalRunRequest};
use crate::tools::ToolContext;
use agent_client_protocol as acp;
use agent_client_protocol::ContentBlock;
use parking_lot::Mutex;
use serde_json::json;
use std::collections::{HashMap, VecDeque};
use std::path::Path;
use std::sync::Arc;
#[cfg(test)]
use std::sync::OnceLock;
use tokio::sync::{Mutex as TokioMutex, mpsc, oneshot};
use tokio::time::{Duration, sleep};
use tokio_retry::strategy::ExponentialBackoff;
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_grok_agent::AgentDefinition;
use xai_grok_agent::prompt::agents_md::LEGACY_AGENTS_MD_REMINDER_PREFIX;
use xai_grok_agent::prompt::skills::SkillsConfig;
use xai_grok_sampler::SamplerConfig as SamplingConfig;
use xai_grok_sampling_types::truncate_bytes;
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::BashToolInput;
use xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig;
use xai_grok_tools::types::ToolInput;
use xai_grok_tools::types::compat::CompatConfig;
use xai_grok_tools::types::output::{
BashOutput, ReadFileOutput, ToolOutput as ToolsToolOutput, ToolRunResult,
};
use xai_grok_workspace::file_system::CodebaseIndexManager;
use xai_grok_workspace::permission::{
AccessKind, ClientType, Decision, PermissionEvent, PermissionHandle,
};
use xai_grok_workspace::session::file_state::{FileStateHandle, FileStateTracker};
const SESSION_LOG: &str = "xai_session";
#[path = "compaction.rs"]
mod compaction;
#[path = "compaction_segments.rs"]
mod compaction_segments;
#[path = "acp_session_impl/types.rs"]
mod types;
pub(crate) use types::*;
pub use types::{TodoGateDecision, TodoGateReason};
#[path = "acp_session_impl/goal.rs"]
mod goal;
#[path = "acp_session_impl/interjection.rs"]
mod interjection;
#[path = "acp_session_impl/tool_calls.rs"]
mod tool_calls;
#[path = "acp_session_impl/turn.rs"]
mod turn;
pub(crate) use interjection::*;
#[path = "acp_session_impl/laziness.rs"]
mod laziness;
pub(crate) use laziness::*;
#[path = "acp_session_impl/hooks_plugins.rs"]
mod hooks_plugins;
#[path = "acp_session_impl/mcp.rs"]
mod mcp;
#[path = "acp_session_impl/model_switch.rs"]
mod model_switch;
#[path = "acp_session_impl/prompt_queue.rs"]
mod prompt_queue;
#[path = "acp_session_impl/slash_exec.rs"]
mod slash_exec;
use super::PromptOrigin;
use super::acp_types;
use super::chat_persistence;
use super::compaction_config;
use super::helpers;
use super::memory_state;
use super::telemetry;
#[path = "acp_session_impl/prompt_build.rs"]
mod prompt_build;
use prompt_build::*;
#[path = "acp_session_impl/session_mode.rs"]
mod session_mode;
use session_mode::*;
#[path = "acp_session_impl/sampler_turn.rs"]
mod sampler_turn;
use sampler_turn::*;
#[path = "acp_session_impl/tool_dispatch.rs"]
mod tool_dispatch;
use tool_dispatch::*;
#[path = "acp_session_impl/mcp_snapshot.rs"]
mod mcp_snapshot;
use mcp_snapshot::*;
#[path = "acp_session_impl/tasks_cancel.rs"]
mod tasks_cancel;
use tasks_cancel::*;
#[path = "acp_session_impl/reminders.rs"]
mod reminders;
use reminders::*;
pub use reminders::{CollectedTodoGateInput, TodoGateInput, evaluate_todo_gate};
#[path = "acp_session_impl/laziness_classifier.rs"]
mod laziness_classifier;
pub(crate) use laziness_classifier::*;
#[path = "acp_session_impl/notification_drain.rs"]
mod notification_drain;
use notification_drain::*;
#[path = "acp_session_impl/extensions.rs"]
mod extensions;
use extensions::*;
#[path = "acp_session_impl/memory_dream.rs"]
mod memory_dream;
use memory_dream::*;
#[path = "acp_session_impl/goal_support.rs"]
mod goal_support;
pub(crate) use goal_support::*;
#[path = "acp_session_impl/hook_dispatch.rs"]
mod hook_dispatch;
use hook_dispatch::*;
#[path = "acp_session_impl/stop_gate.rs"]
mod stop_gate;
pub use stop_gate::MAX_STOP_HOOK_CONTINUATIONS_PER_TURN;
#[path = "acp_session_impl/recap.rs"]
mod recap;
#[path = "acp_session_impl/rewind.rs"]
mod rewind;
#[path = "acp_session_impl/run_loop.rs"]
mod run_loop;
#[path = "acp_session_impl/session_setup.rs"]
mod session_setup;
#[path = "acp_session_impl/turn_end.rs"]
mod turn_end;
#[path = "acp_session_impl/updates.rs"]
mod updates;
use run_loop::*;
#[path = "acp_session_impl/spawn.rs"]
mod spawn;
use super::acp_types::*;
pub use spawn::SessionThread;
pub(crate) use spawn::*;
/// Client-registered hook gates (the `x.ai/hooks/run` reverse request).
mod hooks;
pub(crate) struct InputItem {
pub(crate) prompt_id: String,
pub(crate) prompt_blocks: Vec<ContentBlock>,
pub(crate) prompt_mode: PromptMode,
pub(crate) trace_gcs_config: Option<crate::session::repo_changes::TraceExportConfig>,
pub(crate) artifact_tracker: Option<crate::upload::manifest::ArtifactTracker>,
/// Optional client identifier from the prompt request meta (overrides session-level one)
pub(crate) client_identifier: Option<String>,
/// See [`SessionCommand::Prompt::screen_mode`]. Telemetry-only.
pub(crate) screen_mode: Option<String>,
/// See [`SessionCommand::Prompt::verbatim`].
pub(crate) verbatim: bool,
pub(crate) json_schema: Option<serde_json::Value>,
/// Who originated this prompt — user or auto-wake system.
pub(crate) origin: super::PromptOrigin,
/// Typed deferred completion retained while an admitted task wake is queued.
/// Consumed by Ctrl+C if it removes the wake before the turn starts.
pub(crate) task_wake_fallback: Option<TaskWakeFallback>,
pub(crate) respond_to: oneshot::Sender<PromptTurnResult>,
/// Fired after the user message is in chat history and a persistence flush
/// barrier has completed (see `SessionCommand::Prompt::persist_ack`).
pub(crate) persist_ack: Option<oneshot::Sender<()>>,
/// Pre-parsed prompt channel. See `SessionCommand::Prompt::parsed_prompt_tx`.
pub(crate) parsed_prompt_tx: Option<oneshot::Sender<ParsedPromptInfo>>,
/// Server-authoritative prompt-queue metadata. `Some` for
/// user-originated prompts (they appear in the shared queue); `None` for
/// synthetic / system inputs (auto-wake, nudges, notification drains).
pub(crate) queue_meta: Option<crate::session::prompt_queue::QueueEntryMeta>,
/// Whether this prompt entered via the send-now path (explicit, derived
/// during a blocking wait, or an interjection fallback). Send-now inserts
/// land behind earlier still-queued send-now prompts so stacked sends
/// (e.g. during a goal turn, which promotes but never cancels) run FIFO.
pub(crate) send_now: bool,
}
use crate::session::commands::{NotificationPriority, NotificationSource};
/// Resolved tool names for goal-mode prompts.
///
/// Built by [`SessionActor::resolve_goal_tool_names()`] to avoid
/// duplicating `tool_for_kind()` calls across goal functions.
struct GoalToolNames {
goal: String,
task: String,
todo: String,
}
/// Shared body of the goal-mode system reminder.
///
/// Loaded once at compile time. Used by both `setup_goal` (initial
/// `/goal <objective>`) and `resume_goal` (`/goal resume`).
/// Placeholders are uppercase to avoid collision with the literal
/// `{...}` content in the prompt (e.g. JSON-ish call examples).
pub(super) const GOAL_TASK_DISCIPLINE_TEMPLATE: &str =
include_str!("templates/goal_task_discipline.md");
pub(super) const GOAL_RULES_TEMPLATE: &str = include_str!("templates/goal_rules.md");
/// Plan-aware preamble folded into the goal-rules block when the planner
/// is enabled and a plan exists. Empty on the legacy path.
const GOAL_PLAN_BLOCK_TEMPLATE: &str = include_str!("templates/goal_plan_block.md");
/// Body of the per-turn directive continuation nudge injected when an
/// active goal is still running. Loaded once at compile time. Carries
/// the live token count, the inlined next concrete step, and the
/// proactive-testing reminder. Substituted via
/// [`render_goal_continuation_directive`]; placeholders are lowercase
/// because the template carries no literal JSON-ish `{...}` content
/// that would collide (`goal_rules.md` keeps uppercase placeholders
/// because it embeds verbatim user prose that may contain `{...}`).
///
/// This template prints only `Tokens: N` (not a used/budget/remaining
/// breakdown). A `/goal … --budget N` cap IS enforced at the turn-end
/// continuation gate (terminal `BudgetLimited`), but the remaining-budget
/// line is not rendered into this nudge.
pub(super) const GOAL_CONTINUATION_DIRECTIVE_TEMPLATE: &str =
include_str!("templates/goal_continuation_directive.md");
/// Built continuation directive plus the optional premature-stop pattern that
/// the caller emits when it actually continues. Produced by
/// [`SessionActor::prepare_goal_continuation`].
struct GoalContinuationPlan {
directive: String,
stop_pattern: Option<&'static str>,
/// Recommendation embedded in `directive`, if any; handed to
/// `consume_strategist_note` (compare-and-clear) only once the
/// directive is committed for delivery.
strategy_rec: Option<String>,
}
/// Task scheduling state — the only fields that remain behind `TokioMutex`.
///
/// All chat state (conversation, tokens, timing, prompt_index, prompt_texts,
/// agent_edited_paths, last_compaction_prompt_index, sampling_config) has been
/// fully migrated to `ChatStateActor` via `chat_state_handle`.
/// Credentials (api_key, optional extra access key, client_version) live in
/// the `credentials` sync mutex on `SessionActor`.
pub(crate) struct State {
pub(crate) running_task: Option<AgentTask>,
pub(crate) pending_inputs: VecDeque<InputItem>,
pub(crate) pending_notifications: Vec<PendingNotification>,
/// When true, notifications are buffered but not drained until genuine
/// user re-engagement. Set by interactive Ctrl+C, cleared by a user prompt.
pub(crate) notifications_suppressed: bool,
/// Active prompt is still rewindable until the first outbound prompt-scoped
/// event is emitted.
pub(crate) rewindable: bool,
/// Layer-3 LazinessDetector: number of `<system-reminder>` nudges
/// injected so far in this (session, model) pair. Reset to 0 by
/// the actor's main `select!` loop when its `model_switch_rx`
/// watch channel fires — see the `model_switch_rx.changed()` arm
/// in `run_session`. The cap is therefore per-(session, model):
/// switching models is a deliberate user action that resets
/// expectations.
pub(crate) nudges_used_this_session: u32,
}
impl State {
pub(crate) fn clear_pending_notifications(&mut self) {
self.pending_notifications.clear();
}
/// Prompt id of the in-flight turn, if any. This — not
/// `current_prompt_id` / `is_running_prompt` — is the running-turn
/// identity for queue sweeps: `running_task` lives under the same lock as
/// `pending_inputs`, while `handle_completion` clears `current_prompt_id`
/// before taking this lock, so only `running_task` is race-free here.
pub(crate) fn running_prompt_id(&self) -> Option<&str> {
self.running_task.as_ref().map(|t| t.prompt_id.as_str())
}
/// Sweep `pending_inputs`, removing entries matching `drop_if` EXCEPT the
/// running turn's own slot, and return the removed items (callers harvest
/// them for telemetry counts / reservation releases).
///
/// Returned items still carry live `respond_to` senders that this helper
/// does NOT resolve — dropping them unfulfilled is correct only for
/// synthetic items (no client RPC awaits them, the current callers); a
/// caller whose predicate can match user-originated items must resolve
/// each returned item (see `respond_removed_prompt`) or the
/// client's `session/prompt` hangs and fails spuriously.
///
/// The guard is the safety invariant every sweep must inherit: the
/// in-flight turn stays at the queue front until `handle_completion` or a
/// cancel pops it, and an auto-wake turn's reminder makes the model poll
/// the very task that woke it — so a sweep's predicate can match the
/// running turn's own slot. Deleting it shifts a queued user prompt to
/// index 0, where `cancel_running_task`'s resolve-front rule destroys it
/// (the message never runs and is lost from history). Pid-match, not
/// index 0: it protects exactly the true running slot even while idle or
/// if the queue is already desynced from the front-is-running invariant.
pub(crate) fn sweep_pending_inputs(
&mut self,
drop_if: impl Fn(&InputItem) -> bool,
) -> Vec<InputItem> {
let running_pid = self.running_task.as_ref().map(|t| t.prompt_id.clone());
let mut dropped = Vec::new();
let mut kept = VecDeque::with_capacity(self.pending_inputs.len());
for item in std::mem::take(&mut self.pending_inputs) {
if running_pid.as_deref() != Some(item.prompt_id.as_str()) && drop_if(&item) {
dropped.push(item);
} else {
kept.push_back(item);
}
}
self.pending_inputs = kept;
dropped
}
}
/// Canonical "session is idle and safe to inject a synthetic turn"
/// predicate. The post-turn idle consumers — `maybe_drain_notifications`
/// (notification batching), `maybe_fire_laziness_check` (Layer 3 classifier),
/// and `arm_idle_notification` (idle-notification debounce) — all consult this
/// so they share one definition of idleness, with no drift between them.
///
/// Returns `true` exactly when: no turn is running, no user prompt is
/// queued, and interactive Ctrl+C has not suppressed notifications pending
/// genuine user re-engagement.
pub(crate) fn is_session_idle_for_injection(state: &State) -> bool {
state.running_task.is_none()
&& state.pending_inputs.is_empty()
&& !state.notifications_suppressed
}
/// Predicate behind `SessionCommand::IsBusy`: the session has work in flight
/// when a turn is running **or** inputs are queued. Consulted by the leader's
/// idle-unload decision on client disconnect. Kept as a free function so
/// it can be unit-tested directly against a `State` without spawning a full
/// actor + leader.
pub(crate) fn state_is_busy(state: &State) -> bool {
state.running_task.is_some() || !state.pending_inputs.is_empty()
}
use crate::auth::AuthManager;
#[derive(Clone)]
struct ShellManagedGatewayToolClient {
proxy_base_url: String,
auth_manager: Arc<AuthManager>,
}
#[async_trait::async_trait]
impl xai_grok_tools::types::resources::ManagedGatewayToolCaller for ShellManagedGatewayToolClient {
async fn call_tool(
&self,
call_id: &str,
arguments: serde_json::Value,
caller: &str,
) -> Result<
xai_grok_tools::types::resources::ManagedGatewayToolCallResponse,
xai_tool_runtime::ToolError,
> {
let auth_key = self
.auth_manager
.get_valid_token()
.await
.ok()
.or_else(|| self.auth_manager.current_or_expired().map(|a| a.key))
.ok_or_else(|| xai_tool_runtime::ToolError::unauthorized("no auth token available"))?;
let response = crate::session::managed_mcp::call_gateway_tool(
&self.proxy_base_url,
&auth_key,
call_id,
arguments,
)
.await
.map_err(|error| managed_gateway_error_to_tool_error(error, caller))?;
Ok(
xai_grok_tools::types::resources::ManagedGatewayToolCallResponse {
result: response.result,
connectors_needing_reauth: response.connectors_needing_reauth,
},
)
}
}
fn managed_gateway_error_to_tool_error(
error: crate::session::managed_mcp::ManagedMcpFetchError,
caller: &str,
) -> xai_tool_runtime::ToolError {
match error {
crate::session::managed_mcp::ManagedMcpFetchError::Status { status, message } => {
let detail = format!("Managed MCP gateway tool call failed: {message}");
let mut err = if status == reqwest::StatusCode::UNAUTHORIZED {
xai_tool_runtime::ToolError::unauthorized(detail)
} else if status == reqwest::StatusCode::FORBIDDEN {
xai_tool_runtime::ToolError::permission_denied(detail)
} else {
let tool_id = xai_tool_protocol::ToolId::new(caller)
.unwrap_or_else(|_| xai_tool_protocol::ToolId::new("use_tool").expect("valid"));
xai_tool_runtime::ToolError::execution(tool_id, detail)
};
match err.details.as_mut() {
Some(serde_json::Value::Object(map)) => {
map.insert(
HTTP_STATUS_DETAILS_KEY.to_string(),
serde_json::json!(status.as_u16()),
);
}
_ => {
err.details =
Some(serde_json::json!({ HTTP_STATUS_DETAILS_KEY : status.as_u16(), }));
}
}
err
}
crate::session::managed_mcp::ManagedMcpFetchError::Transport(e) => {
xai_tool_runtime::ToolError::network_error(format!(
"Managed MCP gateway tool call failed: {}",
e.without_url()
))
}
crate::session::managed_mcp::ManagedMcpFetchError::NoAuth => {
xai_tool_runtime::ToolError::unauthorized("no auth token available")
}
}
}
#[cfg(test)]
mod managed_gateway_error_tests {
use super::*;
fn status_error(code: u16, message: &str) -> crate::session::managed_mcp::ManagedMcpFetchError {
crate::session::managed_mcp::ManagedMcpFetchError::Status {
status: reqwest::StatusCode::from_u16(code).unwrap(),
message: message.to_string(),
}
}
#[test]
fn unauthorized_status_maps_to_unauthorized_and_carries_status() {
let err = managed_gateway_error_to_tool_error(status_error(401, "expired"), "use_tool");
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::Unauthorized);
assert!(err.detail.contains("expired"));
let details = err.details.as_ref().unwrap();
assert_eq!(
details.get(HTTP_STATUS_DETAILS_KEY),
Some(&serde_json::json!(401))
);
}
#[test]
fn forbidden_status_maps_to_permission_denied_and_carries_status() {
let err = managed_gateway_error_to_tool_error(status_error(403, "denied"), "use_tool");
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::PermissionDenied);
let details = err.details.as_ref().unwrap();
assert_eq!(
details.get(HTTP_STATUS_DETAILS_KEY),
Some(&serde_json::json!(403))
);
}
#[test]
fn general_status_maps_to_execution_with_caller_tool_id() {
let err = managed_gateway_error_to_tool_error(status_error(500, "boom"), "CallMcpTool");
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::Execution);
let details = err.details.as_ref().unwrap();
assert_eq!(
details.get(HTTP_STATUS_DETAILS_KEY),
Some(&serde_json::json!(500))
);
assert_eq!(
details.get("tool_id"),
Some(&serde_json::json!("CallMcpTool"))
);
}
#[test]
fn general_status_falls_back_to_use_tool_for_unknown_caller() {
let err = managed_gateway_error_to_tool_error(status_error(500, "boom"), "not a tool id");
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::Execution);
let details = err.details.as_ref().unwrap();
assert_eq!(details.get("tool_id"), Some(&serde_json::json!("use_tool")));
}
#[test]
fn no_auth_maps_to_unauthorized() {
let err = managed_gateway_error_to_tool_error(
crate::session::managed_mcp::ManagedMcpFetchError::NoAuth,
"use_tool",
);
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::Unauthorized);
}
#[tokio::test]
async fn transport_error_maps_to_network_error_without_url() {
let transport = reqwest::Client::new()
.post("http://127.0.0.1:1/mcp/tools/call")
.send()
.await
.expect_err("connection to a dead port should fail");
let err = managed_gateway_error_to_tool_error(
crate::session::managed_mcp::ManagedMcpFetchError::Transport(transport),
"use_tool",
);
assert_eq!(err.kind, xai_tool_runtime::ToolErrorKind::NetworkError);
assert!(err.detail.contains("Managed MCP gateway tool call failed"));
assert!(
!err.detail.contains("http://"),
"transport detail must not leak the proxy URL: {}",
err.detail
);
}
}
/// Data carried from prepare_tool_call → dispatch_tool → finalize.
#[derive(Debug, Clone)]
pub(crate) struct PreparedToolCall {
/// The model's tool call ID (for tool_result matching).
call_id: String,
/// ACP-internal tool call ID.
tool_call_id: acp::ToolCallId,
/// The tool name as requested by the model.
tool_name: String,
/// The raw arguments string (for post_tool_use hook payload).
raw_arguments: String,
/// Parsed JSON arguments ready for bridge.call().
parsed_args: serde_json::Value,
/// Model ID at time of call.
model_id: String,
/// Whether concatenated JSON recovery was used, and how many objects were found.
concatenated_json_count: usize,
/// Resolved target for meta-dispatch tools (`use_tool`, `CallMcpTool`);
/// `None` for ordinary tools. See [`ToolInput::dispatch_target_name`].
dispatch_target_name: Option<String>,
/// Read-only per `ToolKind`; decides whether the call takes the per-file lock.
is_read_only: bool,
}
impl PreparedToolCall {
/// The tool name hooks see: the resolved dispatch target, else the wire name.
/// The single source for the resolved name across the dispatch-phase hook
/// events (PostToolUse / PostToolUseFailure) and their telemetry labels.
pub(crate) fn hook_tool_name(&self) -> &str {
self.dispatch_target_name
.as_deref()
.unwrap_or(&self.tool_name)
}
}
#[cfg(test)]
pub(crate) use crate::session::streaming_capture::STREAMING_CAPTURE_MAX_BYTES;
pub(crate) use crate::session::streaming_capture::StreamingTurnCapture;
/// One memoized model's auth state, keyed by model id; see
/// [`SessionActor::model_auth_memo`] for the invalidation contract.
#[derive(Clone)]
pub(crate) struct ModelAuthMemo {
pub(crate) model_id: String,
pub(crate) facts: crate::agent::config::ModelAuthFacts,
pub(crate) provider: Option<crate::auth::AuthProviderRef>,
}
/// Phase 3: Post-flight handling after dispatch (inline in execute_tool_calls for now).
pub(crate) struct SessionActor {
pub(crate) session_info: SessionInfo,
/// Shared live handle to the current ACP auth method. Normal sessions hold a
/// clone of `MvpAgent::auth_method_id`, so a mid-session `/login` is picked
/// up by the per-turn auth gate without re-spawning; subagents instead get a
/// fresh, isolated handle seeded once at spawn (frozen for their lifetime).
/// `None` until the agent has selected a method.
pub(crate) auth_method_id: crate::agent::auth_method::SharedAuthMethodId,
/// Memoized per-model auth state, read through
/// [`SessionActor::model_auth_facts`] and
/// [`SessionActor::model_auth_provider`].
///
/// A fresh `Unknown` (config currently unparseable) falls back to the
/// last definite value for the same model rather than demoting a live
/// session to non-refreshable api-key mode. Because a config edit can
/// turn the selected model into a per-model BYOK model without changing
/// its id, keying on the id alone is insufficient: each model/credential
/// chokepoint must clear this memo (`replace(None)`).
pub(crate) model_auth_memo: std::cell::RefCell<Option<ModelAuthMemo>>,
/// 401-attribution callback. Joined with the bearer the
/// sampler sends on the wire to emit an `auth 401 attribution`
/// event at each of the six `OaiCompatClient` 401 arms in
/// `xai-grok-sampler`. Threaded into every `SamplerConfig`
/// reconstructed by `reconstruct_full_config`. `None` when the
/// session was spawned without an `AuthManager` (BYOK direct
/// mode, test fixtures).
pub(crate) attribution_callback: Option<xai_grok_sampler::SharedAttributionCallback>,
/// Auth manager. Owns the token refresher internally (via
/// `configure_refresher()`) and is also used for non-sampler
/// 401 attribution sites: the sampler-side path goes through
/// `attribution_callback` above, while the idle-resume model
/// refresh in this file calls
/// `crate::auth::attribution::record_auth_401` directly using
/// this handle. `None` for tests / BYOK that don't need refresh
/// or the attribution emit.
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) state: TokioMutex<State>,
/// Notification transport: gateway, persistence channel, replay buffer.
pub(crate) notifications: NotificationSender,
pub(crate) permissions: PermissionHandle,
pub(crate) tool_context: ToolContext,
/// Managed Read-deny glob patterns, resolved once at construction and
/// (re-)injected into the ToolBridge so the Grep tool excludes policy-forbidden
/// paths. Actor-retained so session setup and harness-rebuild share one source
/// of truth (mirrors `goal_update_tx`), rather than re-resolving the config.
pub(crate) deny_read_globs: Vec<String>,
/// Consolidated MCP state (configs, clients, init status) protected by a single lock.
/// This ensures atomicity when updating configs or checking initialization status.
pub(crate) mcp_state: Arc<TokioMutex<McpState>>,
/// MCP initialization strategy
pub(crate) mcp_strategy: McpInitStrategy,
/// Actor-based chat state handle — manages conversation, tokens, timing, and persistence.
/// Also stores credentials (api_key, optional extra access key,
/// client_version) opaquely.
pub(crate) chat_state_handle: xai_chat_state::ChatStateHandle,
/// Current running prompt/turn id, shared with SessionHandle.
pub(crate) current_prompt_id: std::sync::Arc<std::sync::Mutex<Option<String>>>,
/// Open blocking reverse-requests (permission / question / plan-approval),
/// keyed by `tool_call_id`. Shared with `SessionHandle` so the roster can
/// read it synchronously to surface `NeedsInput`. Mutated by
/// `PendingInteractionGuard` at each reverse-request site. Never persisted.
pub(crate) pending_interactions: crate::session::pending_interaction::PendingInteractions,
/// Gates product analytics, not trace uploads. Resolved at spawn as
/// `is_telemetry_enabled() && !is_zdr()` — ZDR teams always have this false.
pub(crate) telemetry_enabled: bool,
pub(crate) supports_backend_search: std::cell::Cell<bool>,
pub(crate) compactions_remaining:
std::cell::Cell<Option<xai_grok_sampling_types::CompactionsRemaining>>,
pub(crate) compaction_at_tokens:
std::cell::Cell<Option<xai_grok_sampling_types::CompactionAtTokens>>,
/// Server-side doom-loop check policy, resolved once at spawn by
/// `Config::resolve_doom_loop_recovery`; `None` = disabled.
/// `reconstruct_full_config` threads it into the sampler config, and the
/// sampler itself sends the matching `x-grok-doom-loop-check` header.
pub(crate) doom_loop_recovery: Option<xai_grok_sampling_types::DoomLoopRecoveryPolicy>,
/// Telemetry-only per-turn doom-loop recovery tally (attempts, whether a
/// budget-spent accept happened, tightest trigger label). Accumulated by
/// the event drainer, taken at turn end for the per-turn analytics event.
pub(crate) doom_loop_turn_tally: parking_lot::Mutex<crate::session::signals::DoomLoopTurnTally>,
/// File state tracker for rewind functionality
pub(crate) file_state_tracker: Arc<FileStateTracker>,
/// Last prompt text before the most recent rewind.
/// When set, the next `prompt()` compares its text to distinguish
/// regeneration (same text) from edit-and-retry (different text).
pub(crate) rewind_pending_prompt: std::sync::Mutex<Option<String>>,
/// Startup hints for the session: currently responsible for customizing the user message prefix and the git status mode (fast no untracked for non-interactive mode)
pub(crate) startup_hints: StartupHints,
/// Verbatim mirror-fork override: when `Some`, every turn sends this exact
/// parent tool schema instead of the locally-built toolset, keeping the
/// child's request prefix byte-identical to the parent for radix cache reuse.
/// `None` for all non-fork (and summarized-fork) sessions.
pub(crate) forked_tool_override: Option<Vec<ToolSpec>>,
/// Compaction configuration and runtime state.
pub(crate) compaction: super::compaction_config::CompactionConfig,
/// Memory subsystem: storage, flush config, injection state, telemetry.
pub(crate) memory: super::memory_state::SessionMemory,
/// Telemetry counters for session summary.
pub(crate) session_start: std::time::Instant,
/// Per-chunk idle timeout for inference streaming. If no SSE chunk is received
/// within this duration, the stream is aborted with a non-retryable error.
/// Resolved at construction: per-model config.toml → remote settings → 300s default.
pub(crate) inference_idle_timeout: Duration,
pub(crate) max_retries: u32,
/// Maximum tool-use turns before the session stops. `None` = unlimited.
pub(crate) max_turns: Option<usize>,
/// Pending mid-turn interjections from the user (Ctrl+Enter).
/// Pushed by `SessionCommand::Interject` handler, drained at safe
/// points in `process_conversation_turn`. Internally synchronized.
pub(crate) pending_interjections: InterjectionBuffer<acp::ImageContent>,
/// Skill-announcement reminders that arrived while a turn was running,
/// flushed at the same safe points as `pending_interjections` plus on
/// cancel/idle. The flush also delivers the plan tracker's buffered
/// mid-turn activation reminder (see `activate_plan_mode_mid_turn`).
pub(crate) pending_skill_reminders: Mutex<Vec<ConversationItem>>,
/// Idle flush timeout: `None` = disabled, `Some(duration)` = flush after inactivity.
pub(crate) idle_flush_timeout: Option<std::time::Duration>,
/// Periodic dream check interval: `None` = disabled.
pub(crate) dream_check_timeout: Option<std::time::Duration>,
/// Conversation length at last idle flush — skip if unchanged (no new messages).
pub(crate) last_idle_flush_conversation_len: std::sync::atomic::AtomicUsize,
/// Internal event queue for actor-owned replay buffering and flush barriers.
pub(crate) event_tx: mpsc::UnboundedSender<SessionEvent>,
/// Buffering settings captured at session creation. The concrete ReplayBuffer
/// is owned by `run_session()`.
pub(crate) buffering_settings: Option<BufferingSettings>,
/// Client identifier for telemetry - passed from the MvpAgent (extracted from initialize meta)
pub(crate) client_identifier: Option<String>,
/// Origin client for User-Agent on sampling requests.
pub(crate) origin_client: Option<crate::http::OriginClientInfo>,
/// Feedback manager for signal tracking and feedback request heuristics
pub(crate) feedback_manager: Arc<FeedbackManager>,
pub(crate) upload_queue:
std::sync::Arc<std::sync::OnceLock<xai_file_utils::queue::UploadQueue>>,
/// Cancellation token for the feedback sync loop (None if no feedback client)
pub(crate) sync_loop_cancel: Option<tokio_util::sync::CancellationToken>,
/// The fully-built Agent: owns the ToolBridge, system prompt, policies,
/// and the AgentDefinition. Replaces the old `tool_bridge` + `agent_definition` fields.
/// Wrapped in `RefCell` for mid-session mutation (skill refresh, prompt regen).
/// Safe: session actor is single-threaded (LocalSet), no concurrent access.
pub(crate) agent: std::cell::RefCell<xai_grok_agent::Agent>,
/// Dedup slot for `x.ai/git_head_changed`, shared with the fs-watch
/// `GitHead` consumer (see `git_head_dedup_key`).
pub(crate) last_reported_branch: Arc<parking_lot::Mutex<Option<String>>>,
/// Client opted into `x.ai/gitHeadChanged`. When false (headless/SDK),
/// `maybe_notify_git_branch` no-ops — no git subprocess.
git_head_enabled: bool,
/// Shared models manager for etag-triggered refresh from response headers.
pub(crate) models_manager: crate::agent::models::ModelsManager,
/// Stable display path for forked sessions (original project path).
///
/// Used by `build_user_message_prefix` (user-message `Workspace Path`),
/// `PathRewriter` (tool result path sanitization), and hunk tracker
/// (client-facing diff paths). The system prompt's `Workspace Path` is
/// set at build time via `AgentBuilder::with_prompt_working_directory()`.
///
/// Set once at session spawn from the `prompt_display_cwd` parameter
/// (e.g. for forked sessions that should display the original project
/// path). Uses `OnceLock` for lock-free reads, set-once semantics, and
/// `&self` mutability (SessionActor is behind `Arc`).
pub(crate) display_cwd: std::sync::OnceLock<String>,
/// The active agent type for this session. Initialized from the
/// `AgentDefinition` at spawn, updated when the session mode changes
/// via `handle_session_mode()`. Used by the model-switch guard to
/// determine whether a model's `agent_type` is compatible with the
/// current session.
pub(crate) active_agent_type: parking_lot::Mutex<Option<String>>,
/// Live gate shared with the notification bridge (see
/// `NotificationBridgeConfig::queue_exit_reminder_on_approved_exit`).
/// Seeded at spawn from the agent definition's harness; refreshed by
/// `handle_rebuild_agent_for_definition` so the bridge always agrees
/// with the live harness gate.
pub(crate) queue_exit_reminder_on_approved_exit: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// First skill the current prompt activated via its slash-skill path,
/// recorded as `skill.name` on the turn span. Reset at the start of each
/// prompt (`handle_prompt`), so it never leaks across turns.
pub(crate) active_skill: parking_lot::Mutex<Option<String>>,
/// Canonical session mode last set via ACP `session/set_mode`.
/// Used as the fallback start prompt mode when prompt request metadata
/// does not explicitly provide one.
pub(crate) current_prompt_mode: Arc<parking_lot::Mutex<PromptMode>>,
/// Prompt mode captured at the start of the current turn. Set once in
/// `handle_prompt` and never modified during the turn. Used for
/// `start_prompt_mode` telemetry.
pub(crate) turn_start_prompt_mode: parking_lot::Mutex<PromptMode>,
/// Effective mode of the currently running turn. Set at turn start from
/// the prompt mode parameter, then updated only by agent-initiated tool
/// calls (`EnterPlanMode` / `ExitPlanMode`). NOT affected by
/// `session/set_mode` (which only changes the next turn's start mode).
/// Read at turn end for `end_prompt_mode` telemetry.
pub(crate) turn_prompt_mode: Arc<parking_lot::Mutex<PromptMode>>,
/// Plan mode lifecycle tracker. Session-scoped dynamic state (not part
/// of `AgentDefinition`). All plan mode logic lives in `plan_mode.rs`;
/// the session actor just calls into the tracker at the appropriate points.
/// `Arc`-shared with the notification bridge so `PlanModeEntered` /
/// `PlanModeExited` tool notifications can transition state directly.
pub(crate) plan_mode: Arc<parking_lot::Mutex<crate::session::plan_mode::PlanModeTracker>>,
/// Whether goal mode (`/goal`) is enabled for this session (feature flag).
pub(crate) goal_enabled: bool,
/// `goal_enabled` && `update_goal` in toolset; refreshed with command availability.
goal_harness_enabled: std::sync::atomic::AtomicBool,
/// One-shot: auto-pause persisted Active goal when harness is unavailable.
goal_harness_availability_reconciled: std::sync::atomic::AtomicBool,
/// Goal mode orchestration tracker. Session-scoped state for the
/// Design-Execute-Verify loop. Modeled after `plan_mode` above.
pub(crate) goal_tracker: Arc<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>,
/// `task_id`s of background tasks (and monitors) that originated during
/// the goal turn — either spawned by the goal model itself or reparented
/// from a harness verifier/planner subagent on its exit. Their late
/// auto-wake completions are dropped by [`Self::maybe_drain_notifications`]
/// regardless of the goal's current status, so a leftover dev/verification
/// server that completes after the run ended (Blocked / paused / cleared)
/// cannot wake the idle parent. Reset when a new goal starts or the goal
/// is cleared.
pub(crate) goal_turn_task_ids: parking_lot::Mutex<std::collections::HashSet<String>>,
/// Consecutive non-completing (cancelled/errored) goal-mode turns while
/// the goal is `Active`. Reset to 0 on a successful turn or on user
/// `/goal resume`. Auto-pauses the goal with `GoalPauseReason::BackOff`
/// once the counter reaches [`GOAL_CONTINUATION_BACKOFF_THRESHOLD`].
/// In-memory only — session restart is itself a reset.
pub(crate) goal_continuation_streak: std::sync::atomic::AtomicU32,
/// Consecutive blocked attempts from the model. Reset on successful
/// turn completion, goal completion, or goal resume. Only after 3
/// consecutive blocked attempts does the goal actually pause.
pub(crate) goal_blocked_streak: std::sync::atomic::AtomicU32,
/// Receiver for goal-update envelopes from the `update_goal` tool.
/// Wrapped in `Option` so the drainer task can `.take()` it at
/// session start; tests put a fresh receiver back via
/// `seed_channel` helpers.
pub(crate) goal_update_rx: std::cell::RefCell<
Option<
tokio::sync::mpsc::UnboundedReceiver<
xai_grok_tools::implementations::grok_build::update_goal::UpdateGoalEnvelope,
>,
>,
>,
/// Sender half of the goal-update channel, retained so a mid-session
/// harness rebuild can re-register the `GoalUpdateHandle` on the fresh,
/// empty ToolBridge. The `rx` half is owned by the drainer task (see
/// `goal_update_rx`).
pub(crate) goal_update_tx: tokio::sync::mpsc::UnboundedSender<
xai_grok_tools::implementations::grok_build::update_goal::UpdateGoalEnvelope,
>,
/// Resolved master kill-switch for the verification stage (the
/// adversarial skeptic panel). `false` short-circuits
/// `drain_goal_updates` to plain `tracker.complete()` + ack
/// `CompletedWithoutClassifier` and BYPASSES the verification
/// stage. `true` enables the skeptic panel with all guards
/// (Active-only, mid-turn defer, in-flight re-entry, max-runs
/// cap). The field name is preserved to keep
/// the env / remote / config wire contract stable.
pub(crate) goal_classifier_enabled: bool,
/// Master switch for the goal planner subagent.
pub(crate) goal_planner_enabled: bool,
/// Master switch for the one-shot goal summarizer (the closing
/// "what was accomplished" summary on a verified achievement).
/// Cached at actor construction (mirrors `goal_classifier_enabled`);
/// absent remote setting tracks goal mode, `Some(false)` is a kill-switch.
pub(crate) goal_summary_enabled: bool,
/// Resolved skeptic count for the verification stage.
/// Cached at actor construction (mirrors `goal_classifier_enabled`)
/// and threaded into [`Self::run_verification_stage_for_drain`].
/// Default `GOAL_VERIFIER_SKEPTIC_COUNT`; clamped to
/// `[GOAL_VERIFIER_SKEPTIC_MIN, GOAL_VERIFIER_SKEPTIC_MAX]` by the
/// resolver.
pub(crate) goal_verifier_skeptic_count: u32,
/// Resolved per-role `/goal` model selection (planner / strategist
/// single pairs + the ordered skeptic pool), with the kill-switch
/// already applied. Cached at actor construction from remote settings;
/// `Default` (all `InheritCurrent`, empty pool) reproduces today's
/// behavior. Consumed by the per-role spawn wiring.
pub(crate) goal_role_models: GoalRoleModelConfig,
/// Kill-switch (`GROK_GOAL_USE_CURRENT_MODEL_ONLY` / `[features]
/// goal_use_current_model_only`) resolved at actor build. When `true`,
/// every `/goal` role inherits the current model. `goal_role_models`
/// already reflects it (planner/strategist `InheritCurrent`, empty pool),
/// but the skeptic panel also checks this flag directly so a
/// previously-frozen `skeptic_model_assignment` is overridden too — an
/// instant rollback even for an already-frozen goal.
pub(crate) goal_use_current_model_only: bool,
/// Resolved per-goal classifier run cap (number of
/// `update_goal(completed: true)` rejections before the goal
/// auto-pauses via `BackOff`). Cached at actor construction like
/// `goal_verifier_skeptic_count`. Default
/// `GOAL_CLASSIFIER_MAX_RUNS_DEFAULT`; floored at
/// `GOAL_CLASSIFIER_MAX_RUNS_MIN` with no upper ceiling by the
/// resolver. Read by [`Self::resolve_goal_classifier_policy`].
pub(crate) goal_classifier_max_runs: u32,
/// Resolved N for the stall-triggered strategist: it fires every N
/// consecutive `NotAchieved` verifications (and again at 2N, 3N, …).
/// Cached at actor construction. Default `max(1, goal_classifier_max_runs
/// / 2)`; clamped to `>= 1` by the resolver. Read by the strategist
/// trigger in `apply_classifier_outcome`.
pub(crate) goal_strategist_every: u32,
/// Resolved refuted-goal round count before the continuation directive
/// escalates to a forceful "re-verify now" block; cached at actor
/// construction. Read by [`Self::prepare_goal_continuation`].
pub(crate) goal_reverify_after: u32,
/// Set on session load once `maybe_reconcile_active_goal_without_plan`
/// has run so subsequent prompt-flow ticks don't repeat the
/// pause-on-load check.
pub(crate) goal_plan_reconciled: std::sync::atomic::AtomicBool,
/// FIFO of mid-turn-deferred `completed: true` inputs. The
/// envelope's ack was resolved with `DeferredToTurnEnd` at defer
/// time; only the input is parked here for the TurnEnd drain to
/// run through the verification stage.
pub(crate) pending_classifier_completions: parking_lot::Mutex<
VecDeque<xai_grok_tools::implementations::grok_build::update_goal::UpdateGoalInput>,
>,
/// Per-session re-entry guard for the verification stage. Set with
/// `compare_exchange(false, true)` at fire-entry and cleared on
/// result. A second `completed: true` that races in while the
/// flag is set short-circuits through
/// [`Self::account_not_achieved_without_sampler`].
pub(crate) goal_classifier_in_flight: std::sync::atomic::AtomicBool,
/// Agent-level managed MCP config cache (refreshed in background).
pub(crate) managed_mcp_handle: crate::session::managed_mcp::ManagedMcpStateHandle,
/// Earliest managed MCP token expiry; checked before tool dispatch.
pub(crate) managed_mcp_expires_at: std::sync::Mutex<Option<chrono::DateTime<chrono::Utc>>>,
/// Original client-provided MCP servers from session creation.
/// Retained for re-merge during plugin reload.
pub(crate) initial_client_mcp_servers: Vec<acp::McpServer>,
/// Shared MCP tool metadata for the BM25 search index. Updated after MCP init.
pub(crate) tool_metadata_snapshot:
Arc<std::sync::Mutex<crate::session::tool_index::ToolMetadataSnapshot>>,
/// Tracks which servers have been announced via system-reminder, for
/// change detection. Maps server_name -> (tool_count, description_hash).
pub(crate) mcp_announced_servers:
Mutex<HashMap<String, xai_grok_tools::implementations::search_tool::ServerFingerprint>>,
/// Controls whether MCP server reminders inject only changes (Delta)
/// or the full server list (Full). Read from `MCP_REMINDER_MODE` env var.
pub(crate) mcp_reminder_mode: McpReminderMode,
/// Set when the MCP server set changes and a reminder needs injection.
/// Cleared by `maybe_inject_mcp_reminder` after injecting.
pub(crate) mcp_reminder_dirty: Arc<std::sync::atomic::AtomicBool>,
pub(crate) mcp_connecting_reminder_injected: std::cell::Cell<bool>,
/// Wakes waiters when MCP background handshakes finish and
/// `initializing_servers` is cleared. Used by
/// `wait_for_mcp_templated_prefix_ready` to avoid polling.
pub(crate) mcp_handshakes_done: Arc<tokio::sync::Notify>,
/// Background-computed user-message prefix, injected before the first prompt.
pub(crate) deferred_prefix: TaskSlot<String>,
/// Extensions to notify at turn and session lifecycle edges. Built once by `session_extension_registry` at actor construction and frozen after.
pub(crate) extension_registry: xai_agent_lifecycle::LocalExtensionRegistry,
/// Local calendar date last surfaced to the model — either stamped into the
/// `<user_info>` prefix (at session start, compaction, or resume) or
/// announced via a date-rollover `<system-reminder>`. Drives
/// [`SessionActor::maybe_inject_date_rollover_reminder`] (date
/// rollover: tell the model the date advanced when a long session crosses
/// local midnight, since the cached prefix isn't re-stamped per turn). The
/// actor is single-threaded, so a `Cell` suffices.
pub(crate) last_announced_local_date: std::cell::Cell<chrono::NaiveDate>,
/// Prompt index when search_tool last ran. -1 = never. Used for turns_since_last_search.
pub(crate) last_search_prompt_index: std::sync::atomic::AtomicI64,
/// Timestamp (millis since epoch) of the last successful API request.
/// Used to detect session resume after idle and proactively refresh model metadata.
pub(crate) last_api_request_at: std::sync::atomic::AtomicI64,
/// Hook registry for session lifecycle and tool event hooks.
/// Loaded at session startup; can be updated mid-session via `/plugins reload`.
/// `None` when no plugin registry was supplied at spawn time.
/// Wrapped in `RefCell` for mid-session reload from `&self` methods.
/// Safe: session actor is single-threaded (LocalSet), no concurrent access.
pub(crate) hook_registry:
std::cell::RefCell<Option<Arc<xai_grok_hooks::discovery::HookRegistry>>>,
/// Client hooks from `session/new` `_meta["x.ai/hooks"]`; gated in
/// [`crate::session::acp_session::hooks`]. `RefCell` so `load_session` reconnect can
/// replace the set on the live actor (see `SessionCommand::SetClientHooks`).
pub(crate) client_hooks: std::cell::RefCell<crate::extensions::hooks::ClientHooks>,
/// Resolved workspace root for hooks: git worktree root if in a git repo,
/// otherwise session cwd. Used for hook child process cwd, envelope fields,
/// and GROK_WORKSPACE_ROOT env var.
pub(crate) hook_resolved_workspace_root: String,
/// The detected VCS kind for this session's workspace.
pub(crate) vcs_kind: xai_grok_workspace::session::git::VcsKind,
/// Errors from last hook config load (parse failures, etc.).
pub(crate) hook_load_errors: std::cell::RefCell<Vec<String>>,
/// Plugin registry snapshot for this session. Updated on `/plugins reload`.
/// `RefCell` for mid-session reload from `&self` methods.
pub(crate) plugin_registry:
std::cell::RefCell<Option<std::sync::Arc<xai_grok_agent::plugins::PluginRegistry>>>,
/// Shared handle to the agent-level plugin registry.
/// Used by `/plugins reload` to trigger a rebuild that new sessions see.
pub(crate) plugin_registry_handle: Option<xai_grok_agent::plugins::SharedPluginRegistryHandle>,
/// Centralized event tracking: event log, turn-end guard, active tool,
/// doom loop terminate flag. All event-related state lives here.
pub(crate) events: crate::session::events::EventTracker,
/// Optional hub-side session event emitter (always constructed without a
/// harness client in the agent; methods no-op with `None` transport).
pub(crate) observability_bridge: xai_computer_hub_sdk::ObservabilityBridge,
/// Turn number captured at the start of each turn (before prompt index
/// increment). Used by `ToolCallStarted` bridge emissions so they
/// report the same turn number as `TurnStarted` / `TurnEnded`.
pub(crate) current_turn_number: std::cell::Cell<u64>,
/// Recap rate-limit watermark (`main_turns` of last finished recap; `0` = none).
pub(crate) last_recap_main_turn: std::cell::Cell<usize>,
/// True while a recap model call is in flight (auto or manual). Prevents
/// concurrent `spawn_local` recaps from racing watermark restore.
pub(crate) recap_in_flight: std::cell::Cell<bool>,
/// Bumped on each real user prompt (queue accept + turn start); in-flight
/// recap suppresses emit if this changes before commit.
pub(crate) recap_epoch: std::cell::Cell<u64>,
/// True while THIS session has a prompt turn in flight (RAII-guarded in
/// `handle_prompt`, like `tool_context.is_turn_active` — which is the
/// agent-wide coordinator flag shared by all sessions and so unusable
/// for per-session decisions). `Arc` so it can be re-checked inside the
/// chat-state actor's `RepairHistory` handler.
pub(crate) session_turn_active: Arc<std::sync::atomic::AtomicBool>,
/// Per-turn accumulator for the model's streamed generations, populated by
/// `handle_sampling_event` while the sampler is streaming. Each
/// `SamplingEvent::Completed` discards the in-progress generation (committed
/// to `afterStateHistory`) without wiping the capture, so a same-turn
/// doomloop retry's earlier uncommitted generations are preserved.
///
/// On user-cancel mid-stream, a sampler terminal failure (e.g.
/// `MaxTokensTruncation`), or a doomloop, the consumer takes the capture via
/// `SessionCommand::TakeStreamingCapture`, which `finalize_for_upload`s the
/// uncommitted generations and uploads them as
/// `{session_id}/turn_N/streaming_partial.json` for trace inspection. See
/// [`crate::session::streaming_capture`].
///
/// **This is deliberately out-of-band from `chat_state`.** The partial
/// is never returned by `BuildConversationRequest`, never pushed via
/// `push_assistant_response`, and never sent to the model on subsequent
/// turns. Trace-only, inspection-only.
///
/// **Known tail race:** if a queued prompt's `StreamStarted` arrives at
/// the actor's `select!` between cancel-time and `TakeStreamingCapture`,
/// the live slot is reset with the new turn's prompt-id before the
/// take. The take handler then sees a prompt-id mismatch and returns
/// `None`, dropping the cancelled turn's `streaming_partial.json`.
/// A `tracing::warn!` tripwire in the handler logs every occurrence so
/// we can quantify the loss in production before investing in a stash.
pub(crate) streaming_turn_capture: parking_lot::Mutex<StreamingTurnCapture>,
/// Per-turn barrier that orders the streamed message against the turn's
/// tool calls.
///
/// The sampler's events (text/thought chunks) are emitted by a separate
/// drainer task (`handle_sampling_event`), while the turn loop emits the
/// canonical client `ToolCall` notifications itself after
/// `run_turn_via_sampler` returns. Both call `send_update`, which allocates
/// the process-global, monotonically-increasing `eventId` AT CALL TIME (see
/// `generate_event_id`). Because the two run as distinct tasks on the
/// session `LocalSet`, the tool call's `send_update` could interleave
/// BETWEEN two still-draining text chunks — allocating an `eventId` mid
/// message and splitting the assistant text around the tool call on every
/// attached client (the eventId order is what clients render in).
///
/// To keep all of a turn's `eventId`s in stream order, `run_turn_via_sampler`
/// installs a sender here before submitting and awaits the receiver after the
/// response arrives; the drainer fires it the moment it processes the
/// terminal `SamplingEvent::Completed` (every text/thought chunk has been
/// `send_update`d by then). `None` between turns.
pub(crate) turn_stream_drained: parking_lot::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
/// Handle to the per-session `xai-grok-sampler` actor.
///
/// Live sessions get a real handle from `spawn_session_actor`;
/// tests and other constructor sites use `SamplerHandle::noop()`.
/// All inference flows through this handle.
pub(crate) sampler_handle: xai_grok_sampler::SamplerHandle,
/// Cached recipe for constructing this session's [`xai_grok_agent::Agent`].
///
/// Populated once at session spawn and then reused by
/// `handle_rebuild_agent_for_definition` to build a fresh `Agent`
/// (system prompt, [`xai_grok_tools::bridge::ToolBridge`], tool
/// registry, tool name aliases, compaction policy, and reminder
/// policy) when the user picks a model with a different
/// `agent_type` before sending any user message.
///
/// See [`crate::session::agent_rebuild`] for the canonical-construction
/// invariant.
pub(crate) rebuild_spec: Arc<crate::session::agent_rebuild::AgentRebuildSpec>,
/// Resolved vision model ID for auxiliary image processing.
/// Populated from `Config.image_description_model` at spawn.
pub(crate) image_description_model: String,
/// Cache auxiliary image outputs by content and prompt fingerprint.
pub(crate) image_describe_cache: Arc<crate::session::image_describe::ImageDescribeCache>,
/// Per-subagent token state keyed by `subagent_id`; sums into
/// goal totals via [`Self::goal_tokens`].
pub(crate) subagent_token_records: parking_lot::Mutex<HashMap<String, SubagentTokenRecord>>,
pub(crate) workspace_ops: xai_grok_workspace::WorkspaceOps,
/// Template for building trace configs on synthetic auto-wake turns.
/// Captured from the first real user prompt's trace config so synthetic
/// turns can upload artifacts using the same bucket/method.
pub(crate) trace_config_template: std::cell::RefCell<Option<TraceConfigTemplate>>,
/// Layer-3 LazinessDetector: monotonic counter bumped whenever a
/// fresh (non-synthetic) user prompt arrives at the actor.
/// `maybe_fire_laziness_check` snapshots the value at start and
/// polls for changes in its idle-wait loop.
///
/// **vs. `tokio::sync::Notify`** (the original design):
/// generation-counter snapshot+compare avoids the stored-permit
/// hazard. A `notify_one()` emitted before the classifier spawns
/// would cause the spawn-later `.notified()` arm to fire
/// immediately, aborting the classifier on the very first idle
/// period after any real turn. An `AtomicU64` has no such hazard.
///
/// **vs. `tokio::sync::watch::Sender<u64>`** (the mirror design
/// used for `ModelsManager::model_switch_watch`): single-consumer
/// cardinality here. The only reader of `user_input_generation`
/// is the per-actor laziness task's snapshot+compare; no
/// main-loop subscriber needs a wake-on-change for user input
/// (the prompt handler is itself the *producer*, in the same
/// task). `tokio::sync::watch::Sender` is internally lock-bearing
/// (an `RwLock<T>` per `tokio` source), so adopting it for this
/// field would re-introduce a per-actor lock for a use case an
/// `AtomicU64` already covers correctly. Model-switch differs
/// because its main-loop arm DOES need a wakeup to zero the
/// per-session nudge counter — the watch channel's `.changed()`
/// is the right primitive there.
pub(crate) user_input_generation: std::sync::atomic::AtomicU64,
/// Session-scoped `--laziness-debug-log <path>`. When `Some`, the
/// Layer-3 classifier fires after every turn end (bypassing the
/// idle wait, the per-model enable gate, and the nudge cap), and
/// the full outcome is appended as a JSONL line to this file.
/// Observation-only — no nudges are ever injected when this is
/// `Some`. `Arc<Path>` because the path is immutable after
/// session spawn; concurrent appends rely on `O_APPEND`'s atomic
/// guarantee for writes under `PIPE_BUF` (JSONL lines fit).
pub(crate) laziness_debug_log: Option<std::sync::Arc<std::path::Path>>,
}
/// Template for building trace configs on synthetic auto-wake turns.
///
/// Captured from the first real user prompt's `TraceExportConfig` so
/// synthetic turns can upload artifacts to the same GCS bucket using
/// the same upload method (direct / proxy).
#[derive(Clone)]
pub(crate) struct TraceConfigTemplate {
pub(crate) bucket_url: Option<String>,
pub(crate) upload_method: crate::session::repo_changes::UploadMethod,
}
impl SessionActor {
/// Get the signals handle for tracking session events.
fn signals_handle(&self) -> SessionSignalsHandle {
self.feedback_manager.signals_handle()
}
fn emit_event(&self, event: crate::session::events::Event) {
self.events.emit(event);
}
fn emit_turn_ended(
&self,
outcome: crate::session::events::TurnOutcomeLabel,
category: Option<crate::session::events::CancellationCategory>,
context: Option<serde_json::Value>,
) {
self.events.emit_turn_ended(outcome, category, context);
}
/// Current model ID for OTLP span attributes. Reads from chat_state_handle
/// so it always reflects the latest model override — no stale cached field.
/// Returns "unknown" if no sampling config is set.
async fn current_model_id(&self) -> String {
self.chat_state_handle
.get_sampling_config()
.await
.map(|c| c.model)
.filter(|m| !m.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
/// Build a hook run context for dispatching hook events.
fn session_id_string(&self) -> String {
self.session_info.id.0.to_string()
}
/// Send a before-turn hook via the local workspace channel.
/// Fire-and-forget — failures are logged but do not interrupt the turn.
async fn send_before_turn_event(
&self,
payload: xai_tool_protocol::turn_hook::BeforeTurnPayload,
) {
self.workspace_ops
.on_before_turn(&self.session_id_string(), &payload)
.await;
}
/// Send an after-turn hook via the local workspace channel.
/// Fire-and-forget — failures are logged but do not interrupt the turn.
async fn send_after_turn_event(&self, payload: xai_tool_protocol::turn_hook::AfterTurnPayload) {
self.workspace_ops
.on_after_turn(&self.session_id_string(), &payload)
.await;
}
/// Compute the live command availability snapshot for this session.
///
/// Convenience wrapper that fetches the toolset and delegates to
/// `build_command_availability`. Use this on the inbound resolve
/// path; the outbound advertise path enumerates tools once and
/// shares the slice across both calls (see
/// `send_available_commands_update`).
async fn command_availability(&self) -> slash_commands::CommandAvailability {
let tool_names = self.registered_tool_names().await;
let availability = self.build_command_availability(&tool_names);
self.maybe_reconcile_active_goal_without_harness().await;
self.maybe_reconcile_active_goal_without_plan().await;
availability
}
/// Build the `CommandAvailability` snapshot from a precomputed slice
/// of tool names plus the live session-scoped capability state.
///
/// Single source of truth for the seven gate fields -- both
/// `command_availability` (resolve path) and
/// `send_available_commands_update` (advertise path) call this so
/// the two paths can never drift.
fn build_command_availability(
&self,
tool_names: &[String],
) -> slash_commands::CommandAvailability {
use xai_grok_tools::implementations::memory::{
MEMORY_GET_TOOL_NAME, MEMORY_SEARCH_TOOL_NAME,
};
let memory_read_registered = tool_names
.iter()
.any(|n| n == MEMORY_SEARCH_TOOL_NAME || n == MEMORY_GET_TOOL_NAME);
let goal = self.sync_goal_harness_from_tools(tool_names);
slash_commands::CommandAvailability {
feedback: self.feedback_manager.is_enabled(),
memory: self.memory.is_enabled() && memory_read_registered,
memory_configured: self.memory.backend_params.is_some(),
scheduler: tool_names.iter().any(|n| {
n == xai_grok_tools::implementations::grok_build::SCHEDULER_CREATE_TOOL_NAME
}),
hooks: self.hook_registry.borrow().is_some(),
plugins: self.plugin_registry.borrow().is_some(),
goal,
}
}
/// Names of every tool registered with the session's tool bridge.
///
/// Async wrapper that fetches `tool_definitions()` and projects to
/// the `function.name` field. Allocates one `Vec<String>` per call;
/// callers that need both gating and the wire payload should call
/// once and pass the slice to `build_command_availability`.
async fn registered_tool_names(&self) -> Vec<String> {
let bridge = self.agent.borrow().tool_bridge().clone();
bridge
.tool_definitions()
.await
.into_iter()
.map(|td| td.function.name)
.collect()
}
/// Send visible text output to the TUI from a slash command.
///
/// Uses `AgentMessageChunk` so the text appears in the conversation
/// scrollback, then flushes the replay buffer to ensure delivery
/// before the turn ends.
async fn send_slash_command_output(&self, text: &str) {
self.send_update(
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(text.to_string()),
))),
None,
)
.await;
if let Err(e) = crate::session::replay_events::flush_replay_actor(&self.event_tx).await {
tracing::warn!(?e, "flush_replay_actor failed");
}
}
/// Send a feedback request notification to the client.
async fn send_feedback_notification(&self, request: crate::session::feedback::FeedbackRequest) {
self.send_xai_notification(XaiSessionUpdate::FeedbackRequest(request.into()))
.await;
}
}
impl SessionActor {
/// Owned handle to the active `ToolBridge`. Used by async methods
/// that need to drop the `RefCell::Ref<Agent>` borrow before
/// awaiting — `Arc::clone` is cheap, and an outstanding `Ref`
/// across `.await` would panic if anything on the suspended path
/// did `self.agent.borrow_mut()`.
fn tool_bridge_handle(&self) -> Arc<xai_grok_tools::bridge::ToolBridge> {
Arc::clone(self.agent.borrow().tool_bridge())
}
}
const PROMPT_CONTEXT_FILENAME: &str = "prompt_context.json";
/// Persist the structured prompt context to `{session_dir}/prompt_context.json`.
///
/// This is best-effort: failures are logged but do not block session creation.
/// The saved JSON enables deterministic re-rendering, `grok prompt --json`
/// inspection, and post-hoc debugging of what went into a session's system prompt.
fn save_prompt_context(session_info: &SessionInfo, prompt_context: &xai_grok_agent::PromptContext) {
let dir = crate::session::persistence::session_dir(session_info);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(?e, "failed to create session dir for prompt_context.json");
return;
}
let path = dir.join(PROMPT_CONTEXT_FILENAME);
match serde_json::to_string_pretty(prompt_context) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
tracing::warn!(?e, "failed to write prompt_context.json");
}
}
Err(e) => {
tracing::warn!(?e, "failed to serialize PromptContext");
}
}
}
const SYSTEM_PROMPT_FILENAME: &str = "system_prompt.txt";
/// Synchronously and atomically rewrite `{session_dir}/chat_history.jsonl`.
///
/// Serializes to a temp file then `rename`s over the target, matching the
/// persistence actor's own crash-safety (a truncating in-place write can tear
/// the file on crash / `ENOSPC`). Best-effort with a logged failure.
///
/// Callers use this for a *synchronous* on-disk snapshot at spawn / initialize /
/// agent-rebuild: `chat_state_handle.replace_conversation` persists the same
/// content, but only after two async actor hops, so a reload that races the
/// first prompt could otherwise read the bare pre-enrichment template. A
/// distinct temp suffix (`.sync.tmp`) avoids clobbering the persistence actor's
/// own `chat_history.jsonl.tmp`; whichever atomic `rename` lands last wins and
/// the content is identical, so the two writers can never produce a torn file.
fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[ConversationItem]) {
let dir = crate::session::persistence::session_dir(session_info);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(
session_id = % session_info.id.0, ? e,
"persist_chat_history_jsonl_sync: failed to create session dir"
);
return;
}
let final_path = dir.join("chat_history.jsonl");
let tmp_path = dir.join("chat_history.jsonl.sync.tmp");
let result = (|| -> std::io::Result<()> {
use std::io::Write;
let mut buf = Vec::new();
for item in conversation {
serde_json::to_writer(&mut buf, item)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
buf.push(b'\n');
}
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
})();
if let Err(e) = result {
tracing::warn!(
session_id = % session_info.id.0, ? e,
"persist_chat_history_jsonl_sync: failed to persist chat_history.jsonl"
);
let _ = std::fs::remove_file(&tmp_path);
}
}
/// Persist the exact rendered system prompt to `{session_dir}/system_prompt.txt`.
/// Should match the first System entry in `chat_history.jsonl` modulo trailing
/// newlines (`canonical_system_prompt_eq`); a trailing-newline-only difference
/// is benign, and this artifact is a convenience mirror, not the source of truth
/// (the conversation head is).
fn save_system_prompt(session_info: &SessionInfo, system_prompt: &str) {
let dir = crate::session::persistence::session_dir(session_info);
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(?e, "failed to create session dir for system_prompt.txt");
return;
}
let path = dir.join(SYSTEM_PROMPT_FILENAME);
if let Err(e) = std::fs::write(&path, system_prompt) {
tracing::warn!(?e, "failed to write system_prompt.txt");
}
}
/// Load the canonical system prompt from `{session_dir}/system_prompt.txt`.
///
/// Returns `None` for sessions created before this artifact existed.
/// Callers should fall back to extracting from `chat_history.jsonl` if absent.
#[expect(dead_code, reason = "API for future viewers/debug tools")]
pub(crate) fn load_system_prompt(session_info: &SessionInfo) -> Option<String> {
let dir = crate::session::persistence::session_dir(session_info);
load_system_prompt_from_dir(&dir)
}
fn load_system_prompt_from_dir(session_dir: &std::path::Path) -> Option<String> {
std::fs::read_to_string(session_dir.join(SYSTEM_PROMPT_FILENAME)).ok()
}
/// Load the canonical prompt context from `{session_dir}/prompt_context.json`.
///
/// Returns `None` for sessions without a persisted context.
#[expect(dead_code, reason = "API for future viewers/debug tools")]
pub(crate) fn load_prompt_context(
session_info: &SessionInfo,
) -> Option<xai_grok_agent::PromptContext> {
let dir = crate::session::persistence::session_dir(session_info);
load_prompt_context_from_dir(&dir)
}
fn load_prompt_context_from_dir(
session_dir: &std::path::Path,
) -> Option<xai_grok_agent::PromptContext> {
let json = std::fs::read_to_string(session_dir.join(PROMPT_CONTEXT_FILENAME)).ok()?;
serde_json::from_str(&json)
.map_err(|e| tracing::warn!(?e, "failed to deserialize prompt_context.json"))
.ok()
}
#[cfg(test)]
#[path = "acp_session_tests/client_hooks_tests.rs"]
mod client_hooks_tests;
#[cfg(test)]
#[path = "acp_session_tests/replace_system_prompt_tests.rs"]
mod replace_system_prompt_tests;
#[cfg(test)]
#[path = "acp_session_tests/support.rs"]
mod support;
#[cfg(test)]
#[path = "acp_session_tests/usage_categories_tests.rs"]
mod usage_categories_tests;
#[cfg(test)]
mod managed_gateway_descriptor_tests {
use super::*;
use xai_grok_tools::types::output::{MCPOutput, ToolOutput};
use xai_grok_tools::types::tool::{ToolKind, ToolNamespace};
#[derive(Debug, Default)]
struct FixtureMcpTool;
impl xai_grok_tools::types::tool_metadata::ToolMetadata for FixtureMcpTool {
fn kind(&self) -> ToolKind {
ToolKind::Other
}
fn tool_namespace(&self) -> ToolNamespace {
ToolNamespace::MCP
}
fn description_template(&self) -> &str {
"fixture"
}
}
impl xai_tool_runtime::Tool for FixtureMcpTool {
type Args = serde_json::Value;
type Output = ToolOutput;
fn id(&self) -> xai_tool_protocol::ToolId {
xai_tool_protocol::ToolId::new("server__tool").expect("valid")
}
fn description(
&self,
_ctx: &::xai_tool_runtime::ListToolsContext,
) -> xai_tool_types::ToolDescription {
xai_tool_types::ToolDescription::new("server__tool", "fixture")
}
async fn run(
&self,
_ctx: xai_tool_runtime::ToolCallContext,
_args: serde_json::Value,
) -> Result<ToolOutput, xai_tool_runtime::ToolError> {
Ok(ToolOutput::MCP(MCPOutput::okay_output(
"server__tool".to_string(),
"server".to_string(),
"ok".to_string(),
)))
}
}
#[tokio::test]
async fn refresh_snapshot_indexes_only_admitted_gateway_tools() {
let bridge = Arc::new(crate::tools::bridge::ToolBridge::for_test());
bridge
.register_mcp_tools(
"server__tool".to_string(),
FixtureMcpTool,
Some(serde_json::json!({ "type" : "object" })),
)
.await
.expect("local fixture registration succeeds");
let mcp_state = Arc::new(TokioMutex::new(McpState::new(vec![])));
let managed = crate::session::managed_mcp::ManagedMcpStateHandle::default();
{
let mut state = managed.lock().await;
state.enable_gateway_tools();
let epoch = state.start_gateway_tool_fetch().unwrap();
assert!(state.complete_gateway_tool_fetch(
epoch,
crate::session::managed_mcp::GatewayToolCatalog {
tools: vec![
crate::session::managed_mcp::GatewayTool {
connector_id: "server".to_string(),
connector_name: "Gateway Collision".to_string(),
tool_id: "tool".to_string(),
tool_name: "Collision".to_string(),
call_id: "gateway.collision".to_string(),
description: "Gateway collision".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "gateway".to_string(),
connector_name: "Gateway".to_string(),
tool_id: "search".to_string(),
tool_name: "Search".to_string(),
call_id: "gateway.search".to_string(),
description: "Gateway search".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
],
total_tools: 2,
connectors_needing_reauth: vec![],
}
));
}
let snapshot = Arc::new(std::sync::Mutex::new(
crate::session::tool_index::ToolMetadataSnapshot::default(),
));
refresh_mcp_snapshot_for_test(bridge, mcp_state, managed, snapshot.clone()).await;
let snapshot = snapshot.lock().unwrap();
let names: std::collections::HashSet<&str> = snapshot
.tools
.iter()
.map(|tool| tool.qualified_name.as_str())
.collect();
assert!(names.contains("gateway__search"));
let server_tool = snapshot
.tools
.iter()
.find(|tool| tool.qualified_name == "server__tool")
.expect("local MCP tool remains indexed");
assert_eq!(server_tool.server_name, "server");
assert_eq!(server_tool.description, "fixture");
}
#[tokio::test]
async fn refresh_snapshot_excludes_disabled_gateway_tools_and_connectors() {
let bridge = Arc::new(crate::tools::bridge::ToolBridge::for_test());
let mcp_state = Arc::new(TokioMutex::new(McpState::new(vec![])));
let managed = crate::session::managed_mcp::ManagedMcpStateHandle::default();
{
let mut state = managed.lock().await;
state.enable_gateway_tools();
let epoch = state.start_gateway_tool_fetch().unwrap();
assert!(state.complete_gateway_tool_fetch(
epoch,
crate::session::managed_mcp::GatewayToolCatalog {
tools: vec![
crate::session::managed_mcp::GatewayTool {
connector_id: "linear".to_string(),
connector_name: "Linear".to_string(),
tool_id: "list_issues".to_string(),
tool_name: "List".to_string(),
call_id: "linear.list_issues".to_string(),
description: "List issues".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "linear".to_string(),
connector_name: "Linear".to_string(),
tool_id: "create_issue".to_string(),
tool_name: "Create".to_string(),
call_id: "linear.create_issue".to_string(),
description: "Create issue".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "slack".to_string(),
connector_name: "Slack".to_string(),
tool_id: "search".to_string(),
tool_name: "Search".to_string(),
call_id: "slack.search".to_string(),
description: "Search Slack".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
],
total_tools: 3,
connectors_needing_reauth: vec![],
}
));
}
let snapshot = Arc::new(std::sync::Mutex::new(
crate::session::tool_index::ToolMetadataSnapshot::default(),
));
let disabled: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::from([
(
"linear".to_string(),
std::collections::HashSet::from(["linear__create_issue".to_string()]),
),
(
crate::util::config::MANAGED_GATEWAY_DISABLED_CONNECTORS_KEY.to_string(),
std::collections::HashSet::from(["slack".to_string()]),
),
]);
refresh_mcp_snapshot_for_test_with_disabled(
bridge,
mcp_state,
managed,
snapshot.clone(),
&disabled,
)
.await;
let snapshot = snapshot.lock().unwrap();
let names: std::collections::HashSet<&str> = snapshot
.tools
.iter()
.map(|tool| tool.qualified_name.as_str())
.collect();
assert!(names.contains("linear__list_issues"));
assert!(!names.contains("linear__create_issue"));
assert!(!names.contains("slack__search"));
}
}
/// ToolBridge must route file operations through the injected FileSystem,
/// not direct disk I/O. When `.with_fs()` is dropped from the builder,
/// tools fall back to LocalFs and ACP client-side enforcement stops working.
#[cfg(test)]
#[path = "acp_session_tests/fs_injection_regression_tests.rs"]
mod fs_injection_regression_tests;
#[cfg(test)]
#[path = "acp_session_tests/interjection_actor_tests.rs"]
mod interjection_actor_tests;
#[cfg(test)]
#[path = "acp_session_tests/observability_bridge_mapping_tests.rs"]
mod observability_bridge_mapping_tests;
#[cfg(test)]
#[path = "acp_session_tests/permission_auto_mode_tests.rs"]
mod permission_auto_mode_tests;
/// Resume re-park of the parked `exit_plan_mode` approval.
#[cfg(test)]
#[path = "acp_session_tests/plan_approval_resume_tests.rs"]
mod plan_approval_resume_tests;
/// Plan-mode edit gate: read-only except the plan file, even under allow-all.
#[cfg(test)]
#[path = "acp_session_tests/plan_mode_edit_gate_tests.rs"]
mod plan_mode_edit_gate_tests;
/// Mid-turn plan-mode toggle: immediate activation + buffered reminder.
#[cfg(test)]
#[path = "acp_session_tests/plan_mode_midturn_tests.rs"]
mod plan_mode_midturn_tests;
/// Tests for [`conversation_has_project_instructions`], the idempotence
/// helper that gates the spawn-time AGENTS.md / CLAUDE.md injector.
///
/// Coverage:
/// - True when a tagged [`SyntheticReason::ProjectInstructions`] user item
/// is present (the new post-Task-1 form).
/// - True when an untagged legacy user item is present whose first text
/// part begins with the wrapper-tag prefix written by older shells.
/// - False for an empty conversation, for a conversation with only a real
/// user message, when the wrapper prefix appears in a non-first content
/// part, and when the wrapper prefix is buried mid-text.
#[cfg(test)]
#[path = "acp_session_tests/project_instructions_idempotence_tests.rs"]
mod project_instructions_idempotence_tests;
#[cfg(test)]
#[path = "acp_session_tests/prompt_mode_transition_tests.rs"]
mod prompt_mode_transition_tests;
#[cfg(test)]
#[path = "acp_session_tests/prompt_queue_actor_tests.rs"]
mod prompt_queue_actor_tests;
/// Regression coverage for the per-turn `record_token_usage` path.
#[cfg(test)]
#[path = "acp_session_tests/record_response_token_usage_tests.rs"]
mod record_response_token_usage_tests;
#[cfg(test)]
#[path = "acp_session_tests/replay_buffer_send_update_tests.rs"]
mod replay_buffer_send_update_tests;
#[cfg(test)]
#[path = "acp_session_tests/reverse_request_session_id_tests.rs"]
mod reverse_request_session_id_tests;
#[cfg(test)]
#[path = "acp_session_tests/rewind_cross_compaction_tests.rs"]
mod rewind_cross_compaction_tests;
#[cfg(test)]
#[path = "acp_session_tests/rewind_synthetic_turn_tests.rs"]
mod rewind_synthetic_turn_tests;
#[cfg(test)]
#[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"]
mod rewrite_zero_turn_prefix_tests;
/// Pins the `SubagentFinished` usage-fold attribution gate.
#[cfg(test)]
#[path = "acp_session_tests/subagent_usage_fold_tests.rs"]
mod subagent_usage_fold_tests;
#[cfg(test)]
#[path = "acp_session_tests/turn_completion_emit_tests.rs"]
mod turn_completion_emit_tests;
#[cfg(test)]
mod tool_meta_stamp_tests {
//! Pin the `x.ai/tool` stamps on the harness emission paths: the early
//! ToolCall registered by `prepare_tool_call` and the permission-request
//! ToolCallUpdate (a dropped `stamp_tool_meta` call would regress silently).
use super::replay_buffer_send_update_tests::make_replay_send_update_fixture;
use super::support::test_agent_with_tools;
use super::*;
use tokio::sync::mpsc;
use xai_grok_tools::registry::types::ToolConfig;
use xai_grok_tools::tool_taxonomy::TOOL_META_KEY;
use xai_grok_workspace::permission::PermissionCommand;
fn read_file_call() -> crate::sampling::types::ToolCallResponse {
crate::sampling::types::ToolCallResponse {
id: "call-stamp-1".to_string(),
kind: "function".to_string(),
function: crate::sampling::types::ToolCallFunction {
name: "read_file".to_string(),
arguments: r#"{"target_file":"/tmp/stamp.txt"}"#.to_string(),
},
}
}
/// The `x.ai/tool` object from an event's `_meta`, if present.
fn tool_meta(meta: Option<&acp::Meta>) -> Option<&serde_json::Value> {
meta.and_then(|m| m.get(TOOL_META_KEY))
}
#[tokio::test(flavor = "current_thread")]
async fn prepare_tool_call_stamps_early_tool_call_and_refinement() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let mut fixture = make_replay_send_update_fixture().await;
fixture.actor.agent = std::cell::RefCell::new(
test_agent_with_tools(vec![ToolConfig::from_id(
"GrokBuild:read_file".to_string(),
)])
.await,
);
let prepared = fixture
.actor
.prepare_tool_call(read_file_call(), &mut Vec::new())
.await
.expect("prepare_tool_call should not error");
assert!(prepared.is_ok(), "read_file should prepare cleanly");
let mut early = None;
let mut refined = None;
while let Ok(event) = fixture.event_rx.try_recv() {
let SessionEvent::Notification(SessionNotification::Acp(n)) = event else {
continue;
};
match &n.update {
acp::SessionUpdate::ToolCall(tc) => early = Some(tc.meta.clone()),
acp::SessionUpdate::ToolCallUpdate(tu) => {
refined = Some(tu.meta.clone());
}
_ => {}
}
}
let early = early.expect("early ToolCall emitted");
let t = tool_meta(early.as_ref()).expect("early ToolCall carries x.ai/tool");
assert_eq!(t["name"], "read_file");
assert_eq!(t["kind"], "read");
assert_eq!(t["namespace"], "grok_build");
assert!(t.get("input").is_none(), "identity-only before parse");
let refined = refined.expect("refinement ToolCallUpdate emitted");
let t = tool_meta(refined.as_ref()).expect("refinement carries x.ai/tool");
assert_eq!(t["input"]["path"], "/tmp/stamp.txt");
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn permission_request_update_carries_tool_meta() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let mut fixture = make_replay_send_update_fixture().await;
fixture.actor.agent = std::cell::RefCell::new(
test_agent_with_tools(vec![ToolConfig::from_id(
"GrokBuild:read_file".to_string(),
)])
.await,
);
let (perm_tx, mut perm_rx) = mpsc::unbounded_channel();
fixture.actor.permissions = PermissionHandle::Actor {
cmd_tx: perm_tx,
yolo_state: Arc::new(std::sync::atomic::AtomicBool::new(false)),
auto_state: Arc::new(std::sync::atomic::AtomicBool::new(false)),
side_query_wired: Arc::new(std::sync::atomic::AtomicBool::new(false)),
yolo_pin: None,
deny_read_globs: Arc::new(vec![]),
in_flight: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
};
let captured: Arc<tokio::sync::Mutex<Option<acp::ToolCallUpdate>>> =
Arc::new(tokio::sync::Mutex::new(None));
let captured_in_task = captured.clone();
tokio::task::spawn_local(async move {
while let Some(cmd) = perm_rx.recv().await {
if let PermissionCommand::Request {
tool_call_update,
respond_to,
..
} = cmd
{
*captured_in_task.lock().await = Some(tool_call_update);
let _ = respond_to.send(Decision::Allow);
}
}
});
let prepared = fixture
.actor
.prepare_tool_call(read_file_call(), &mut Vec::new())
.await
.expect("prepare_tool_call should not error");
assert!(prepared.is_ok(), "allowed read_file should prepare cleanly");
let update = captured
.lock()
.await
.take()
.expect("permission request must have been issued");
let t = tool_meta(update.meta.as_ref())
.expect("permission-request ToolCallUpdate carries x.ai/tool");
assert_eq!(t["name"], "read_file");
assert_eq!(t["kind"], "read");
assert_eq!(t["input"]["path"], "/tmp/stamp.txt");
})
.await;
}
}
/// Drop guard that records aggregate turn metrics on the current tracing span
struct TurnMetrics {
turn_tool_count: u64,
turn_model_calls: u64,
span: tracing::Span,
}
impl TurnMetrics {
fn new() -> Self {
Self {
turn_tool_count: 0,
turn_model_calls: 0,
span: tracing::Span::current(),
}
}
fn record_model_response(&mut self, num_tool_calls: usize) {
self.turn_model_calls += 1;
self.turn_tool_count += num_tool_calls as u64;
}
}
impl Drop for TurnMetrics {
fn drop(&mut self) {
self.span.record("turn_tool_count", self.turn_tool_count);
self.span.record("turn_model_calls", self.turn_model_calls);
}
}
/// Token rotation on the sampler/inference path is owned by the
/// proactive refresh loop and the per-turn pre-request refresh
/// (`refresh_token_if_expired`). `handle_sampling_failure` surfaces
/// auth errors to the caller and never invokes the refresher itself.
#[cfg(test)]
#[path = "acp_session_tests/auth_error_no_retry_tests.rs"]
mod auth_error_no_retry_tests;
/// Regression coverage for the auto-wake suppression sweep + shutdown
/// drain. These exercise the helpers added to fix the trailing
/// `<system-reminder>` chat history bug.
#[cfg(test)]
#[path = "acp_session_tests/auto_wake_suppression_tests.rs"]
mod auto_wake_suppression_tests;
#[cfg(test)]
#[path = "acp_session_tests/between_turn_completion_tests.rs"]
mod between_turn_completion_tests;
#[cfg(test)]
#[path = "acp_session_tests/build_tool_parse_error_message_tests.rs"]
mod build_tool_parse_error_message_tests;
#[cfg(test)]
#[path = "acp_session_tests/cancel_running_task_tests.rs"]
mod cancel_running_task_tests;
#[cfg(test)]
#[path = "acp_session_tests/feedback_turn_lookup_tests.rs"]
mod feedback_turn_lookup_tests;
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs"]
mod goal_reminder_subagent_rules_tests;
#[cfg(test)]
#[path = "acp_session_tests/idle_resume_tests.rs"]
mod idle_resume_tests;
#[cfg(test)]
#[path = "acp_session_tests/inline_auto_compact_flow_tests.rs"]
mod inline_auto_compact_flow_tests;
#[cfg(test)]
#[path = "acp_session_tests/laziness/laziness_debug_tests.rs"]
mod laziness_debug_tests;
#[cfg(test)]
#[path = "acp_session_tests/laziness/laziness_detector_tests.rs"]
mod laziness_detector_tests;
#[cfg(test)]
#[path = "acp_session_tests/laziness/laziness_integration_tests.rs"]
mod laziness_integration_tests;
#[cfg(test)]
#[path = "acp_session_tests/load_user_prompts_tests.rs"]
mod load_user_prompts_tests;
#[cfg(test)]
#[path = "acp_session_tests/media_gen_auth_retry_tests.rs"]
mod media_gen_auth_retry_tests;
#[cfg(test)]
#[path = "acp_session_tests/memory_config_tests.rs"]
mod memory_config_tests;
#[cfg(test)]
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
mod parallel_dispatch_tests;
#[cfg(test)]
#[path = "acp_session_tests/prompt_context_persistence_tests.rs"]
mod prompt_context_persistence_tests;
#[cfg(test)]
#[path = "acp_session_tests/reactive_managed_reauth_e2e_tests.rs"]
mod reactive_managed_reauth_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/reactive_managed_reauth_tests.rs"]
mod reactive_managed_reauth_tests;
#[cfg(test)]
#[path = "acp_session_tests/session_thread_tests.rs"]
mod session_thread_tests;
#[cfg(test)]
#[path = "acp_session_tests/turn/turn_end_guard_tests.rs"]
mod turn_end_guard_tests;
#[cfg(test)]
#[path = "acp_session_tests/wait_for_mcp_prefix_tests.rs"]
mod wait_for_mcp_prefix_tests;
#[cfg(test)]
#[path = "acp_session_tests/web_search_e2e_tests.rs"]
mod web_search_e2e_tests;
#[cfg(test)]
mod managed_gateway_tool_tests {
use super::*;
use xai_grok_tools::types::output::{MCPOutput, ToolOutput};
use xai_grok_tools::types::tool::{ToolKind, ToolNamespace};
use xai_grok_tools::types::tool_metadata::ToolMetadata;
#[derive(Debug)]
struct FixtureMcpTool;
impl ToolMetadata for FixtureMcpTool {
fn kind(&self) -> ToolKind {
ToolKind::Other
}
fn tool_namespace(&self) -> ToolNamespace {
ToolNamespace::MCP
}
fn description_template(&self) -> &str {
"fixture"
}
}
impl xai_tool_runtime::Tool for FixtureMcpTool {
type Args = serde_json::Value;
type Output = ToolOutput;
fn id(&self) -> xai_tool_protocol::ToolId {
xai_tool_protocol::ToolId::new("server__tool").expect("valid")
}
fn description(
&self,
_ctx: &::xai_tool_runtime::ListToolsContext,
) -> xai_tool_types::ToolDescription {
xai_tool_types::ToolDescription::new("server__tool", "fixture")
}
async fn run(
&self,
_ctx: xai_tool_runtime::ToolCallContext,
_args: serde_json::Value,
) -> Result<ToolOutput, xai_tool_runtime::ToolError> {
Ok(ToolOutput::MCP(MCPOutput::okay_output(
"server__tool".to_string(),
"server".to_string(),
"ok".to_string(),
)))
}
}
#[tokio::test]
async fn refresh_snapshot_seeds_only_admitted_gateway_catalog_entries() {
let bridge = Arc::new(crate::tools::bridge::ToolBridge::for_test());
bridge
.register_mcp_tools(
"server__tool".to_string(),
FixtureMcpTool,
Some(serde_json::json!({ "type" : "object" })),
)
.await
.expect("local fixture registration succeeds");
let mcp_state = Arc::new(TokioMutex::new(McpState::new(vec![])));
let managed = crate::session::managed_mcp::ManagedMcpStateHandle::default();
{
let mut state = managed.lock().await;
state.enable_gateway_tools();
let epoch = state.start_gateway_tool_fetch().unwrap();
assert!(state.complete_gateway_tool_fetch(
epoch,
crate::session::managed_mcp::GatewayToolCatalog {
tools: vec![
crate::session::managed_mcp::GatewayTool {
connector_id: "server".to_string(),
connector_name: "Gateway Collision".to_string(),
tool_id: "tool".to_string(),
tool_name: "Collision".to_string(),
call_id: "gateway.collision".to_string(),
description: "Gateway collision".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "gateway".to_string(),
connector_name: "Gateway".to_string(),
tool_id: "search".to_string(),
tool_name: "Search".to_string(),
call_id: "gateway.search".to_string(),
description: "Gateway search".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
],
total_tools: 2,
connectors_needing_reauth: vec![],
}
));
}
let snapshot = Arc::new(std::sync::Mutex::new(
crate::session::tool_index::ToolMetadataSnapshot::default(),
));
refresh_mcp_snapshot_for_test(bridge.clone(), mcp_state, managed, snapshot.clone()).await;
let catalog = bridge
.read_resource::<xai_grok_tools::types::resources::ManagedGatewayToolCatalog>()
.await
.expect("catalog resource should be seeded");
assert!(catalog.get("gateway__search").is_some());
assert!(
catalog.get("server__tool").is_none(),
"gateway catalog resource must match admitted snapshot and skip local collisions"
);
}
#[tokio::test]
async fn refresh_snapshot_excludes_disabled_gateway_tools_and_connectors() {
let bridge = Arc::new(crate::tools::bridge::ToolBridge::for_test());
let mcp_state = Arc::new(TokioMutex::new(McpState::new(vec![])));
let managed = crate::session::managed_mcp::ManagedMcpStateHandle::default();
{
let mut state = managed.lock().await;
state.enable_gateway_tools();
let epoch = state.start_gateway_tool_fetch().unwrap();
assert!(state.complete_gateway_tool_fetch(
epoch,
crate::session::managed_mcp::GatewayToolCatalog {
tools: vec![
crate::session::managed_mcp::GatewayTool {
connector_id: "linear".to_string(),
connector_name: "Linear".to_string(),
tool_id: "list_issues".to_string(),
tool_name: "List".to_string(),
call_id: "linear.list_issues".to_string(),
description: "List issues".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "linear".to_string(),
connector_name: "Linear".to_string(),
tool_id: "create_issue".to_string(),
tool_name: "Create".to_string(),
call_id: "linear.create_issue".to_string(),
description: "Create issue".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
crate::session::managed_mcp::GatewayTool {
connector_id: "slack".to_string(),
connector_name: "Slack".to_string(),
tool_id: "search".to_string(),
tool_name: "Search".to_string(),
call_id: "slack.search".to_string(),
description: "Search Slack".to_string(),
json_schema: serde_json::json!({ "type" : "object" }),
},
],
total_tools: 3,
connectors_needing_reauth: vec![],
}
));
}
let snapshot = Arc::new(std::sync::Mutex::new(
crate::session::tool_index::ToolMetadataSnapshot::default(),
));
let disabled: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::from([
(
"linear".to_string(),
std::collections::HashSet::from(["linear__create_issue".to_string()]),
),
(
crate::util::config::MANAGED_GATEWAY_DISABLED_CONNECTORS_KEY.to_string(),
std::collections::HashSet::from(["slack".to_string()]),
),
]);
refresh_mcp_snapshot_for_test_with_disabled(
bridge.clone(),
mcp_state,
managed,
snapshot.clone(),
&disabled,
)
.await;
let catalog = bridge
.read_resource::<xai_grok_tools::types::resources::ManagedGatewayToolCatalog>()
.await
.expect("catalog resource should be seeded");
assert!(catalog.get("linear__list_issues").is_some());
assert!(catalog.get("linear__create_issue").is_none());
assert!(catalog.get("slack__search").is_none());
let snapshot = snapshot.lock().unwrap();
let names: std::collections::HashSet<&str> = snapshot
.tools
.iter()
.map(|tool| tool.qualified_name.as_str())
.collect();
assert!(names.contains("linear__list_issues"));
assert!(!names.contains("linear__create_issue"));
assert!(!names.contains("slack__search"));
}
}
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_backoff_tests.rs"]
mod goal_backoff_tests;
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_classifier_e2e_tests.rs"]
mod goal_classifier_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_planner_e2e_tests.rs"]
mod goal_planner_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_strategist_e2e_tests.rs"]
mod goal_strategist_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"]
mod goal_summarizer_e2e_tests;
#[cfg(test)]
#[path = "acp_session_tests/interjection_tests.rs"]
mod interjection_tests;
#[cfg(test)]
#[path = "acp_session_tests/recap_display_only_tests.rs"]
mod recap_display_only_tests;
#[cfg(test)]
#[path = "acp_session_tests/reminder_policy_tests.rs"]
mod reminder_policy_tests;