#![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::{
    PromptCompletionKind, PromptTurnOk, PromptTurnResult, SessionCommand, ok_end_turn,
};
use super::handle::SessionHandle;
use super::notifications::NotificationSender;
use crate::agent::update_chunk_merge::{BufferingSettings, ReplayBuffer};
use crate::extensions::notification::SessionUpdate as XaiSessionUpdate;
use crate::extensions::notification::{
    RetryState, SessionNotification as XaiSessionNotification, is_reauthable_failure,
};
use crate::sampling::error::map_sampling_err_to_acp;
use crate::sampling::types::{ChatRequestMessage, ToolCallResponse, ToolDefinition};
use crate::sampling::{
    ContentPart, ConversationItem, ConversationRequest, ConversationResponse, SamplingError,
    SyntheticReason, ToolSpec, conversation_truncate_for_prompt,
};
use crate::session::ClientFsConfig;
use crate::session::feedback_manager::{FeedbackManager, FeedbackManagerConfig};
use crate::session::fs_watch::{self, git_head_dedup_key};
use crate::session::info::Info as SessionInfo;
use crate::session::mcp_servers::McpInitStrategy;
use crate::session::mcp_servers::McpMetaConfigMap;
use crate::session::mcp_servers::McpState;
use crate::session::mcp_servers::OauthInteractivity;
use crate::session::mcp_servers::build_pending_clients;
use crate::session::mcp_servers::mcp_server_name;
use crate::session::mcp_servers::mcp_target_str;
use crate::session::mcp_servers::mcp_transport_str;
use crate::session::mcp_servers::parse_mcp_tool_name;
use crate::session::persistence::{
    PersistenceContentChunk, PersistenceHandle, PersistenceMsg, get_prompt_file_path,
};
use crate::session::plan_mode::PromptMode;
use crate::session::prompt_parser::parse_prompt_with_skills;
use crate::session::replay_events::{SessionEvent, SessionNotification};
use crate::session::result::ExtMethodResult;
use crate::session::signals::{SessionSignalsHandle, TurnDeltaSnapshot};
use crate::session::slash_commands::{self, BuiltinAction, SlashCommandOutcome};
use crate::session::storage::SessionUpdate;
use crate::session::user_message::extract_user_query;
use crate::session::user_message::{construct_user_message, construct_user_message_minimal};
use crate::terminal::{DEFAULT_TIMEOUT, TerminalRunRequest};
use crate::tools::ToolContext;
use agent_client_protocol as acp;
use agent_client_protocol::ContentBlock;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use kigi_agent::AgentDefinition;
use kigi_agent::prompt::agents_md::LEGACY_AGENTS_MD_REMINDER_PREFIX;
use kigi_agent::prompt::skills::SkillsConfig;
use kigi_sampler::SamplerConfig as SamplingConfig;
use kigi_sampling_types::truncate_bytes;
use kigi_tools::computer::local::LocalTerminalBackend;
use kigi_tools::implementations::BashToolInput;
use kigi_tools::implementations::kigi::web_fetch::WebFetchConfig;
use kigi_tools::types::ToolInput;
use kigi_tools::types::compat::CompatConfig;
use kigi_tools::types::output::{
    BashOutput, ReadFileOutput, ToolOutput as ToolsToolOutput, ToolRunResult,
};
use kigi_workspace::file_system::CodebaseIndexManager;
use kigi_workspace::permission::{
    AccessKind, ClientType, Decision, PermissionEvent, PermissionHandle,
};
use kigi_workspace::session::file_state::{FileStateHandle, FileStateTracker};
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;
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/graph.rs"]
mod graph;
#[path = "acp_session_impl/graph_optimize.rs"]
mod graph_optimize;
#[path = "acp_session_impl/graph_replan.rs"]
mod graph_replan;
#[path = "acp_session_impl/graph_workers.rs"]
mod graph_workers;
#[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"]
pub(crate) mod sampler_turn;
use sampler_turn::*;
#[path = "acp_session_impl/tool_dispatch.rs"]
mod tool_dispatch;
use tool_dispatch::*;
#[path = "acp_session_impl/mcp_snapshot.rs"]
mod mcp_snapshot;
use mcp_snapshot::*;
#[path = "acp_session_impl/tasks_cancel.rs"]
mod tasks_cancel;
use tasks_cancel::*;
#[path = "acp_session_impl/reminders.rs"]
mod reminders;
use reminders::*;
pub use reminders::{CollectedTodoGateInput, TodoGateInput, evaluate_todo_gate};
#[path = "acp_session_impl/laziness_classifier.rs"]
mod laziness_classifier;
pub(crate) use laziness_classifier::*;
#[path = "acp_session_impl/notification_drain.rs"]
mod notification_drain;
use notification_drain::*;
#[path = "acp_session_impl/extensions.rs"]
mod extensions;
use extensions::*;
#[path = "acp_session_impl/memory_dream.rs"]
mod memory_dream;
use memory_dream::*;
#[path = "acp_session_impl/goal_support.rs"]
mod goal_support;
pub(crate) use goal_support::*;
#[path = "acp_session_impl/hook_dispatch.rs"]
mod hook_dispatch;
use hook_dispatch::*;
#[path = "acp_session_impl/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 `kigi/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,
    /// 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,
    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<()>>,
    /// Server-authoritative prompt-queue metadata. `Some` for
    /// user-originated prompts (they appear in the shared queue); `None` for
    /// synthetic / system inputs (auto-wake, nudges, notification drains).
    pub(crate) queue_meta: Option<crate::session::prompt_queue::QueueEntryMeta>,
    /// Whether this prompt entered via the send-now path (explicit, derived
    /// during a blocking wait, or an interjection fallback). Send-now inserts
    /// land behind earlier still-queued send-now prompts so stacked sends
    /// (e.g. during a goal turn, which promotes but never cancels) run FIFO.
    pub(crate) send_now: bool,
}
use crate::session::commands::{NotificationPriority, NotificationSource};
/// Resolved tool names for goal-mode prompts.
///
/// Built by [`SessionActor::resolve_goal_tool_names()`] to avoid
/// duplicating `tool_for_kind()` calls across goal functions.
struct GoalToolNames {
    goal: String,
    task: String,
    todo: String,
}
/// Shared body of the goal-mode system reminder.
///
/// Loaded once at compile time. Used by both `setup_goal` (initial
/// `/goal <objective>`) and `resume_goal` (`/goal resume`).
/// Placeholders are uppercase to avoid collision with the literal
/// `{...}` content in the prompt (e.g. JSON-ish call examples).
pub(super) const GOAL_TASK_DISCIPLINE_TEMPLATE: &str =
    include_str!("templates/goal_task_discipline.md");
