2026-07-16 06:46:02 +01:00
#![ 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 ,
2026-07-18 19:48:28 +01:00
TaskWakeAdmission , TaskWakeFallback , ok_end_turn ,
2026-07-16 06:46:02 +01:00
} ;
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 ::* ;
2026-07-19 18:40:33 +01:00
#[ path = " acp_session_impl/stop_gate.rs " ]
mod stop_gate ;
pub use stop_gate ::MAX_STOP_HOOK_CONTINUATIONS_PER_TURN ;
2026-07-16 06:46:02 +01:00
#[ 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 ,
2026-07-18 19:48:28 +01:00
/// 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 > ,
2026-07-16 06:46:02 +01:00
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 > ,
2026-07-18 19:48:28 +01:00
/// When true, notifications are buffered but not drained until genuine
/// user re-engagement. Set by interactive Ctrl+C, cleared by a user prompt.
2026-07-16 06:46:02 +01:00
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
2026-07-18 19:48:28 +01:00
/// them for telemetry counts / reservation releases).
2026-07-16 06:46:02 +01:00
///
/// 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
2026-07-18 19:48:28 +01:00
/// each returned item (see `respond_removed_prompt`) or the
2026-07-16 06:46:02 +01:00
/// 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
2026-07-18 19:48:28 +01:00
/// queued, and interactive Ctrl+C has not suppressed notifications pending
/// genuine user re-engagement.
2026-07-16 06:46:02 +01:00
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 ;
2026-07-20 18:06:59 +01:00
/// 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 > ,
}
2026-07-16 06:46:02 +01:00
/// 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 ,
2026-07-20 18:06:59 +01:00
/// 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 > > ,
2026-07-16 06:46:02 +01:00
/// 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 ;