Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/acp_session.rs
T
ZacharyZhang-NY 4361b03259 Add /graph G0: serial graph engineering mode over the goal engine
A deterministic DAG scheduler layered on the existing goal engine: /graph
<objective> decomposes the objective via a graph-planner subagent, gates the
result through Agentproof-style static validation (cycles, unknown deps,
duplicate slugs, caps), appends a structural final-verification node, then
executes each node as one ordinary goal — planner, worker loop, adversarial
verifier, budget and pause machinery all reused verbatim. The in-turn loop
advances nodes within the same turn (multi-loop closed loop); goal-side
auto-pauses cascade to the graph at a single chokepoint; node goals are armed
with the remaining graph budget so mid-node overruns trip graph-wide.

Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to
<session_dir>/graph/state.json with a clear-tombstone; per-version immutable
baselines and per-node artifact archives live under graph/<graph_id>/.
Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run).

Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed):
the goal-inactive loop break now consults the graph seam (mid-turn
classifier-disabled completions can no longer strand an Active graph), the
pause cascade fires even when no node goal is in flight (cancel during
planning), node bookkeeping precedes the long setup await, /graph clear only
resets the engine it owns, budget trips terminally fail the in-flight node,
and the pause transitions in /goal pause + /graph pause no longer hide inside
debug_assert! (a release-build no-op inherited from upstream).

Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop
e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence
round-trip + tombstone, and status rendering.
2026-07-20 14:07:45 -04:00

1492 lines
75 KiB
Rust

#![allow(clippy::await_holding_refcell_ref)]
#![allow(clippy::arc_with_non_send_sync)]
//! Session actor implementation for the MVP ACP agent.
//!
//! Each session runs as an actor with its own chat history and tool context.
//! The agent owns the client connection and routes commands and events via
//! channels:
//! - Agent → Session: `SessionCommand` (prompt, cancel, shutdown)
//! - Session → Client: `session_notification` via a shared gateway handle
//!
use super::commands::{
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/interjection.rs"]
mod interjection;
#[path = "acp_session_impl/tool_calls.rs"]
mod tool_calls;
#[path = "acp_session_impl/turn.rs"]
mod turn;
pub(crate) use interjection::*;
#[path = "acp_session_impl/laziness.rs"]
mod laziness;
pub(crate) use laziness::*;
#[path = "acp_session_impl/hooks_plugins.rs"]
mod hooks_plugins;
#[path = "acp_session_impl/mcp.rs"]
mod mcp;
#[path = "acp_session_impl/model_switch.rs"]
mod model_switch;
#[path = "acp_session_impl/prompt_queue.rs"]
mod prompt_queue;
#[path = "acp_session_impl/slash_exec.rs"]
mod slash_exec;
use super::PromptOrigin;
use super::acp_types;
use super::chat_persistence;
use super::compaction_config;
use super::helpers;
use super::memory_state;
use super::telemetry;
#[path = "acp_session_impl/prompt_build.rs"]
mod prompt_build;
use prompt_build::*;
#[path = "acp_session_impl/session_mode.rs"]
mod session_mode;
use session_mode::*;
#[path = "acp_session_impl/sampler_turn.rs"]
mod sampler_turn;
use sampler_turn::*;
#[path = "acp_session_impl/tool_dispatch.rs"]
mod tool_dispatch;
use tool_dispatch::*;
#[path = "acp_session_impl/mcp_snapshot.rs"]
mod mcp_snapshot;
use mcp_snapshot::*;
#[path = "acp_session_impl/tasks_cancel.rs"]
mod tasks_cancel;
use tasks_cancel::*;
#[path = "acp_session_impl/reminders.rs"]
mod reminders;
use reminders::*;
pub use reminders::{CollectedTodoGateInput, TodoGateInput, evaluate_todo_gate};
#[path = "acp_session_impl/laziness_classifier.rs"]
mod laziness_classifier;
pub(crate) use laziness_classifier::*;
#[path = "acp_session_impl/notification_drain.rs"]
mod notification_drain;
use notification_drain::*;
#[path = "acp_session_impl/extensions.rs"]
mod extensions;
use extensions::*;
#[path = "acp_session_impl/memory_dream.rs"]
mod memory_dream;
use memory_dream::*;
#[path = "acp_session_impl/goal_support.rs"]
mod goal_support;
pub(crate) use goal_support::*;
#[path = "acp_session_impl/hook_dispatch.rs"]
mod hook_dispatch;
use hook_dispatch::*;
#[path = "acp_session_impl/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)>>,
/// 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>>,
/// `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)?;
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<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;
/// 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;