pub(super) const GOAL_RULES_TEMPLATE: &str = include_str!("templates/goal_rules.md");
/// Plan-aware preamble folded into the goal-rules block when the planner
/// is enabled and a plan exists. Empty on the legacy path.
const GOAL_PLAN_BLOCK_TEMPLATE: &str = include_str!("templates/goal_plan_block.md");
/// Body of the per-turn directive continuation nudge injected when an
/// active goal is still running. Loaded once at compile time. Carries
/// the live token count, the inlined next concrete step, and the
/// proactive-testing reminder. Substituted via
/// [`render_goal_continuation_directive`]; placeholders are lowercase
/// because the template carries no literal JSON-ish `{...}` content
/// that would collide (`goal_rules.md` keeps uppercase placeholders
/// because it embeds verbatim user prose that may contain `{...}`).
///
/// This template prints only `Tokens: N` (not a used/budget/remaining
/// breakdown). A `/goal … --budget N` cap IS enforced at the turn-end
/// continuation gate (terminal `BudgetLimited`), but the remaining-budget
/// line is not rendered into this nudge.
pub(super) const GOAL_CONTINUATION_DIRECTIVE_TEMPLATE: &str =
    include_str!("templates/goal_continuation_directive.md");
/// Built continuation directive plus the optional premature-stop pattern that
/// the caller emits when it actually continues. Produced by
/// [`SessionActor::prepare_goal_continuation`].
struct GoalContinuationPlan {
    directive: String,
    stop_pattern: Option<&'static str>,
    /// Recommendation embedded in `directive`, if any; handed to
    /// `consume_strategist_note` (compare-and-clear) only once the
    /// directive is committed for delivery.
    strategy_rec: Option<String>,
}
/// Task scheduling state — the only fields that remain behind `TokioMutex`.
///
/// All chat state (conversation, tokens, timing, prompt_index, prompt_texts,
/// agent_edited_paths, last_compaction_prompt_index, sampling_config) has been
/// fully migrated to `ChatStateActor` via `chat_state_handle`.
/// Credentials (api_key, optional extra access key, client_version) live in
/// the `credentials` sync mutex on `SessionActor`.
pub(crate) struct State {
    pub(crate) running_task: Option<AgentTask>,
    pub(crate) pending_inputs: VecDeque<InputItem>,
    pub(crate) pending_notifications: Vec<PendingNotification>,
    /// When true, notifications are buffered but not drained until the next
    /// user-initiated prompt arrives. Set on cancel, cleared on user Prompt.
    pub(crate) notifications_suppressed: bool,
    /// Active prompt is still rewindable until the first outbound prompt-scoped
    /// event is emitted.
    pub(crate) rewindable: bool,
    /// Layer-3 LazinessDetector: number of `<system-reminder>` nudges
    /// injected so far in this (session, model) pair. Reset to 0 by
    /// the actor's main `select!` loop when its `model_switch_rx`
    /// watch channel fires — see the `model_switch_rx.changed()` arm
    /// in `run_session`. The cap is therefore per-(session, model):
    /// switching models is a deliberate user action that resets
    /// expectations.
    pub(crate) nudges_used_this_session: u32,
}
impl State {
    pub(crate) fn clear_pending_notifications(&mut self) {
        self.pending_notifications.clear();
    }
    /// Prompt id of the in-flight turn, if any. This — not
    /// `current_prompt_id` / `is_running_prompt` — is the running-turn
    /// identity for queue sweeps: `running_task` lives under the same lock as
    /// `pending_inputs`, while `handle_completion` clears `current_prompt_id`
    /// before taking this lock, so only `running_task` is race-free here.
    pub(crate) fn running_prompt_id(&self) -> Option<&str> {
        self.running_task.as_ref().map(|t| t.prompt_id.as_str())
    }
    /// Sweep `pending_inputs`, removing entries matching `drop_if` EXCEPT the
    /// running turn's own slot, and return the removed items (callers harvest
    /// them for telemetry counts / `auto_wake_delivered` un-marks).
    ///
    /// Returned items still carry live `respond_to` senders that this helper
    /// does NOT resolve — dropping them unfulfilled is correct only for
    /// synthetic items (no client RPC awaits them, the current callers); a
    /// caller whose predicate can match user-originated items must resolve
    /// each returned item (see `respond_removed_queued_prompt`) or the
    /// client's `session/prompt` hangs and fails spuriously.
    ///
    /// The guard is the safety invariant every sweep must inherit: the
    /// in-flight turn stays at the queue front until `handle_completion` or a
    /// cancel pops it, and an auto-wake turn's reminder makes the model poll
    /// the very task that woke it — so a sweep's predicate can match the
    /// running turn's own slot. Deleting it shifts a queued user prompt to
    /// index 0, where `cancel_running_task`'s resolve-front rule destroys it
    /// (the message never runs and is lost from history). Pid-match, not
    /// index 0: it protects exactly the true running slot even while idle or
    /// if the queue is already desynced from the front-is-running invariant.
    pub(crate) fn sweep_pending_inputs(
        &mut self,
        drop_if: impl Fn(&InputItem) -> bool,
    ) -> Vec<InputItem> {
        let running_pid = self.running_task.as_ref().map(|t| t.prompt_id.clone());
        let mut dropped = Vec::new();
        let mut kept = VecDeque::with_capacity(self.pending_inputs.len());
        for item in std::mem::take(&mut self.pending_inputs) {
            if running_pid.as_deref() != Some(item.prompt_id.as_str()) && drop_if(&item) {
                dropped.push(item);
            } else {
                kept.push_back(item);
            }
        }
        self.pending_inputs = kept;
        dropped
    }
}
/// Canonical "session is idle and safe to inject a synthetic turn"
/// predicate. The post-turn idle consumers — `maybe_drain_notifications`
/// (notification batching), `maybe_fire_laziness_check` (Layer 3 classifier),
/// and `arm_idle_notification` (idle-notification debounce) — all consult this
/// so they share one definition of idleness, with no drift between them.
///
/// Returns `true` exactly when: no turn is running, no user prompt is
/// queued, and notifications haven't been suppressed by a cancel.
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;
/// 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;
/// Spawn-time metadata for a subagent, kept by `subagent_id` so the `SubagentStop` event
/// (whose notification carries neither) can report the subagent's type and description.
#[derive(Clone)]
pub(crate) struct SubagentSpawnInfo {
    pub description: String,
    pub subagent_type: String,
}
/// Phase 3: Post-flight handling after dispatch (inline in execute_tool_calls for now).
pub(crate) struct SessionActor {
    pub(crate) session_info: SessionInfo,
    /// Shared live handle to the current ACP auth method. Normal sessions hold a
    /// clone of `MvpAgent::auth_method_id`, so a mid-session `/login` is picked
    /// up by the per-turn auth gate without re-spawning; subagents instead get a
    /// fresh, isolated handle seeded once at spawn (frozen for their lifetime).
    /// `None` until the agent has selected a method.
    pub(crate) auth_method_id: crate::agent::auth_method::SharedAuthMethodId,
    /// Memoized per-model auth facts, keyed by model id — see
    /// [`SessionActor::model_auth_facts`].
    pub(crate) model_auth_facts:
        std::cell::RefCell<Option<(String, crate::agent::config::ModelAuthFacts)>>,
    /// The catalog KEY this session's model was selected by (`{platform}/{model}`
    /// for a registry model), owned PER SESSION.
    ///
    /// H4: `SamplingConfig::model` is the bare routing slug, and duplicate slugs
    /// across an API-key platform and its subscription-OAuth twin
    /// (`xai`/`xai-grok`, `anthropic`/`claude-pro-max`, `openai`/`openai-codex`)
    /// are BY DESIGN, so the slug alone cannot name the platform. This used to
    /// be read from `ModelsManager::current_model_id()` — a single
    /// PROCESS-GLOBAL cell that Leader mode never writes
    /// (`agent/handlers/model_switch.rs`) and that is last-writer-wins across
    /// concurrent sessions, so both collision directions resolved the wrong
    /// platform: the subscription session lost its resolver (unrecoverable 401
    /// ~1h in) and the API-key session got the pooled OAuth bearer stamped over
    /// its own `sk-…` key. Written at spawn and on every `SetSessionModel`.
    pub(crate) selected_catalog_key: std::cell::RefCell<Option<String>>,
    /// 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
    /// `kigi-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<kigi_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: kigi_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
    pub(crate) supports_backend_search: std::cell::Cell<bool>,
    pub(crate) compactions_remaining:
        std::cell::Cell<Option<kigi_sampling_types::CompactionsRemaining>>,
    pub(crate) compaction_at_tokens:
        std::cell::Cell<Option<kigi_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-kigi-doom-loop-check` header.
    pub(crate) doom_loop_recovery: Option<kigi_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>,
    /// 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<kigi_agent::Agent>,
    /// Dedup slot for `kigi/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 `kigi/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>>,
    /// Whether graph mode (`/graph`) is enabled for this session (feature
    /// flag `KIGI_GRAPH`). Availability additionally requires the goal
    /// harness — graph nodes execute as goals.
    pub(crate) graph_enabled: bool,
    /// Graph mode orchestration tracker: the deterministic DAG scheduler
    /// layered over the goal engine. Modeled after `goal_tracker` above;
    /// all graph state logic lives in `graph_tracker.rs`.
    pub(crate) graph_tracker: Arc<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
    /// Max graph nodes running concurrently (1 = serial G0 behavior).
    /// Cached at actor construction from `resolve_graph_concurrency`.
    pub(crate) graph_concurrency: u32,
    /// Max worker↔verifier rounds per parallel graph node before the
    /// node fails. Cached at actor construction.
    pub(crate) graph_node_rounds: u32,
    /// Max replan passes per graph (0 = replanning off). Cached at
    /// actor construction.
    pub(crate) graph_replan_cap: u32,
    /// Topology optimizer switch (plan-boundary passes; shares the
    /// replan cap). Cached at actor construction.
    pub(crate) graph_optimizer_enabled: bool,
    /// `.kigi` dir at the git root, when the session cwd is in a git
    /// repo — home of the project-level shared graph projection.
    pub(crate) graph_project_dir: Option<std::path::PathBuf>,
    /// Held single-writer lock on the project graph. `Some` while this
    /// session owns the graph (created or resumed it); dropped on
    /// `/graph clear`.
    pub(crate) graph_project_lock:
        std::cell::RefCell<Option<crate::session::graph_project::ProjectGraphLock>>,
    /// `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<
                kigi_tools::implementations::kigi::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<
        kigi_tools::implementations::kigi::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 (`KIGI_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<kigi_tools::implementations::kigi::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,
    /// 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, kigi_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: kigi_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<kigi_hooks::discovery::HookRegistry>>>,
    /// Client hooks from `session/new` `_meta["kigi/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 KIGI_WORKSPACE_ROOT env var.
    pub(crate) hook_resolved_workspace_root: String,
    /// The detected VCS kind for this session's workspace.
    pub(crate) vcs_kind: kigi_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<kigi_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<kigi_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,
    /// 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 `kigi-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: kigi_sampler::SamplerHandle,
    /// Cached recipe for constructing this session's [`kigi_agent::Agent`].
    ///
    /// Populated once at session spawn and then reused by
    /// `handle_rebuild_agent_for_definition` to build a fresh `Agent`
    /// (system prompt, [`kigi_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>,
    /// [`SubagentSpawnInfo`] by `subagent_id`: inserted on `SubagentSpawned`, removed on
    /// `SubagentFinished`.
    pub(crate) subagent_spawn_info: parking_lot::Mutex<HashMap<String, SubagentSpawnInfo>>,
    /// 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: kigi_workspace::WorkspaceOps,
    /// 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>>,
}
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: kigi_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: kigi_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 kigi_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 == kigi_tools::implementations::kigi::SCHEDULER_CREATE_TOOL_NAME),
            hooks: self.hook_registry.borrow().is_some(),
            plugins: self.plugin_registry.borrow().is_some(),
            goal,
            // Graph rides the goal harness: nodes execute as goals, so
            // `/graph` is only real when `/goal` is.
            graph: self.graph_enabled && 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<kigi_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, `kigi 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: &kigi_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)?;
        crate::util::fs::replace_file(&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<kigi_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<kigi_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;
/// 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/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/replace_system_prompt_tests.rs"]
mod replace_system_prompt_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;
/// The same guard for the model→platform lookup (the dual-credential slug
/// collision) and for the stamped aux/summary configs.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_platform_tests.rs"]
mod session_bearer_leak_platform_tests;
/// LEAK guard: the primary Kimi subscription bearer must never ride a request
/// to an API-key registry platform's host, while the subscription-OAuth
/// platforms keep a live resolver from their OWN pooled manager.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_tests.rs"]
mod session_bearer_leak_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/support.rs"]
mod support;
#[cfg(test)]
#[path = "acp_session_tests/turn_completion_emit_tests.rs"]
mod turn_completion_emit_tests;
#[cfg(test)]
#[path = "acp_session_tests/usage_categories_tests.rs"]
mod usage_categories_tests;
#[cfg(test)]
mod tool_meta_stamp_tests {
    //! Pin the `kigi/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 kigi_tools::registry::types::ToolConfig;
    use kigi_tools::tool_taxonomy::TOOL_META_KEY;
    use kigi_workspace::permission::PermissionCommand;
    use tokio::sync::mpsc;
    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 `kigi/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("Kigi: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 kigi/tool");
                assert_eq!(t["name"], "read_file");
                assert_eq!(t["kind"], "read");
                assert_eq!(t["namespace"], "kigi");
                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 kigi/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("Kigi: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 kigi/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_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_reminder_subagent_rules_tests.rs"]
mod goal_reminder_subagent_rules_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/graph/graph_e2e_tests.rs"]
mod graph_e2e_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/interjection_tests.rs"]
mod interjection_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/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/recap_display_only_tests.rs"]
mod recap_display_only_tests;
#[cfg(test)]
#[path = "acp_session_tests/reminder_policy_tests.rs"]
mod reminder_policy_tests;
#[cfg(test)]
#[path = "acp_session_tests/session_thread_tests.rs"]
mod session_thread_tests;
#[cfg(test)]
#[path = "acp_session_tests/tool_auth_retry_tests.rs"]
mod tool_auth_retry_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;
