use async_trait::async_trait; use std::io::{self, BufRead, BufReader, Seek, SeekFrom}; use std::path::Path; use crate::extensions::notification::SessionNotification; use crate::sampling::ConversationItem; use crate::session::info::Info; use crate::session::persistence::Summary; use crate::session::signals::SessionSignals; use crate::session::wire_tags::{ AVAILABLE_COMMANDS_UPDATE_PREFIX, REWIND_MARKER, USER_MESSAGE_CHUNK, }; use crate::tools::todo::TodoState; use agent_client_protocol as acp; use kigi_sampling_types::ReasoningEffort; use kigi_workspace::session::file_state::RewindPoint; pub mod jsonl; pub mod search; pub mod search_fts; pub mod search_remote_sync; pub(crate) mod summary_write; /// Iterator that streams session updates from a JSONL file without loading all into memory. /// Each call to `next()` reads and parses one line. pub struct UpdatesIterator { reader: BufReader, line_buffer: String, } impl UpdatesIterator { /// Create a new iterator over updates in the given file. /// Returns None if the file doesn't exist. pub fn open(path: &Path) -> io::Result> { if !path.exists() { return Ok(None); } let file = std::fs::File::open(path)?; Ok(Some(Self { reader: BufReader::new(file), line_buffer: String::new(), })) } /// Create a new iterator starting at the given byte offset. /// Returns None if the file doesn't exist. /// Used for delta replay: read only updates appended after a known offset. pub fn open_at(path: &Path, offset: u64) -> io::Result> { if !path.exists() { return Ok(None); } let file = std::fs::File::open(path)?; let mut reader = BufReader::new(file); reader.seek(SeekFrom::Start(offset))?; Ok(Some(Self { reader, line_buffer: String::new(), })) } /// Returns the current byte position in the underlying file. /// After iterating, this is the offset of the next unread byte (i.e., EOF /// if all updates were consumed). Used to record the replay end offset for /// subsequent delta replay. pub fn stream_position(&mut self) -> io::Result { self.reader.stream_position() } } impl Iterator for UpdatesIterator { type Item = io::Result; fn next(&mut self) -> Option { self.line_buffer.clear(); match self.reader.read_line(&mut self.line_buffer) { Ok(0) => None, // EOF Ok(_) => { let line = self.line_buffer.trim(); if line.is_empty() { return self.next(); } match SessionUpdateEnvelope::from_str(line) { Ok(update) => Some(Ok(update)), Err(e) => Some(Err(io::Error::new(io::ErrorKind::InvalidData, e))), } } Err(e) => Some(Err(e)), } } } /// Method name for standard ACP session/update notifications. const ACP_SESSION_UPDATE_METHOD: &str = "session/update"; /// Method name for extension session/update notifications. pub(crate) const XAI_SESSION_UPDATE_METHOD: &str = "_kigi/session/update"; /// Pre-rebrand spelling of [`XAI_SESSION_UPDATE_METHOD`], as written into /// `updates.jsonl` by builds that predate the `kigi/` extension-method /// rename. READ-SIDE ALIAS ONLY: parsing accepts both spellings so existing /// session files keep loading; the write side always emits /// [`XAI_SESSION_UPDATE_METHOD`]. pub(crate) const LEGACY_XAI_SESSION_UPDATE_METHOD: &str = "_x.ai/session/update"; /// Whether `method` names the extension session-update rail, accepting the /// current spelling and the legacy pre-rebrand spelling (persisted session /// files written by older builds). pub(crate) fn is_ext_session_update_method(method: &str) -> bool { method == XAI_SESSION_UPDATE_METHOD || method == LEGACY_XAI_SESSION_UPDATE_METHOD } /// A unified session update that can be either an ACP notification or an xAI extension notification. /// This allows storing all session updates in chronological order. /// /// Note: The `Serialize` implementation produces a format without timestamp (for GCS uploads, etc.). /// For disk storage with timestamps, use `SessionUpdateEnvelope` via the JSONL adapter methods. #[derive(Debug, Clone)] pub enum SessionUpdate { /// Standard ACP session/update notification (boxed due to large size) Acp(Box), /// xAI extension session notification (e.g., diff_review) Xai(Box), } impl serde::Serialize for SessionUpdate { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(2))?; match self { SessionUpdate::Acp(notification) => { map.serialize_entry("method", ACP_SESSION_UPDATE_METHOD)?; map.serialize_entry("params", notification)?; } SessionUpdate::Xai(notification) => { map.serialize_entry("method", XAI_SESSION_UPDATE_METHOD)?; map.serialize_entry("params", notification)?; } } map.end() } } impl<'de> serde::Deserialize<'de> for SessionUpdate { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { // Deserialize to a JSON value first to handle both envelope and legacy formats let value = serde_json::Value::deserialize(deserializer)?; SessionUpdateEnvelope::from_value(value).map_err(serde::de::Error::custom) } } /// The serialized envelope for a session update, including metadata for debugging. /// This is the typed structure that gets written to updates.jsonl (disk storage only). /// /// Note: This is separate from `SessionUpdate`'s own serialization to avoid affecting /// other consumers (e.g., network listeners) who don't need the timestamp metadata. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct SessionUpdateEnvelope { /// Unix timestamp (seconds since epoch) when this update was written. /// Useful for debugging timing issues in the updates.jsonl file. #[serde(default)] pub timestamp: u64, /// The method name identifying the update type. /// Either "session/update" for ACP or "_kigi/session/update" for xAI extensions. pub method: String, /// The actual notification payload. pub params: serde_json::Value, } impl SessionUpdateEnvelope { /// Create a new envelope with the current timestamp for disk storage. pub(crate) fn from_update(update: &SessionUpdate) -> Result { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); match update { SessionUpdate::Acp(notification) => Ok(Self { timestamp, method: ACP_SESSION_UPDATE_METHOD.to_string(), params: serde_json::to_value(notification)?, }), SessionUpdate::Xai(notification) => Ok(Self { timestamp, method: XAI_SESSION_UPDATE_METHOD.to_string(), params: serde_json::to_value(notification)?, }), } } /// Convert this envelope back into a SessionUpdate. pub(crate) fn into_update(self) -> Result { if is_ext_session_update_method(&self.method) { let notification: SessionNotification = serde_json::from_value(self.params)?; Ok(SessionUpdate::Xai(Box::new(notification))) } else { // ACP notification (method == "session/update" or unknown) let notification: acp::SessionNotification = serde_json::from_value(self.params)?; Ok(SessionUpdate::Acp(Box::new(notification))) } } /// Try to parse from a JSON value, handling both envelope format and legacy raw format. pub(crate) fn from_value(value: serde_json::Value) -> Result { // Check if this looks like an envelope (has "method" field) if value.get("method").is_some() { let envelope: SessionUpdateEnvelope = serde_json::from_value(value)?; envelope.into_update() } else { // Backwards compatibility: old format without envelope wrapper // Treat as raw ACP notification let notification: acp::SessionNotification = serde_json::from_value(value)?; Ok(SessionUpdate::Acp(Box::new(notification))) } } /// Parse a session update directly from a JSON string, avoiding intermediate `Value` allocation. /// /// Uses a borrowing envelope with `&RawValue` for the params field so the JSON bytes /// for the notification payload are only parsed once (directly to the typed struct) /// instead of twice (str -> Value -> typed). pub(crate) fn from_str(line: &str) -> Result { #[derive(serde::Deserialize)] struct BorrowedEnvelope<'a> { #[serde(default)] method: Option<&'a str>, #[serde(borrow)] params: &'a serde_json::value::RawValue, } // Try to parse as envelope first (has "method" + "params") if let Ok(envelope) = serde_json::from_str::>(line) { let raw_params = envelope.params.get(); return if envelope.method.is_some_and(is_ext_session_update_method) { let notification: SessionNotification = serde_json::from_str(raw_params)?; Ok(SessionUpdate::Xai(Box::new(notification))) } else { let notification: acp::SessionNotification = serde_json::from_str(raw_params)?; Ok(SessionUpdate::Acp(Box::new(notification))) }; } // Backwards compatibility: legacy format without envelope let notification: acp::SessionNotification = serde_json::from_str(line)?; Ok(SessionUpdate::Acp(Box::new(notification))) } } /// All persisted data for a session #[derive(Debug, Clone)] pub struct PersistedData { pub summary: Summary, pub chat_history: Vec, /// All session updates (ACP updates and xAI extension updates) in chronological order pub updates: Vec, pub plan_state: Option, /// Persisted plan mode lifecycle state (None for sessions created before plan mode) pub plan_mode_state: Option, /// Rewind points for session rewind functionality pub rewind_points: Vec, /// Persisted session signals (None for sessions created before signals persistence) pub signals: Option, /// Persisted announcement tracking state (None for sessions before this feature) pub announcement_state: Option, /// Persisted goal mode orchestration state (None for sessions without goal mode) pub goal_mode_state: Option, /// Persisted graph mode orchestration state (None for sessions without graph mode) pub graph_mode_state: Option, } /// Persisted data WITHOUT updates - for memory-efficient session loading #[derive(Debug, Clone)] pub struct PersistedDataLight { pub summary: Summary, pub chat_history: Vec, pub plan_state: Option, pub plan_mode_state: Option, // No `rewind_points` field: the resume path defers them (loaded lazily by // `FileStateTracker`). Use `load_session` for the eager set. /// Persisted session signals (None for sessions created before signals persistence) pub signals: Option, /// Persisted announcement tracking state (None for sessions before this feature) pub announcement_state: Option, /// Persisted goal mode orchestration state (None for sessions without goal mode) pub goal_mode_state: Option, /// Persisted graph mode orchestration state (None for sessions without graph mode) pub graph_mode_state: Option, } /// Result of copying session data #[derive(Debug, Clone)] pub struct CopySessionResult { pub chat_messages_copied: usize, pub updates_copied: usize, pub plan_state_copied: bool, /// Whether `plan_mode.json` (plan mode lifecycle state) was copied. pub plan_mode_state_copied: bool, pub signals_copied: bool, /// Whether `tool_state.json` (persisted tool state, e.g. TodoState) was copied. pub tool_state_copied: bool, /// Whether `announcement_state.json` was copied. pub announcement_state_copied: bool, /// Number of `compaction/segment_*.md` (+ `INDEX.md`) files copied from the /// source session's compaction archive. `0` when disabled or none exist. pub compaction_segments_copied: usize, } /// Options for copying session data during fork #[derive(Debug, Clone)] pub struct CopySessionOptions { /// Parent session ID to set in the forked session's summary. pub parent_session_id: Option, /// Model ID override for the forked session (None = keep source model). pub new_model_id: Option, /// Truncate copied history to this prompt index (0-based, inclusive). pub target_prompt_index: Option, /// When true, skip `transform_conversation_cwd` during copy. /// /// Set for forks where the child should see the original project path /// (e.g. worktree forks with a persisted `display_cwd`). Non-worktree /// forks should keep this false so conversation paths are rewritten to /// the new cwd. pub skip_cwd_transform: bool, /// Stable display path for fork sessions. Persisted in the forked /// summary so the prompt-facing cwd survives session restore/reload. pub prompt_display_cwd: Option, // ── Generic fork extensions (used by subagent + worktree forks) ── /// Override `session_kind` in the forked summary. Defaults to `"fork"`. /// Subagent resume sets `"subagent_resume"`. pub session_kind: Option, /// How the fork's initial context was bootstrapped: `"new"` or `"forked"`. pub fork_context_source: Option, /// Parent prompt/turn ID that triggered this fork. pub fork_parent_prompt_id: Option, /// Whether to copy the plan state file. Defaults to `true`. pub copy_plan_state: bool, /// Whether to copy the plan mode state file. Defaults to `true`. pub copy_plan_mode_state: bool, /// Whether to copy the signals file. Defaults to `true`. pub copy_signals: bool, /// Whether to copy `tool_state.json` (persisted tool state). Defaults to `true`. pub copy_tool_state: bool, /// Whether to copy `announcement_state.json`. Defaults to `true`. pub copy_announcement_state: bool, /// Whether to copy the `compaction/` segment archive (`segment_*.md` + /// `INDEX.md`, the verbose pre-compaction transcripts). Defaults to /// `false` — these can be large and most copy paths don't need them. Forks /// enable it so the child retains the parent's pre-compaction history. pub copy_compaction_segments: bool, /// When true, apply fork-safety filtering to copied chat history: /// - Strip synthetic user messages (doom loop warnings, compaction metadata) /// - Truncate at the last complete turn boundary /// - Remove trailing incomplete assistant responses pub fork_filter: bool, /// Number of inherited parent conversation items. Stored in the child's /// summary so compaction can preserve the inherited prefix. pub inherited_prefix_len: Option, /// When true, strip `reasoning` (thinking/reasoning_content) from all /// assistant messages in the copied chat history. /// /// Set for forks so that the new session does not inherit the prior /// model's chain-of-thought -- each fork starts with a clean slate /// for reasoning on the new prompt. pub strip_reasoning: bool, /// The original workspace directory this worktree session was spawned from. /// Propagated to the forked session's `Summary::source_workspace_dir`. pub source_workspace_dir: Option, } impl Default for CopySessionOptions { fn default() -> Self { Self { parent_session_id: None, new_model_id: None, target_prompt_index: None, skip_cwd_transform: false, prompt_display_cwd: None, session_kind: None, fork_context_source: None, fork_parent_prompt_id: None, copy_plan_state: true, copy_plan_mode_state: true, copy_signals: true, copy_tool_state: true, copy_announcement_state: true, copy_compaction_segments: false, fork_filter: false, inherited_prefix_len: None, strip_reasoning: false, source_workspace_dir: None, } } } /// Chunk `_meta.promptIndex` on an ACP `UserMessageChunk`, if present. fn acp_user_chunk_prompt_index(update: &SessionUpdate) -> Option { let SessionUpdate::Acp(n) = update else { return None; }; let acp::SessionUpdate::UserMessageChunk(chunk) = &n.update else { return None; }; chunk .meta .as_ref() .and_then(|m| m.get("promptIndex")) .and_then(|v| v.as_u64()) .map(|v| v as usize) } fn is_acp_user_message_chunk(update: &SessionUpdate) -> bool { matches!( update, SessionUpdate::Acp(n) if matches!(n.update, acp::SessionUpdate::UserMessageChunk(_)) ) } /// Tracks user-message runs for turn counting (updates truncate / filter_rewind). /// /// Progressive: every user run counts until the first `promptIndex` appears; /// after that only marked runs count (mid-turn phantoms omit the marker). /// A change of `promptIndex` (including unmarked ↔ marked) opens a new run — /// matching replay's split so back-to-back cancelled prompts stay distinct. struct UserRunTurnTracker { seen_marker: bool, in_user: bool, /// `promptIndex` of the current user run (`None` = unmarked / phantom run). current_run_pi: Option, } impl UserRunTurnTracker { fn new() -> Self { Self { seen_marker: false, in_user: false, current_run_pi: None, } } /// Returns true if this user chunk opens a **counted** turn. fn on_user_chunk(&mut self, prompt_index: Option) -> bool { if prompt_index.is_some() { self.seen_marker = true; } let counts = if self.seen_marker { prompt_index.is_some() } else { true }; let new_run = if !self.in_user { true } else if self.seen_marker || prompt_index.is_some() { prompt_index != self.current_run_pi } else { false }; if new_run { self.current_run_pi = prompt_index; self.in_user = true; counts } else { self.in_user = true; false } } fn on_non_user(&mut self) { self.in_user = false; self.current_run_pi = None; } } /// Calculate how many updates to keep for a given target prompt index (0-based, inclusive). /// /// Progressive: unmarked user runs before the first `_meta.promptIndex` count /// as turns; after the first marker only marked runs count (phantoms omit it). pub fn updates_truncate_for_prompt(updates: &[SessionUpdate], target_prompt_index: usize) -> usize { let mut user_turn_count = 0; let mut tracker = UserRunTurnTracker::new(); for (i, update) in updates.iter().enumerate() { if is_acp_user_message_chunk(update) { if tracker.on_user_chunk(acp_user_chunk_prompt_index(update)) { user_turn_count += 1; if user_turn_count > target_prompt_index + 1 { return i; } } } else { tracker.on_non_user(); } } updates.len() } /// Storage adapter trait for session persistence /// Abstracts over different storage backends (JSONL, SQLite, etc.) #[async_trait] pub trait StorageAdapter: Send + Sync { /// Initialize a new session or load existing one /// Returns the Summary (creates if needed, loads if exists) async fn init_session(&self, info: &Info, model_id: acp::ModelId) -> io::Result; /// Set the session title unconditionally (manual `/rename`); last write /// wins. Also marks the title manual (`Summary::title_is_manual`) so /// clients restore the prompt-border title on resume. async fn update_session_title(&self, info: &Info, session_title: String) -> io::Result<()>; /// Set the session title only if the session has no title yet, used by /// automatic LLM title generation so it never overwrites a manual /// `/rename`. Never marks the title manual. Returns `true` if the title /// was written, `false` if an existing title was preserved. The check and /// write are atomic under the summary lock, so a concurrent manual rename /// always wins. async fn set_generated_title_if_absent( &self, info: &Info, session_title: String, ) -> io::Result; /// Append a session update (ACP update or xAI extension update) and increment counter async fn append_update(&self, info: &Info, update: &SessionUpdate) -> io::Result<()>; /// Append a chat message and increment counter async fn append_chat_message(&self, info: &Info, message: &ConversationItem) -> io::Result<()>; /// Update the current model in summary (delegates to /// `update_current_model_and_agent` with `agent_name = None`). async fn update_current_model(&self, info: &Info, model_id: &acp::ModelId) -> io::Result<()> { self.update_current_model_and_agent(info, model_id, None, None) .await } /// Update the current model and agent name in summary. /// `agent_name` is the resolved agent definition name /// persisted so session resume doesn't depend on the mutable model catalog. /// `None` leaves the existing `agent_name` unchanged (used by legacy callers /// that only update the model ID). async fn update_current_model_and_agent( &self, info: &Info, model_id: &acp::ModelId, agent_name: Option<&str>, reasoning_effort: Option>, ) -> io::Result<()>; /// Update the collection ID for telemetry tracing async fn update_collection_id(&self, info: &Info, collection_id: &str) -> io::Result<()>; /// Update the persisted HEAD commit and branch in summary async fn update_git_head( &self, info: &Info, commit: Option, branch: Option, ) -> io::Result<()>; /// Update the monotonic telemetry trace turn counter ("next turn" value). async fn update_next_trace_turn( &self, info: &Info, next_trace_turn: u64, request_id: Option<&str>, ) -> io::Result<()>; /// Write/update the plan state async fn write_plan_state(&self, info: &Info, state: &TodoState) -> io::Result<()>; /// Write/update plan mode lifecycle state async fn write_plan_mode_state( &self, info: &Info, state: &crate::session::plan_mode::PlanModeSnapshot, ) -> io::Result<()>; /// Write/update the session signals snapshot async fn write_signals(&self, info: &Info, signals: &SessionSignals) -> io::Result<()>; /// Write/update the announcement tracking state async fn write_announcement_state( &self, info: &Info, state: &crate::session::announcement_state::AnnouncementState, ) -> io::Result<()>; /// Write/update the goal mode orchestration state async fn write_goal_mode_state( &self, info: &Info, state: &crate::session::goal_tracker::GoalOrchestration, ) -> io::Result<()>; /// Write/update the graph mode orchestration state. `None` removes /// the state file (tombstone after `/graph clear`). async fn write_graph_mode_state( &self, info: &Info, state: Option<&crate::session::graph_tracker::GraphOrchestration>, ) -> io::Result<()>; /// Load all persisted data for a session async fn load_session(&self, info: &Info) -> io::Result; /// Load session data WITHOUT updates (for memory efficiency when updates /// will be streamed). Implementations also do NOT read rewind points here; /// those are deferred and lazily loaded on demand from the path returned by /// [`rewind_points_file_path`](StorageAdapter::rewind_points_file_path). async fn load_session_without_updates(&self, info: &Info) -> io::Result; /// Loads the summary of the session async fn load_summary(&self, info: &Info) -> io::Result; /// List session summaries, optionally filtered by current working directory. /// When `cwd` is `None`, returns summaries for all sessions. async fn list_sessions(&self, cwd: Option<&str>) -> io::Result>; /// Permanently delete a session's stored data (all files for the /// session). Implementations must treat a missing session as success /// (idempotent delete). async fn delete_session(&self, info: &Info) -> io::Result<()>; /// Append a rewind point for session rewind functionality async fn append_rewind_point(&self, info: &Info, point: &RewindPoint) -> io::Result<()>; /// Load all rewind points for a session async fn load_rewind_points(&self, info: &Info) -> io::Result>; /// Sync all session files to disk. Called before CopyFile to ensure all writes are persisted. async fn sync_session_files(&self, info: &Info) -> io::Result<()>; /// Truncate rewind points from a specific prompt index (inclusive) /// Used when rewinding to remove future history async fn truncate_rewind_points_from(&self, info: &Info, from_index: usize) -> io::Result<()>; /// Merge rewind points at indices `>= target_index` into the point at /// `target_index - 1` and drop the folded points, as a read-modify-write on /// disk (used after a ConversationOnly rewind). Reading the current on-disk /// set makes this authoritative: it never relies on a (possibly partially /// loaded) in-memory tracker, so historical points can't be lost. async fn merge_rewind_points_from(&self, info: &Info, target_index: usize) -> io::Result<()>; /// Replace the entire chat history (used for compaction and rewind) async fn replace_chat_history( &self, info: &Info, messages: &[ConversationItem], ) -> io::Result<()>; /// Copy session data from source to target, transforming session IDs /// The `options` parameter allows setting parent session tracking and model overrides. async fn copy_session_data( &self, source_info: &Info, target_info: &Info, options: CopySessionOptions, ) -> io::Result; /// Load only user prompts from a session's updates file. /// This is an optimized method that avoids loading chat_history, plan_state, etc. /// Returns user prompts in chronological order. async fn load_prompts_only(&self, info: &Info) -> io::Result>; /// Load assistant text content from a session's updates file. /// Returns assistant responses in chronological order, extracted from ContentChunk text. async fn load_assistant_text(&self, info: &Info) -> io::Result>; /// Load tool metadata from a session's updates file. /// Per Phase 1 contract (ACP data model): /// - Tool name: from `ToolCall.title` (display name; acp::ToolCall has no .name field) /// - File paths: from `ToolCall.locations[].path` (ACP stores locations, not parsed arguments) /// - Errors: skipped (no is_error field on acp::SessionUpdate::ToolCallUpdate) async fn load_tool_metadata(&self, info: &Info) -> io::Result>; /// Get the path to the updates file for streaming reads. /// Returns None if the storage backend doesn't support streaming. fn updates_file_path(&self, info: &Info) -> Option; /// Path to the rewind-points file for lazy/deferred loading, or None if the /// backend doesn't persist them to a streamable file. The adapter owns the /// on-disk layout, so callers must use this rather than recomputing the path /// (it differs for non-default storage modes, e.g. subagent/fork sessions). fn rewind_points_file_path(&self, info: &Info) -> Option; /// Append a feedback entry (user feedback) to feedback.jsonl async fn append_feedback( &self, info: &Info, entry: &crate::session::persistence::LocalFeedbackEntry, ) -> io::Result<()>; /// Append a /btw side question entry to btw_history.jsonl async fn append_btw( &self, info: &Info, entry: &crate::session::persistence::BtwEntry, ) -> io::Result<()>; /// Write a compaction checkpoint file to `compaction_checkpoints/{checkpoint_id}.json`. async fn write_compaction_checkpoint( &self, info: &Info, checkpoint: &crate::extensions::notification::CompactionCheckpointFile, ) -> io::Result<()>; /// Write a compaction request artifact to `compaction_requests/{request_id}.json`. /// Captures the exact request sent to the compaction model and the response /// (or final error) it produced. Used for offline prompt iteration. async fn write_compaction_request( &self, info: &Info, request: &crate::extensions::notification::CompactionRequestFile, ) -> io::Result<()>; /// Write a recap request artifact to `recap_requests/{request_id}.json`. /// Captures the exact request sent for `/recap` or auto recap and the /// response (or error). Used for offline recap prompt / garble analysis. async fn write_recap_request( &self, info: &Info, request: &crate::extensions::notification::RecapRequestFile, ) -> io::Result<()>; /// Render+write `compaction/segment_NNN.md` (storage assigns the resume-safe /// index) and append its `INDEX.md` row. async fn write_compaction_segment( &self, info: &Info, segment: &crate::extensions::notification::CompactionSegmentFile, ) -> io::Result<()>; /// Read a compaction checkpoint file by its relative path within the session directory. async fn read_compaction_checkpoint( &self, info: &Info, checkpoint_file: &str, ) -> io::Result; } pub use jsonl::JsonlStorageAdapter; /// Extracts `method` and raw `params` from an updates.jsonl envelope /// without parsing the notification payload. #[derive(serde::Deserialize)] pub(crate) struct RawLinePeek<'a> { #[serde(default)] pub method: Option<&'a str>, #[serde(borrow, default)] pub params: Option<&'a serde_json::value::RawValue>, } /// Peeks at `update.sessionUpdate` tag and `_meta` without full deserialization. #[derive(serde::Deserialize)] pub(crate) struct RawParamsPeek<'a> { #[serde(borrow, default)] pub update: Option>, #[serde(borrow, default, rename = "_meta")] pub meta: Option<&'a serde_json::value::RawValue>, } #[derive(serde::Deserialize)] pub(crate) struct RawUpdatePeek<'a> { #[serde(rename = "sessionUpdate")] pub session_update: &'a str, #[serde(default)] pub target_prompt_index: Option, /// Chunk `_meta.promptIndex` when present (owned; not borrowed). #[serde(default, rename = "_meta")] pub meta: Option, } #[derive(serde::Deserialize)] pub(crate) struct RawChunkMetaPeek { #[serde(default, rename = "promptIndex")] pub prompt_index: Option, } /// Filter rewind dead branches from raw JSONL lines. /// Skips parsing entirely when no rewind markers are present. /// /// This is the canonical implementation of rewind dead-branch filtering, /// used by both the initial replay and delta replay paths. pub(crate) fn filter_rewind_lines<'a>(lines: Vec<&'a str>) -> Vec<&'a str> { let has_rewinds = lines.iter().any(|l| l.contains(&*REWIND_MARKER)); if !has_rewinds { return lines; } let mut result: Vec<&str> = Vec::with_capacity(lines.len()); let mut prompt_starts: Vec = Vec::new(); let mut tracker = UserRunTurnTracker::new(); for line in &lines { let (raw_params, is_xai) = if let Ok(env) = serde_json::from_str::>(line) { let raw = env.params.map(|p| p.get()).unwrap_or(line); let xai = env.method.is_some_and(is_ext_session_update_method); (raw, xai) } else { (*line, false) }; let peek = serde_json::from_str::>(raw_params) .ok() .and_then(|p| p.update); let tag = peek .as_ref() .map(|u| (u.session_update, u.target_prompt_index)); if is_xai && let Some((s, Some(target))) = tag.as_ref().map(|(s, t)| (*s, *t)) && s == *REWIND_MARKER { let trunc = prompt_starts.get(target).copied().unwrap_or(result.len()); result.truncate(trunc); prompt_starts.truncate(target); tracker.on_non_user(); continue; } let is_user_chunk = !is_xai && tag .as_ref() .map(|(s, _)| *s == *USER_MESSAGE_CHUNK) .unwrap_or(false); if is_user_chunk { let pi = peek.as_ref().and_then(|u| { u.meta .as_ref() .and_then(|m| m.prompt_index.map(|v| v as usize)) }); if tracker.on_user_chunk(pi) { prompt_starts.push(result.len()); } } else { tracker.on_non_user(); } result.push(line); } result } /// Filter rewind dead branches from typed `SessionUpdate` values. /// /// This is the typed equivalent of [`filter_rewind_lines`] — same algorithm /// (prompt-boundary tracking + truncation on `RewindMarker`) but operates on /// fully-deserialized updates instead of raw JSON strings. pub fn filter_rewind_updates(updates: Vec) -> Vec { let has_rewinds = updates.iter().any(|u| { matches!( u, SessionUpdate::Xai(n) if matches!( n.update, crate::extensions::notification::SessionUpdate::RewindMarker { .. } ) ) }); if !has_rewinds { return updates; } let mut result: Vec = Vec::with_capacity(updates.len()); let mut prompt_starts: Vec = Vec::new(); let mut tracker = UserRunTurnTracker::new(); for update in updates { // Check for rewind marker — truncate back to the target prompt. if let SessionUpdate::Xai(ref n) = update && let crate::extensions::notification::SessionUpdate::RewindMarker { target_prompt_index, .. } = &n.update { let trunc = prompt_starts .get(*target_prompt_index) .copied() .unwrap_or(result.len()); result.truncate(trunc); prompt_starts.truncate(*target_prompt_index); tracker.on_non_user(); continue; } if is_acp_user_message_chunk(&update) { if tracker.on_user_chunk(acp_user_chunk_prompt_index(&update)) { prompt_starts.push(result.len()); } } else { tracker.on_non_user(); } result.push(update); } result } /// Strip `` and `` XML wrappers from user /// message chunks so replayed/exported prompts show clean text. /// /// Only modifies `UserMessageChunk` text content; all other update types /// pass through unchanged. The tags are injected by the subagent fork/resume /// logic in `subagent.rs`. pub fn strip_context_wrappers(update: acp::SessionUpdate) -> acp::SessionUpdate { let acp::SessionUpdate::UserMessageChunk(mut chunk) = update else { return update; }; if let acp::ContentBlock::Text(ref mut t) = chunk.content { for tag in &["fork-context", "resume-context"] { let open = format!("<{tag}>"); let close = format!(""); if let Some(start) = t.text.find(&open) && let Some(rel_end) = t.text[start + open.len()..].find(&close) { let end = start + open.len() + rel_end; let remove_end = end + close.len(); t.text = format!("{}{}", &t.text[..start], t.text[remove_end..].trim_start()); } } } acp::SessionUpdate::UserMessageChunk(chunk) } /// Load session updates from disk, ready for replay or export. /// /// This is the canonical way to get replay-ready typed updates from a session /// ID. It: /// 1. Locates the session directory via [`find_session_dir_by_id`] /// 2. Opens `updates.jsonl` via [`UpdatesIterator`] /// 3. Collects all parseable updates (skipping malformed lines) /// 4. Filters rewind dead branches via [`filter_rewind_updates`] /// 5. Strips `` / `` wrappers from user messages /// via [`strip_context_wrappers`] /// /// Returns `None` if the session is not found or has no `updates.jsonl`. /// Returns only `SessionUpdate::Acp` updates (xAI-extension updates like /// rewind markers and compaction signals are consumed by the filter and not /// included in the output). pub fn load_updates_for_replay( session_id: &str, ) -> std::io::Result>> { let Some(session_dir) = crate::session::persistence::find_session_dir_by_id(session_id) else { return Ok(None); }; load_updates_for_replay_from_dir(&session_dir) } /// Like [`load_updates_for_replay`], but resolves the session under a specific kigi home. pub fn load_updates_for_replay_at( session_id: &str, kigi_home: &std::path::Path, ) -> std::io::Result>> { let sessions_root = kigi_home.join("sessions"); let Some(session_dir) = crate::session::persistence::find_session_dir_by_id_in_root(session_id, &sessions_root) else { return Ok(None); }; load_updates_for_replay_from_dir(&session_dir) } fn load_updates_for_replay_from_dir( session_dir: &std::path::Path, ) -> std::io::Result>> { let updates_path = session_dir.join("updates.jsonl"); let Some(iter) = UpdatesIterator::open(&updates_path)? else { return Ok(None); }; let all: Vec = iter.filter_map(|r| r.ok()).collect(); let filtered = filter_rewind_updates(all); let acp_updates: Vec = filtered .into_iter() .filter_map(|u| match u { SessionUpdate::Acp(notif) => Some(strip_context_wrappers(notif.update)), SessionUpdate::Xai(_) => None, }) .collect(); Ok(Some(acp_updates)) } pub(crate) struct PreparedReplay<'a> { pub lines: Vec<&'a str>, pub mark_replay: bool, pub last_tokens: u64, /// Highest `eventId` counter across all live (rewind-filtered) lines, used /// to re-seed the process-global event counter on resume so post-load live /// events keep monotonically increasing ids (see /// [`crate::util::event_id::ensure_event_counter_at_least`]). `None` when no /// line carried a parseable `eventId` (older shell). pub max_event_seq: Option, pub total_live: usize, /// Replayed spawns with no matching finish (a rewind can drop the finish) — /// `(subagent_id, child_session_id)`, reconciled on load. pub unfinished_subagents: Vec<(String, String)>, } /// Unpaired spawns across the rewind-filtered timeline. Substring pre-filter /// keeps non-subagent lines off the JSON path. fn collect_unfinished_subagents(filtered: &[&str]) -> Vec<(String, String)> { use crate::extensions::notification::SessionUpdate as Update; let mut pending: std::collections::BTreeMap = std::collections::BTreeMap::new(); for line in filtered { if !line.contains("subagent_spawned") && !line.contains("subagent_finished") { continue; } // Parse the typed notification. Envelope lines nest it under `params`; // legacy lines put it at the top level (fall back to the whole line, // matching `filter_rewind_lines`). let raw = serde_json::from_str::>(line) .ok() .and_then(|e| e.params.map(|p| p.get())) .unwrap_or(line); let Ok(notification) = serde_json::from_str::(raw) else { continue; }; match notification.update { Update::SubagentSpawned { subagent_id, child_session_id, .. } => { pending.insert(subagent_id, child_session_id); } Update::SubagentFinished { subagent_id, .. } => { pending.remove(&subagent_id); } _ => {} } } pending.into_iter().collect() } /// The raw `_meta` object of a persisted line, if any, without allocating a /// `serde_json::Value`. Handles both the enveloped (`{method,params}`) and legacy /// (params-at-top-level) on-disk formats. fn line_meta(line: &str) -> Option<&serde_json::value::RawValue> { let env = serde_json::from_str::>(line).ok()?; let raw = env.params.map(|p| p.get()).unwrap_or(line); serde_json::from_str::>(raw).ok()?.meta } /// The `"update":` object key (a protocol key, not an enum discriminant). The /// structural `params.update` is the FIRST occurrence in a persisted line: the /// envelope prefix has no `"update":`, and any nested `"update"` (in `_meta` or a /// tool's `rawInput`/`rawOutput`) is serialized after it, so the first match delimits it. const UPDATE_KEY: &str = r#""update":"#; /// Is this persisted line an `available_commands_update`? /// /// The slash-command catalog is re-advertised in full after every `session/load`, /// so the historical copies in `updates.jsonl` are redundant on replay and /// dominate large sessions (~51% of bytes in pathological cases). The lines stay /// on disk; this only skips forwarding them to the client. /// /// A cheap [`AVAILABLE_COMMANDS_UPDATE_PREFIX`] substring pre-filter, then a /// positional confirm that the value at the first [`UPDATE_KEY`] begins with the /// ACU discriminant. Reads only the prefix (never the huge `availableCommands` /// array), so it can't be fooled by the discriminant embedded in `_meta` or a /// tool payload (never the first `"update":`). pub(crate) fn line_is_available_commands_update(line: &str) -> bool { if !line.contains(&*AVAILABLE_COMMANDS_UPDATE_PREFIX) { return false; } line.find(UPDATE_KEY) .map(|pos| { line[pos + UPDATE_KEY.len()..] .trim_start() .starts_with(&*AVAILABLE_COMMANDS_UPDATE_PREFIX) }) .unwrap_or(false) } // `_meta` protocol field names (not enum discriminants). /// `_meta` key holding the running token count. The serde `rename` below must /// match it by hand (serde attrs can't reference a const). const TOTAL_TOKENS_KEY: &str = "totalTokens"; /// `_meta` key holding the per-event id used for cursor-based reconnect. const EVENT_ID_KEY: &str = "eventId"; /// Extract `_meta.totalTokens` from a persisted update line without allocating a /// `serde_json::Value`. Returns `None` when the line carries no token count. fn line_total_tokens(line: &str) -> Option { if !line.contains(TOTAL_TOKENS_KEY) { return None; } #[derive(serde::Deserialize)] struct TokensPeek { #[serde(rename = "totalTokens")] total_tokens: Option, } serde_json::from_str::(line_meta(line)?.get()) .ok() .and_then(|t| t.total_tokens) } /// This line's `_meta.eventId`, if any. Cheap peek (no `Value`). fn line_event_id(line: &str) -> Option> { if !line.contains(EVENT_ID_KEY) { return None; } #[derive(serde::Deserialize)] struct EventIdPeek<'a> { // `Cow` so an escaped eventId still parses and compares equal // (`Option` always deserializes owned; `&str` would error). #[serde(rename = "eventId", borrow)] event_id: Option>, } serde_json::from_str::>(line_meta(line)?.get()) .ok() .and_then(|e| e.event_id) } /// Does this line's `_meta.eventId` equal `cursor_id`? fn line_has_event_id(line: &str, cursor_id: &str) -> bool { line_event_id(line).as_deref() == Some(cursor_id) } /// Rewind-filter, resolve the reconnect cursor, drop redundant command catalogs, /// and scan `totalTokens`. Pure data processing — no gateway, no async. /// /// The cursor is resolved BEFORE dropping ACUs: ACUs carry `_meta.eventId` and the /// post-load re-advertise is usually the *last* persisted event, so an idle client /// commonly reconnects with an ACU's eventId as its cursor. Resolving against the /// ACU-inclusive set keeps incremental reconnect cheap instead of a full replay. pub(crate) fn prepare_replay_lines<'a>( raw_contents: &'a str, cursor: Option<&str>, ) -> PreparedReplay<'a> { let filtered = filter_rewind_lines( raw_contents .lines() .filter(|l| !l.trim().is_empty()) .collect(), ); // Highest `eventId` counter across all live (rewind-filtered) lines, used to // re-seed the process-global event counter on resume so post-load live events // keep monotonically increasing ids. eventId is "{sessionId}-{counter}" and // session ids contain dashes, so the counter is the suffix after the LAST '-'. let mut max_event_seq: Option = None; for line in &filtered { if line.contains("eventId") && let Ok(env) = serde_json::from_str::>(line) && let Some(raw) = env.params.map(|p| p.get()) && let Ok(pp) = serde_json::from_str::>(raw) && let Some(meta_raw) = pp.meta && let Ok(meta) = serde_json::from_str::(meta_raw.get()) && let Some(seq) = meta .get("eventId") .and_then(|v| v.as_str()) .and_then(|s| s.rsplit('-').next()) .and_then(|c| c.parse::().ok()) { max_event_seq = Some(max_event_seq.map_or(seq, |m| m.max(seq))); } } // Last live update carrying `_meta.totalTokens` (reverse scan, last-wins) over // the full surviving timeline, so the count reflects total session state. // ACUs never carry `totalTokens`, so scanning the ACU-inclusive set is safe. let last_tokens = filtered .iter() .rev() .find_map(|l| line_total_tokens(l)) .unwrap_or(0); // Resolve the reconnect cursor against the ACU-inclusive set. `mark_replay` // is true for a full historical replay (no cursor, or cursor not found). // // The cursor is refused when a FORWARDED tail line lacks an `eventId`: // such a line cannot be covered by a future cursor and has no client-side // dedup, so re-delivering it as live would re-apply it. Full replay is // the safe fallback — the client swaps it in wholesale. Id-less lines // come from older binaries or any emitter outside the stamping // chokepoints (see `ensure_event_id_meta`). ACU lines are exempt: they // are dropped below, never forwarded. let cursor_pos = cursor .and_then(|id| filtered.iter().rposition(|l| line_has_event_id(l, id))) .filter(|&pos| { let bounded = filtered[pos + 1..] .iter() .all(|l| line_is_available_commands_update(l) || line_event_id(l).is_some()); if !bounded { tracing::warn!( "replay: post-cursor tail contains eventId-less lines; full replay instead" ); } bounded }); let mark_replay = cursor_pos.is_none(); let start = cursor_pos.map_or(0, |pos| pos + 1); // Single pass: drop ACUs (kept on disk), collect the post-cursor tail to // forward, and count the full ACU-free live set for the skip log. let mut lines: Vec<&str> = Vec::with_capacity(filtered.len().saturating_sub(start)); let mut total_live = 0usize; for (i, &line) in filtered.iter().enumerate() { if line_is_available_commands_update(line) { continue; } total_live += 1; if i >= start { lines.push(line); } } PreparedReplay { lines, mark_replay, last_tokens, max_event_seq, total_live, unfinished_subagents: collect_unfinished_subagents(&filtered), } } /// Blank-strip, drop redundant command catalogs, and rewind-filter a raw /// `updates.jsonl` segment. Shared by the delta-replay path (which has no /// reconnect cursor); the initial replay path is [`prepare_replay_lines`], which /// additionally resolves a cursor (and so must see ACUs) before dropping them. pub(crate) fn filter_delta_replay_lines(contents: &str) -> Vec<&str> { let live: Vec<&str> = contents .lines() .filter(|l| !l.trim().is_empty() && !line_is_available_commands_update(l)) .collect(); filter_rewind_lines(live) } // ============================================================================ // Selective prompt-extraction parser // ============================================================================ /// An event yielded by [`PromptExtractIterator`]. /// /// Each event represents the minimal information extracted from one /// `updates.jsonl` line without deserializing the full typed notification. #[derive(Debug, PartialEq)] pub enum PromptExtractEvent { /// A text chunk from a `UserMessageChunk` ACP update. /// /// Multiple consecutive `UserTextChunk` events belong to the same user /// message and should be concatenated by the caller. `prompt_index` is the /// chunk `_meta.promptIndex` when the turn pipeline stamped one. UserTextChunk { text: String, prompt_index: Option, }, /// A `RewindMarker` xAI update: truncate accumulated prompts to this index. /// /// Any in-progress user message should be flushed before truncating. RewindTo(usize), /// Any other update type — signals that the current user message (if any) /// has ended. NotUserMessage, } impl PromptExtractEvent { pub fn user_text(text: impl Into) -> Self { Self::UserTextChunk { text: text.into(), prompt_index: None, } } pub fn user_text_pi(text: impl Into, prompt_index: usize) -> Self { Self::UserTextChunk { text: text.into(), prompt_index: Some(prompt_index), } } } /// Iterator that streams [`PromptExtractEvent`]s from a `updates.jsonl` file. /// /// Unlike [`UpdatesIterator`], this never materialises a full /// `acp::SessionNotification` or `SessionNotification`. Instead it uses /// zero-copy `serde_json` deserialization with `&RawValue` to peek at the /// discriminant field and only extracts the one or two fields actually needed /// for prompt reconstruction: /// /// - ACP `"user_message_chunk"` → `update.content.text` /// - xAI `"rewind_marker"` → `update.target_prompt_index` /// - everything else → [`PromptExtractEvent::NotUserMessage`] /// /// Parse errors on individual lines are treated conservatively as /// `NotUserMessage` (matching the "skip malformed line" behavior of the /// original [`UpdatesIterator`]-based path, but safely terminating any /// in-progress user-message accumulation). pub struct PromptExtractIterator { reader: std::io::BufReader, line_buffer: String, } impl PromptExtractIterator { /// Open a `updates.jsonl` file for selective prompt extraction. /// /// Returns `None` if the file does not exist. pub fn open(path: &std::path::Path) -> std::io::Result> { if !path.exists() { return Ok(None); } let file = std::fs::File::open(path)?; Ok(Some(Self { reader: std::io::BufReader::new(file), line_buffer: String::new(), })) } } impl Iterator for PromptExtractIterator { type Item = PromptExtractEvent; fn next(&mut self) -> Option { loop { self.line_buffer.clear(); match std::io::BufRead::read_line(&mut self.reader, &mut self.line_buffer) { Ok(0) => return None, // EOF Err(_) => return Some(PromptExtractEvent::NotUserMessage), Ok(_) => {} } let line = self.line_buffer.trim(); if line.is_empty() { continue; } return Some(parse_prompt_extract_event(line)); } } } /// Assemble accumulated user-prompt strings from a stream of [`PromptExtractEvent`]s. /// /// Encapsulates the accumulation, flush, and rewind-truncation rules in one /// place so that every caller — whether reading from disk or from an in-memory /// iterator — applies identical prompt-extraction semantics: /// /// - Consecutive `UserTextChunk` events are concatenated into one prompt until /// a non-user event or a `promptIndex` change opens a new run. /// - Progressive counting (same as [`UserRunTurnTracker`]): every user run /// counts until the first `_meta.promptIndex`; after that only marked runs /// count (mid-turn phantoms are dropped from the list). /// - `NotUserMessage` flushes any in-progress prompt. /// - `RewindTo(n)` flushes then truncates the list to `n` **counted** prompts. /// /// The resulting `Vec` is the resume `prompt_texts` / rewind-picker index /// space: `prompt_index == prompts.len()` after load, matching live turn /// stamping (not raw user-message count). pub fn collect_prompts_from_events(iter: impl Iterator) -> Vec { let mut prompts: Vec = Vec::new(); let mut current = String::new(); let mut in_user = false; let mut current_run_pi: Option = None; let mut current_counts = false; let mut seen_marker = false; fn flush( prompts: &mut Vec, current: &mut String, in_user: &mut bool, current_run_pi: &mut Option, current_counts: &mut bool, ) { if *in_user { if *current_counts { let trimmed = current.trim().to_string(); if !trimmed.is_empty() { prompts.push(trimmed); } } current.clear(); *in_user = false; *current_run_pi = None; *current_counts = false; } } for event in iter { match event { PromptExtractEvent::UserTextChunk { text, prompt_index } => { if prompt_index.is_some() { seen_marker = true; } let counts = if seen_marker { prompt_index.is_some() } else { true }; let new_run = if !in_user { true } else if seen_marker || prompt_index.is_some() { prompt_index != current_run_pi } else { false }; if new_run { flush( &mut prompts, &mut current, &mut in_user, &mut current_run_pi, &mut current_counts, ); in_user = true; current_run_pi = prompt_index; current_counts = counts; current.push_str(&text); } else { current.push_str(&text); if current_run_pi.is_none() && prompt_index.is_some() { current_run_pi = prompt_index; current_counts = true; } } } PromptExtractEvent::RewindTo(target_index) => { // Flush any in-progress user message before truncating. // Rewinding TO prompt N keeps prompts[0..N]. flush( &mut prompts, &mut current, &mut in_user, &mut current_run_pi, &mut current_counts, ); prompts.truncate(target_index); } PromptExtractEvent::NotUserMessage => { flush( &mut prompts, &mut current, &mut in_user, &mut current_run_pi, &mut current_counts, ); } } } flush( &mut prompts, &mut current, &mut in_user, &mut current_run_pi, &mut current_counts, ); prompts } /// Collect assistant text from a stream of [`SessionUpdate`]s. /// /// Extracts `ContentChunk.text` from `AgentMessageChunk` updates. /// Capped at 100k chars total. /// /// Note: This collector does not honor rewind markers (unlike PromptExtractIterator). /// Rewound-away branches may still contribute to FTS index. This is a known limitation; /// fix by using a rewind-aware replay model (future work). pub fn collect_assistant_text( iter: impl Iterator>, ) -> Vec { const MAX_CHARS: usize = 100_000; let mut texts: Vec = Vec::new(); let mut current = String::new(); let mut chars_emitted = 0usize; for res in iter { let update = match res { Ok(u) => u, Err(e) => { tracing::warn!(error = %e, "skipping malformed update in assistant text collector"); continue; } }; match update { SessionUpdate::Acp(notification) => { match notification.update { acp::SessionUpdate::AgentMessageChunk(chunk) => { if let acp::ContentBlock::Text(text_content) = chunk.content && !text_content.text.is_empty() { // Reserve space for separator before computing budget to avoid overshoot let sep_cost = usize::from(!current.is_empty()); let budget = MAX_CHARS .saturating_sub(chars_emitted) .saturating_sub(sep_cost); if budget == 0 { continue; } let text = if text_content.text.len() > budget { // Truncate on a valid UTF-8 char boundary let mut end = budget; while end > 0 && !text_content.text.is_char_boundary(end) { end -= 1; } &text_content.text[..end] } else { &text_content.text }; if !current.is_empty() { current.push(' '); chars_emitted += 1; } current.push_str(text); chars_emitted += text.len(); } } _ => { // End of assistant turn if !current.is_empty() { let t = current.trim().to_string(); if !t.is_empty() { texts.push(t); } current.clear(); } } } } SessionUpdate::Xai(_) => { if !current.is_empty() { let t = current.trim().to_string(); if !t.is_empty() { texts.push(t); } current.clear(); } } } } if !current.is_empty() { let t = current.trim().to_string(); if !t.is_empty() { texts.push(t); } } texts } /// Collect tool metadata from a stream of [`SessionUpdate`]s. /// /// Per Phase 1 contract (ACP data model): /// - Tool name: from `ToolCall.title` (display name; acp::ToolCall has no .name) /// - File paths: from `ToolCall.locations[].path` (ACP stores locations, not raw arguments) /// - Errors: skipped (no is_error on acp::ToolCallUpdate) /// /// Bounds: /// - Max 200 tool calls per session /// - Each extraction capped at 100k chars before final join /// /// Note: This collector does not honor rewind markers (unlike PromptExtractIterator). /// Rewound-away branches may still contribute to FTS index. This is a known limitation; /// fix by using a rewind-aware replay model (future work). pub fn collect_tool_metadata(iter: impl Iterator>) -> Vec { let mut meta: Vec = Vec::new(); let mut tool_call_count = 0usize; let mut chars_emitted = 0usize; const MAX_TOOL_CALLS: usize = 200; const MAX_CHARS: usize = 100_000; for res in iter { if tool_call_count >= MAX_TOOL_CALLS { break; } let update = match res { Ok(u) => u, Err(e) => { tracing::warn!(error = %e, "skipping malformed update in tool metadata collector"); continue; } }; match update { SessionUpdate::Acp(notification) => { match notification.update { acp::SessionUpdate::ToolCall(tc) => { tool_call_count += 1; // Tool name from .title (acp::ToolCall has no .name field) if !tc.title.is_empty() { let budget = MAX_CHARS.saturating_sub(chars_emitted); if budget == 0 { continue; } let truncated = &tc.title[..tc.title.len().min(budget)]; chars_emitted += truncated.len(); meta.push(truncated.to_string()); } // File paths from locations[].path for loc in &tc.locations { if let Some(path_str) = loc.path.to_str() && !path_str.is_empty() { let budget = MAX_CHARS.saturating_sub(chars_emitted); if budget == 0 { continue; } let truncated = &path_str[..path_str.len().min(budget)]; meta.push(truncated.to_string()); chars_emitted += truncated.len(); } } } acp::SessionUpdate::ToolCallUpdate(_) => { // Tool results come as ToolCallUpdate; no is_error field available } _ => {} } } SessionUpdate::Xai(_) => {} } } meta } // --------------------------------------------------------------------------- // Selective serde structs — only the fields we care about // --------------------------------------------------------------------------- /// Peek inside ACP or xAI `params` to read the `update.sessionUpdate` tag and /// any fields relevant to `user_message_chunk` or `rewind_marker`. /// /// Works for both method types because both use the same `update.sessionUpdate` /// discriminant key in the params JSON. #[derive(serde::Deserialize)] struct ParamsPeek<'a> { #[serde(borrow)] update: UpdatePeek<'a>, } #[derive(serde::Deserialize)] struct UpdatePeek<'a> { #[serde(rename = "sessionUpdate")] session_update: &'a str, /// Present only for `user_message_chunk`. #[serde(borrow, default)] content: Option>, /// Chunk `_meta` on ACP updates (carries `promptIndex` for real turns). #[serde(default, rename = "_meta")] meta: Option, /// Present only for `rewind_marker`. target_prompt_index: Option, } /// Selective peek at a `user_message_chunk` content object. /// /// Shared with the search collectors in [`search`] so the peeked fields and /// their escape-tolerance cannot drift between the prompt-extraction and /// indexing paths. #[derive(serde::Deserialize)] pub(crate) struct ContentPeek<'a> { #[serde(rename = "type", default)] pub content_type: Option<&'a str>, // `Cow`, not `&str`: serde cannot borrow from JSON strings containing // escapes, and the resulting parse error would drop the whole prompt. #[serde(borrow, default)] pub text: Option>, #[serde(rename = "_meta", default)] pub meta: Option>, } #[derive(serde::Deserialize)] pub(crate) struct ContentMetaPeek<'a> { #[serde(borrow, default)] pub bash_command: Option>, } /// Parse one `updates.jsonl` line into a [`PromptExtractEvent`]. /// /// Always returns an event: `NotUserMessage` for every line that is not a /// user-message chunk or rewind marker (including unparseable ones), so an /// in-progress prompt is always flushed conservatively. /// /// Fast path: only those two kinds can produce a non-`NotUserMessage` event, and /// their discriminant appears verbatim, so a cheap substring pre-check skips the /// serde peeks for the vast majority of lines. A line merely embedding the /// discriminant in its content still falls through to the full parse. pub(crate) fn parse_prompt_extract_event(line: &str) -> PromptExtractEvent { if !line.contains(&*USER_MESSAGE_CHUNK) && !line.contains(&*REWIND_MARKER) { return PromptExtractEvent::NotUserMessage; } // Step 1: try to extract the envelope (method + raw params). let (raw_params, is_xai) = if let Ok(env) = serde_json::from_str::>(line) { let raw = env.params.map(|p| p.get()).unwrap_or(line); let xai = env.method.is_some_and(is_ext_session_update_method); (raw, xai) } else { // Not a valid envelope → try legacy format: the line IS the params. (line, false) }; // Step 2: parse the discriminant and relevant payload fields in one pass. let Ok(peek) = serde_json::from_str::>(raw_params) else { // Cannot determine update type → treat conservatively. return PromptExtractEvent::NotUserMessage; }; let tag = peek.update.session_update; if !is_xai && tag == *USER_MESSAGE_CHUNK { if let Some(content) = peek.update.content && content.content_type == Some("text") && let Some(text) = content.text { if content .meta .as_ref() .is_some_and(|m| m.bash_command.is_some()) { return PromptExtractEvent::NotUserMessage; } let prompt_index = peek .update .meta .as_ref() .and_then(|m| m.prompt_index.map(|v| v as usize)); return PromptExtractEvent::UserTextChunk { text: text.into_owned(), prompt_index, }; } // user_message_chunk with non-text content (e.g., image) still ends // any in-progress user message. return PromptExtractEvent::NotUserMessage; } if is_xai && tag == *REWIND_MARKER { if let Some(idx) = peek.update.target_prompt_index { return PromptExtractEvent::RewindTo(idx); } // Malformed rewind_marker: treat conservatively (flush, no truncate). return PromptExtractEvent::NotUserMessage; } PromptExtractEvent::NotUserMessage } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; // ── helpers ────────────────────────────────────────────────────────────── /// Wrap an ACP notification as the envelope stored in updates.jsonl. fn acp_envelope(session_update_json: &str) -> String { format!( r#"{{"timestamp":1,"method":"session/update","params":{{"sessionId":"s","update":{session_update_json}}}}}"# ) } /// Wrap a xAI notification as the envelope stored in updates.jsonl. fn xai_envelope(session_update_json: &str) -> String { format!( r#"{{"timestamp":1,"method":"_kigi/session/update","params":{{"sessionId":"s","update":{session_update_json}}}}}"# ) } // ── parse_prompt_extract_event unit tests ───────────────────────────────── #[test] fn acp_user_text_chunk_yields_user_text() { let line = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hello"}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::user_text("hello") ); } #[test] fn acp_user_text_chunk_with_json_escapes_yields_user_text() { // Escaped JSON strings cannot be borrowed as &str; a regression to a // borrowed peek field would drop this prompt from extraction. let line = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"multi\nline \"quoted\" caf\u00e9"}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::user_text("multi\nline \"quoted\" caf\u{e9}") ); // An escaped bash command now parses too and must be excluded by the // bash_command predicate (it used to be excluded by the parse failure). let bash = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"! echo \"hi\"","_meta":{"bash_command":"echo \"hi\""}}}"#, ); assert_eq!( parse_prompt_extract_event(&bash), PromptExtractEvent::NotUserMessage ); } #[test] fn acp_agent_message_chunk_yields_not_user() { let line = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"reply"}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::NotUserMessage ); } #[test] fn acp_tool_result_yields_not_user() { let line = acp_envelope( r#"{"sessionUpdate":"tool_result","toolCallId":"c1","content":[{"type":"text","text":"big output"}]}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::NotUserMessage ); } #[test] fn xai_rewind_marker_yields_rewind_to() { let line = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":3,"created_at":"2024-01-01"}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::RewindTo(3) ); } #[test] fn xai_rewind_to_zero_yields_rewind_to_zero() { let line = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::RewindTo(0) ); } #[test] fn xai_diff_review_yields_not_user() { let line = xai_envelope(r#"{"sessionUpdate":"diff_review","content":[]}"#); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::NotUserMessage ); } /// An ACP `user_message_chunk` with an image content block (not text) must /// end the current user message without yielding any text. #[test] fn acp_user_message_chunk_image_yields_not_user() { let line = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"image_url","url":"data:image/png;base64,abc"}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::NotUserMessage ); } /// Malformed JSON must produce `NotUserMessage` (conservative flush). #[test] fn malformed_json_yields_not_user() { assert_eq!( parse_prompt_extract_event("not json at all!!!"), PromptExtractEvent::NotUserMessage ); } /// Empty string — the iterator skips blanks, but a direct call must still /// classify conservatively (the parser always yields an event now). #[test] fn empty_string_yields_not_user() { assert_eq!( parse_prompt_extract_event(""), PromptExtractEvent::NotUserMessage ); } /// A valid JSON object that has no recognisable ACP/xAI shape — NotUserMessage. #[test] fn unknown_json_object_yields_not_user() { assert_eq!( parse_prompt_extract_event(r#"{"foo":"bar"}"#), PromptExtractEvent::NotUserMessage ); } /// Legacy format: raw `acp::SessionNotification` without an outer envelope. /// /// Old sessions wrote `{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk",...}}` /// directly without the `method`/`params` envelope. The parser must still /// extract user text from these lines. #[test] fn legacy_format_user_message_chunk() { let line = r#"{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"legacy prompt"}}}"#; assert_eq!( parse_prompt_extract_event(line), PromptExtractEvent::user_text("legacy prompt") ); } #[test] fn legacy_format_non_user_update() { let line = r#"{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}"#; assert_eq!( parse_prompt_extract_event(line), PromptExtractEvent::NotUserMessage ); } // ── PromptExtractIterator integration tests via tempfile ────────────────── use std::io::Write as _; fn write_updates_file(lines: &[&str]) -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); for line in lines { writeln!(f, "{line}").unwrap(); } f } fn collect_events(path: &std::path::Path) -> Vec { PromptExtractIterator::open(path) .unwrap() .unwrap() .collect() } #[test] fn iterator_single_user_prompt() { let chunk = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hello world"}}"#, ); let other = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"reply"}}"#, ); let f = write_updates_file(&[&chunk, &other]); let events = collect_events(f.path()); assert_eq!(events[0], PromptExtractEvent::user_text("hello world")); assert_eq!(events[1], PromptExtractEvent::NotUserMessage); } #[test] fn iterator_multi_chunk_user_message() { let c1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"part1 "}}"#, ); let c2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"part2"}}"#, ); let end = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}"#, ); let f = write_updates_file(&[&c1, &c2, &end]); let events = collect_events(f.path()); assert_eq!(events[0], PromptExtractEvent::user_text("part1 ")); assert_eq!(events[1], PromptExtractEvent::user_text("part2")); assert_eq!(events[2], PromptExtractEvent::NotUserMessage); } #[test] fn iterator_rewind_marker_truncates() { let chunk = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p1"}}"#, ); let end = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a1"}}"#, ); let rewind = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); let f = write_updates_file(&[&chunk, &end, &rewind]); let events = collect_events(f.path()); assert_eq!(events[0], PromptExtractEvent::user_text("p1")); assert_eq!(events[1], PromptExtractEvent::NotUserMessage); assert_eq!(events[2], PromptExtractEvent::RewindTo(0)); } #[test] fn iterator_skips_blank_lines() { let chunk = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hello"}}"#, ); let f = write_updates_file(&["", " ", &chunk, ""]); let events = collect_events(f.path()); assert_eq!(events.len(), 1); assert_eq!(events[0], PromptExtractEvent::user_text("hello")); } #[test] fn iterator_malformed_line_does_not_panic() { let bad = "this is not json !!!"; let good = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"ok"}}"#, ); let f = write_updates_file(&[bad, &good]); let events = collect_events(f.path()); // bad line → NotUserMessage; good line → UserTextChunk assert_eq!(events.len(), 2); assert_eq!(events[0], PromptExtractEvent::NotUserMessage); assert_eq!(events[1], PromptExtractEvent::user_text("ok")); } #[test] fn iterator_nonexistent_file_returns_none() { let result = PromptExtractIterator::open(std::path::Path::new("/nonexistent/updates.jsonl")); assert!(result.unwrap().is_none()); } /// Full round-trip: simulate a session with two user prompts, one rewind, /// then a new prompt. Assemble the events into prompts the same way /// `load_user_prompts_from_updates` does. #[test] fn full_round_trip_with_rewind() { // Turn 1: "first prompt" let u1a = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"first "}}"#, ); let u1b = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"prompt"}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"answer1"}}"#, ); // Turn 2: "second prompt" let u2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"second prompt"}}"#, ); let a2 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"answer2"}}"#, ); // Rewind to before turn 2 (keep 1 prompt) let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":1,"created_at":"2024-01-01"}"#, ); // Turn 2 (after rewind): "new second prompt" let u3 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"new second prompt"}}"#, ); let a3 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"answer3"}}"#, ); let f = write_updates_file(&[&u1a, &u1b, &a1, &u2, &a2, &rw, &u3, &a3]); let prompts = collect_prompts_from_events(PromptExtractIterator::open(f.path()).unwrap().unwrap()); assert_eq!(prompts, vec!["first prompt", "new second prompt"]); } #[test] fn collect_prompts_ignores_unmarked_phantoms_when_markers_present() { let events = [ PromptExtractEvent::user_text_pi("hi", 0), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text("!pwd phantom"), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text_pi("echo hello", 1), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text("echo hi instead"), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text_pi("ty ty", 2), PromptExtractEvent::NotUserMessage, ]; let prompts = collect_prompts_from_events(events.into_iter()); assert_eq!(prompts, vec!["hi", "echo hello", "ty ty"]); } #[test] fn collect_prompts_mixed_unmarked_prefix_then_markers() { let events = [ PromptExtractEvent::user_text("old0"), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text("old1"), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text_pi("new2", 2), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text("!pwd"), PromptExtractEvent::NotUserMessage, PromptExtractEvent::user_text_pi("new3", 3), PromptExtractEvent::NotUserMessage, ]; let prompts = collect_prompts_from_events(events.into_iter()); assert_eq!(prompts, vec!["old0", "old1", "new2", "new3"]); } #[test] fn parse_extracts_prompt_index_from_update_meta() { let line = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"},"_meta":{"promptIndex":3}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::user_text_pi("hi", 3) ); } fn user_chunk(text: &str, prompt_index: Option) -> SessionUpdate { let mut chunk = acp::ContentChunk::new(acp::ContentBlock::Text(acp::TextContent::new( text.to_string(), ))); if let Some(pi) = prompt_index { chunk = chunk.meta( serde_json::json!({ "promptIndex": pi }) .as_object() .cloned(), ); } SessionUpdate::Acp(Box::new(acp::SessionNotification::new( acp::SessionId::new("s"), acp::SessionUpdate::UserMessageChunk(chunk), ))) } fn agent_chunk(text: &str) -> SessionUpdate { SessionUpdate::Acp(Box::new(acp::SessionNotification::new( acp::SessionId::new("s"), acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text( acp::TextContent::new(text.to_string()), ))), ))) } #[test] fn updates_truncate_ignores_unmarked_phantoms_when_markers_present() { let updates = vec![ user_chunk("P0", Some(0)), agent_chunk("A0"), user_chunk("!pwd", None), agent_chunk("out"), user_chunk("P1", Some(1)), agent_chunk("A1"), user_chunk("P2", Some(2)), agent_chunk("A2"), ]; // Keep through P1 (indices 0,1); cut at start of P2 run. let cut = updates_truncate_for_prompt(&updates, 1); assert_eq!(cut, 6); assert!(matches!( &updates[cut], SessionUpdate::Acp(n) if matches!( &n.update, acp::SessionUpdate::UserMessageChunk(c) if matches!(&c.content, acp::ContentBlock::Text(t) if t.text == "P2") ) )); } #[test] fn updates_truncate_splits_consecutive_marked_prompts_without_agent() { let updates: Vec<_> = (0..6) .map(|i| user_chunk(&format!("P{i}"), Some(i))) .collect(); // Target 2 keeps turns 0 and 1; cut at P2 (index 2). assert_eq!(updates_truncate_for_prompt(&updates, 1), 2); assert_eq!(updates_truncate_for_prompt(&updates, 2), 3); assert_eq!(updates_truncate_for_prompt(&updates, 5), 6); } /// Mixed stream: unmarked runs before the first promptIndex still count. #[test] fn updates_truncate_mixed_unmarked_prefix_then_markers() { let updates = vec![ user_chunk("old0", None), agent_chunk("A0"), user_chunk("old1", None), agent_chunk("A1"), user_chunk("new2", Some(2)), agent_chunk("A2"), user_chunk("!pwd", None), agent_chunk("out"), user_chunk("new3", Some(3)), agent_chunk("A3"), ]; // Target 1 keeps old0+old1; cut at new2. assert_eq!(updates_truncate_for_prompt(&updates, 1), 4); // Target 2 keeps through A2 (and phantom run does not add a turn); cut at new3. assert_eq!(updates_truncate_for_prompt(&updates, 2), 8); assert_eq!(updates_truncate_for_prompt(&updates, 0), 2); } #[test] fn filter_rewind_mixed_unmarked_prefix_then_markers() { let o0 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"old0"}}"#, ); let a0 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A0"}}"#, ); let o1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"old1"}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A1"}}"#, ); let n2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"new2"},"_meta":{"promptIndex":2}}"#, ); let a2 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A2"}}"#, ); let n3 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"new3"},"_meta":{"promptIndex":3}}"#, ); // Rewind to target 2: keep turns 0,1 (old0, old1); drop new2+. let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":2,"created_at":"2024-01-01"}"#, ); let after = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"after"},"_meta":{"promptIndex":2}}"#, ); let lines = vec![ o0.as_str(), a0.as_str(), o1.as_str(), a1.as_str(), n2.as_str(), a2.as_str(), n3.as_str(), rw.as_str(), after.as_str(), ]; let kept = filter_rewind_lines(lines); let texts: Vec<&str> = kept .iter() .filter_map(|l| { if l.contains("\"text\":\"old0\"") { Some("old0") } else if l.contains("\"text\":\"old1\"") { Some("old1") } else if l.contains("\"text\":\"new2\"") { Some("new2") } else if l.contains("\"text\":\"new3\"") { Some("new3") } else if l.contains("\"text\":\"after\"") { Some("after") } else { None } }) .collect(); assert_eq!(texts, vec!["old0", "old1", "after"]); } #[test] fn filter_rewind_ignores_unmarked_phantoms_when_markers_present() { let p0 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"P0"},"_meta":{"promptIndex":0}}"#, ); let a0 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A0"}}"#, ); let phantom = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"!pwd"}}"#, ); let p1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"P1"},"_meta":{"promptIndex":1}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"A1"}}"#, ); let p2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"P2"},"_meta":{"promptIndex":2}}"#, ); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":2,"created_at":"2024-01-01"}"#, ); let after = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"after"},"_meta":{"promptIndex":2}}"#, ); let lines = vec![ p0.as_str(), a0.as_str(), phantom.as_str(), p1.as_str(), a1.as_str(), p2.as_str(), rw.as_str(), after.as_str(), ]; let kept = filter_rewind_lines(lines); let texts: Vec<&str> = kept .iter() .filter_map(|l| { if l.contains("\"text\":\"P0\"") { Some("P0") } else if l.contains("!pwd") { Some("phantom") } else if l.contains("\"text\":\"P1\"") { Some("P1") } else if l.contains("\"text\":\"P2\"") { Some("P2") } else if l.contains("\"text\":\"after\"") { Some("after") } else { None } }) .collect(); assert_eq!(texts, vec!["P0", "phantom", "P1", "after"]); } // ── filter_rewind_lines tests ──────────────────────────────────────────── #[test] fn filter_rewind_removes_dead_branch() { let u1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"first"}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"resp1"}}"#, ); let u2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"second"}}"#, ); let a2 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"resp2"}}"#, ); // Rewind to prompt 1 — kills u2, a2 let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":1,"created_at":"2024-01-01"}"#, ); let u3 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"replacement"}}"#, ); let a3 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"resp3"}}"#, ); let lines = vec![ u1.as_str(), a1.as_str(), u2.as_str(), a2.as_str(), rw.as_str(), u3.as_str(), a3.as_str(), ]; let result = filter_rewind_lines(lines); // u1, a1 survive. u2, a2, rewind marker removed. u3, a3 added. assert_eq!(result.len(), 4); assert!(result[0].contains("first")); assert!(result[1].contains("resp1")); assert!(result[2].contains("replacement")); assert!(result[3].contains("resp3")); } #[test] fn filter_rewind_to_zero_clears_all() { let u1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"only"}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"resp"}}"#, ); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); let u2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"fresh start"}}"#, ); let lines = vec![u1.as_str(), a1.as_str(), rw.as_str(), u2.as_str()]; let result = filter_rewind_lines(lines); assert_eq!(result.len(), 1); assert!(result[0].contains("fresh start")); } #[test] fn filter_rewind_double_rewind() { let u1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p1"}}"#, ); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"r1"}}"#, ); let u2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p2"}}"#, ); let a2 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"r2"}}"#, ); let u3 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p3"}}"#, ); let a3 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"r3"}}"#, ); // Rewind to prompt 2 — kills p3/r3 let rw1 = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":2,"created_at":"2024-01-01"}"#, ); let u4 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p4"}}"#, ); let a4 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"r4"}}"#, ); // Rewind to prompt 1 — kills p2/r2/p4/r4 let rw2 = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":1,"created_at":"2024-01-01"}"#, ); let u5 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"final"}}"#, ); let lines = vec![ u1.as_str(), a1.as_str(), u2.as_str(), a2.as_str(), u3.as_str(), a3.as_str(), rw1.as_str(), u4.as_str(), a4.as_str(), rw2.as_str(), u5.as_str(), ]; let result = filter_rewind_lines(lines); // Only p1, r1, final survive assert_eq!(result.len(), 3); assert!(result[0].contains("p1")); assert!(result[1].contains("r1")); assert!(result[2].contains("final")); } // ── prepare_replay_lines tests ─────────────────────────────────────────── /// Envelope with _meta at the params level (where the real agent puts it). fn acp_envelope_with_meta(session_update_json: &str, meta_json: &str) -> String { format!( r#"{{"timestamp":1,"method":"session/update","params":{{"sessionId":"s","update":{session_update_json},"_meta":{meta_json}}}}}"# ) } #[test] fn prepare_replay_cursor_skips_to_position() { let u1 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"old"}}"#, r#"{"eventId":"ev1"}"#, ); let a1 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"old resp"}}"#, r#"{"eventId":"ev2"}"#, ); let u2 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"new"}}"#, r#"{"eventId":"ev3"}"#, ); let raw = format!("{u1}\n{a1}\n{u2}\n"); let prepared = prepare_replay_lines(&raw, Some("ev2")); // Should skip ev1 and ev2, return only ev3 assert_eq!(prepared.lines.len(), 1); assert!(!prepared.mark_replay); assert!(prepared.lines[0].contains("new")); assert_eq!(prepared.total_live, 3); } #[test] fn prepare_replay_cursor_not_found_returns_all() { let u1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}"#, ); let raw = format!("{u1}\n"); let prepared = prepare_replay_lines(&raw, Some("nonexistent")); assert_eq!(prepared.lines.len(), 1); assert!(prepared.mark_replay); // fallback to full replay } /// A resolved cursor is refused when the tail contains an eventId-less /// line (older-binary history): the line has no client-side dedup and no /// future cursor can cover it, so an incremental tail would re-apply it. /// Full replay is the safe fallback. #[test] fn prepare_replay_cursor_refused_when_tail_has_event_id_less_line() { let a1 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"seen"}}"#, r#"{"eventId":"ev1"}"#, ); // xAI-style line persisted by an older binary: no _meta at all. let old_xai = r#"{"timestamp":2,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"hook_annotation","message":"trailing"}}}"#; let raw = format!("{a1}\n{old_xai}\n"); let prepared = prepare_replay_lines(&raw, Some("ev1")); assert!( prepared.mark_replay, "an unbounded tail must force a full replay" ); assert_eq!(prepared.lines.len(), 2, "full history is replayed"); // Same history with the trailing line stamped resolves incrementally. let new_xai = r#"{"timestamp":2,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"hook_annotation","message":"trailing"},"_meta":{"eventId":"ev2"}}}"#; let raw = format!("{a1}\n{new_xai}\n"); let prepared = prepare_replay_lines(&raw, Some("ev1")); assert!(!prepared.mark_replay); assert_eq!(prepared.lines.len(), 1); assert!(prepared.lines[0].contains("trailing")); // An id-less ACU in the tail is exempt from the refusal — ACUs are // dropped before forwarding, so they can never be re-applied. let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let raw = format!("{a1}\n{acu}\n"); let prepared = prepare_replay_lines(&raw, Some("ev1")); assert!( !prepared.mark_replay, "a trailing id-less ACU must not force a full replay" ); assert!( prepared.lines.is_empty(), "the ACU is dropped, never forwarded" ); } #[test] fn prepare_replay_extracts_max_event_seq() { // eventId is "{sessionId}-{counter}" and session ids contain dashes, so // the counter is the suffix after the LAST '-'. max_event_seq is the // highest counter across all live lines — used to re-seed the global // event counter on resume so post-load live events stay monotonic and // don't get dropped by the client's eventId dedup. let a1 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a"}}"#, r#"{"eventId":"019e-abcd-7","totalTokens":100}"#, ); let a2 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"b"}}"#, r#"{"eventId":"019e-abcd-42","totalTokens":250}"#, ); // Out-of-order counter (lower than the max) must not lower the result. let a3 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"c"}}"#, r#"{"eventId":"019e-abcd-13","totalTokens":250}"#, ); let raw = format!("{a1}\n{a2}\n{a3}\n"); let prepared = prepare_replay_lines(&raw, None); assert_eq!( prepared.max_event_seq, Some(42), "max counter across all lines (suffix after last '-')" ); assert_eq!(prepared.last_tokens, 250); } #[test] fn prepare_replay_no_event_ids_yields_none_max_seq() { // Lines without a parseable numeric eventId suffix (older shell) yield // None, so the counter is left untouched on resume. let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a"}}"#, ); let raw = format!("{a1}\n"); let prepared = prepare_replay_lines(&raw, None); assert_eq!(prepared.max_event_seq, None); } // ── available_commands_update skip (T1) + single-pass equivalence ───────── #[test] fn acu_line_detection_exact_and_no_false_positive() { let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); assert!(line_is_available_commands_update(&acu)); // A user message that merely mentions the phrase must NOT match. let user_mentions = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"what is available_commands_update?"}}"#, ); assert!(!line_is_available_commands_update(&user_mentions)); } /// The anchor must reject the discriminant when it sits inside `_meta` (not /// at the `params.update` position) — the real update here is a non-ACU. #[test] fn acu_anchor_ignores_discriminant_in_meta() { let line = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}"#, r#"{"sessionUpdate":"available_commands_update"}"#, ); // The exact `"sessionUpdate":"available_commands_update"` substring IS // present (in _meta), but it's not anchored to `"update":{`. assert!(line.contains(r#""sessionUpdate":"available_commands_update""#)); assert!(!line_is_available_commands_update(&line)); } /// A NON-ACU line whose `_meta` embeds the FULL unescaped nested anchor /// (`{"update":{"sessionUpdate":"available_commands_update",...}}`) passes the /// cheap substring pre-filter but must be REJECTED by the positional confirm /// (its real `params.update` is a `tool_call`) — so it is never dropped. #[test] fn acu_confirm_rejects_nested_update_anchor_in_meta() { let line = acp_envelope_with_meta( r#"{"sessionUpdate":"tool_call","toolCallId":"t","title":"x"}"#, r#"{"echo":{"update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}"#, ); // The discriminant prefix IS present (in _meta) — pre-filter would match... assert!(line.contains(&*AVAILABLE_COMMANDS_UPDATE_PREFIX)); // ...but the structural params.update is a tool_call, so NOT an ACU. assert!(!line_is_available_commands_update(&line)); // And the non-ACU line survives replay (is not dropped). let raw = format!("{line}\n"); let prepared = prepare_replay_lines(&raw, None); assert_eq!(prepared.lines.len(), 1, "non-ACU line must not be dropped"); assert!(prepared.lines[0].contains("tool_call")); } /// Pin the cross-crate assumption behind [`line_is_available_commands_update`]: /// the structural `params.update` serializes BEFORE the optional `_meta`. Run a /// genuine ACU through the real write path ([`SessionUpdateEnvelope::from_update`]) /// and assert its first `"update":` precedes any `"_meta":`, and the detector accepts it. #[test] fn acu_real_write_path_serializes_update_before_meta() { let notif = acp::SessionNotification::new( acp::SessionId::new("s"), acp::SessionUpdate::AvailableCommandsUpdate(acp::AvailableCommandsUpdate::new(vec![])), ) .meta(serde_json::json!({ "eventId": "ev1" }).as_object().cloned()); let envelope = SessionUpdateEnvelope::from_update(&SessionUpdate::Acp(Box::new(notif))).unwrap(); let line = serde_json::to_string(&envelope).unwrap(); let update_idx = line .find(UPDATE_KEY) .expect("serialized ACU line must contain an \"update\" key"); if let Some(meta_idx) = line.find(r#""_meta":"#) { assert!( update_idx < meta_idx, "params.update must serialize before _meta: {line}" ); } assert!(line_is_available_commands_update(&line)); } #[test] fn prepare_replay_drops_available_commands_update() { let u = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}"#, ); let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let a = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"yo"}}"#, ); let raw = format!("{u}\n{acu}\n{a}\n"); let prepared = prepare_replay_lines(&raw, None); // ACU dropped; the two real updates kept in original order. assert_eq!(prepared.lines.len(), 2); assert_eq!(prepared.total_live, 2); assert!( prepared .lines .iter() .all(|l| !l.contains("available_commands_update")) ); assert!(prepared.lines[0].contains("hi")); assert!(prepared.lines[1].contains("yo")); assert!(prepared.mark_replay); } #[test] fn prepare_replay_scans_last_total_tokens_across_kept_lines() { let u = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}"#, r#"{"totalTokens":10}"#, ); let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let a = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"yo"}}"#, r#"{"totalTokens":42}"#, ); let raw = format!("{u}\n{acu}\n{a}\n"); let prepared = prepare_replay_lines(&raw, None); // Last totalTokens wins; ACU lines (no tokens) don't disturb it. assert_eq!(prepared.last_tokens, 42); assert_eq!(prepared.lines.len(), 2); } #[test] fn prepare_replay_rewind_truncates_and_drops_acu() { let u0 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p0"}}"#, r#"{"totalTokens":5}"#, ); let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let a0 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a0"}}"#, r#"{"totalTokens":7}"#, ); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); let u1 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p1"}}"#, r#"{"totalTokens":9}"#, ); let raw = format!("{u0}\n{acu}\n{a0}\n{rw}\n{u1}\n"); let prepared = prepare_replay_lines(&raw, None); // Rewind to 0 kills u0/a0; ACU dropped; only the new p1 survives. assert_eq!(prepared.lines.len(), 1); assert!(prepared.lines[0].contains("p1")); assert_eq!(prepared.total_live, 1); // last_tokens recomputed from the surviving timeline (p1 = 9). assert_eq!(prepared.last_tokens, 9); assert!(prepared.mark_replay); } /// The single-pass implementation must match an independent reference that /// drops ACU then applies the (canonical) rewind filter — for a mixed input. #[test] fn prepare_replay_single_pass_matches_reference() { let lines_src = [ acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p0"}}"#, r#"{"totalTokens":3}"#, ), acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#), acp_envelope_with_meta( r#"{"sessionUpdate":"tool_call_update","toolCallId":"t","status":"completed"}"#, r#"{"totalTokens":11}"#, ), acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#), acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a0"}}"#, ), ]; let raw = format!("{}\n", lines_src.join("\n")); // Reference: filter blanks + ACU, then canonical rewind filter, count. let reference: Vec<&str> = filter_rewind_lines( raw.lines() .filter(|l| !l.trim().is_empty() && !line_is_available_commands_update(l)) .collect(), ); let prepared = prepare_replay_lines(&raw, None); assert_eq!(prepared.lines, reference); assert_eq!(prepared.total_live, reference.len()); assert_eq!(prepared.last_tokens, 11); // last kept line carrying tokens } /// The prompt-extract fast-reject must not be fooled by lines that merely /// contain the discriminant substring inside their content — the full parse /// still classifies them by the real `sessionUpdate` tag. #[test] fn fast_reject_handles_discriminant_substring_in_content() { let line = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"the user_message_chunk format"}}"#, ); assert_eq!( parse_prompt_extract_event(&line), PromptExtractEvent::NotUserMessage ); } /// A `rewind_marker` appearing only inside content must NEVER become a /// `RewindTo` (which would corrupt prompt_index / turn numbering). #[test] fn fast_reject_rewind_marker_in_content() { // (a) agent message mentioning rewind_marker → NotUserMessage. let agent = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"about rewind_marker semantics"}}"#, ); assert_eq!( parse_prompt_extract_event(&agent), PromptExtractEvent::NotUserMessage ); // (b) an ACP (non-xai) update carrying rewind_marker in content is NOT a // real xai rewind_marker → NotUserMessage (no RewindTo). let acp_rewindish = acp_envelope( r#"{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rewind_marker"}}"#, ); assert_eq!( parse_prompt_extract_event(&acp_rewindish), PromptExtractEvent::NotUserMessage ); // (c) a user_message_chunk whose text contains rewind_marker → still the // user text (the discriminant is user_message_chunk). let user = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"explain rewind_marker please"}}"#, ); assert_eq!( parse_prompt_extract_event(&user), PromptExtractEvent::user_text("explain rewind_marker please") ); } /// A user prompt whose text contains the literal escaped-JSON ACU /// discriminant must NOT be dropped as an `available_commands_update` — the /// `"update":{` anchor only matches the real structural discriminant, not the /// escaped fragment in content. #[test] fn acu_drop_ignores_escaped_json_in_content() { let line = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"paste: {\"sessionUpdate\":\"available_commands_update\"}"}}"#, ); // The bare phrase appears in the (escaped) content, but it's not at the // structural `"update":{"sessionUpdate":...` position, so it's kept. assert!(line.contains("available_commands_update")); assert!(!line_is_available_commands_update(&line)); let raw = format!("{line}\n"); let prepared = prepare_replay_lines(&raw, None); assert_eq!(prepared.lines.len(), 1, "user prompt must survive replay"); assert!(prepared.lines[0].contains("available_commands_update")); } /// An idle client reconnecting with the cursor pointing at the LAST persisted /// event — an ACU (the post-load re-advertise) — must resolve the cursor on the /// ACU-inclusive set rather than fall back to full replay. #[test] fn prepare_replay_cursor_on_dropped_acu_resolves() { let u = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}"#, r#"{"eventId":"ev1"}"#, ); let a = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"yo"}}"#, r#"{"eventId":"ev2"}"#, ); let acu = acp_envelope_with_meta( r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#, r#"{"eventId":"ev3"}"#, ); let raw = format!("{u}\n{a}\n{acu}\n"); // Cursor == the ACU's eventId → resolved; nothing after → no replay, // and crucially NOT a full replay. let prepared = prepare_replay_lines(&raw, Some("ev3")); assert!(!prepared.mark_replay, "must not fall back to full replay"); assert!(prepared.lines.is_empty(), "client is already caught up"); // Cursor == ev1 → replay ev2, ev3; the ACU (ev3) is dropped from the tail. let prepared = prepare_replay_lines(&raw, Some("ev1")); assert!(!prepared.mark_replay); assert_eq!(prepared.lines.len(), 1); assert!(prepared.lines[0].contains("yo")); } /// A trailing `rewind_marker` empties the live set and yields /// `last_tokens == 0` (the `unwrap_or(0)` path). #[test] fn prepare_replay_trailing_rewind_marker_empties() { let u0 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p0"}}"#, r#"{"totalTokens":5}"#, ); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); let raw = format!("{u0}\n{rw}\n"); let prepared = prepare_replay_lines(&raw, None); assert!(prepared.lines.is_empty()); assert_eq!(prepared.total_live, 0); assert_eq!(prepared.last_tokens, 0); } /// An ACU as the final line is dropped without disturbing tokens. #[test] fn prepare_replay_trailing_acu_dropped() { let u = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hi"}}"#, r#"{"totalTokens":7}"#, ); let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let raw = format!("{u}\n{acu}\n"); let prepared = prepare_replay_lines(&raw, None); assert_eq!(prepared.lines.len(), 1); assert!(prepared.lines[0].contains("hi")); assert_eq!(prepared.last_tokens, 7); assert_eq!(prepared.total_live, 1); } /// Rewind + cursor + ACU together, with explicit expected values. #[test] fn prepare_replay_rewind_then_cursor_with_acu() { let u0 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p0"}}"#, r#"{"eventId":"e0","totalTokens":2}"#, ); let a0 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a0"}}"#, r#"{"eventId":"e1"}"#, ); let acu0 = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2024-01-01"}"#, ); let u1 = acp_envelope_with_meta( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p1"}}"#, r#"{"eventId":"e2","totalTokens":9}"#, ); let acu1 = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let a1 = acp_envelope_with_meta( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a1"}}"#, r#"{"eventId":"e3","totalTokens":12}"#, ); let raw = format!("{u0}\n{a0}\n{acu0}\n{rw}\n{u1}\n{acu1}\n{a1}\n"); // Rewind to 0 kills u0/a0/acu0; surviving live = [u1(e2), acu1, a1(e3)]. // Cursor on e2 → tail = [acu1, a1]; drop acu1 → lines = [a1]. let prepared = prepare_replay_lines(&raw, Some("e2")); assert!(!prepared.mark_replay); assert_eq!(prepared.lines.len(), 1); assert!(prepared.lines[0].contains("a1")); assert_eq!(prepared.last_tokens, 12); // last token-bearing survivor assert_eq!(prepared.total_live, 2); // ACU-free survivors: u1, a1 } /// The delta-replay helper (shared with the initial path) drops blanks + ACUs /// and applies the canonical rewind filter. #[test] fn filter_delta_replay_drops_blank_acu_and_rewinds() { let u1 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p1"}}"#, ); let acu = acp_envelope(r#"{"sessionUpdate":"available_commands_update","availableCommands":[]}"#); let a1 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a1"}}"#, ); // A second prompt that a trailing rewind_marker then discards. let u2 = acp_envelope( r#"{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"p2-dead"}}"#, ); let a2 = acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"a2-dead"}}"#, ); let rw = xai_envelope( r#"{"sessionUpdate":"rewind_marker","target_prompt_index":1,"created_at":"2024-01-01"}"#, ); let raw = format!("{u1}\n\n{acu}\n{a1}\n{u2}\n{a2}\n{rw}\n"); let live = filter_delta_replay_lines(&raw); // Blank + ACU dropped; the rewind to prompt 1 truncates the dead branch // (u2/a2) and consumes the marker, leaving only p1/a1. assert_eq!(live.len(), 2); assert!( live.iter() .all(|l| !l.contains("available_commands_update")) ); assert!(live[0].contains("p1")); assert!(live[1].contains("a1")); assert!(live.iter().all(|l| !l.contains("dead"))); assert!(live.iter().all(|l| !l.contains("rewind_marker"))); } #[test] fn prepare_replay_reports_spawn_without_finish() { let spawn = |id: &str, child: &str| { format!( r#"{{"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"subagent_spawned","subagent_id":"{id}","parent_session_id":"s","child_session_id":"{child}","subagent_type":"general-purpose","description":"task"}},"_meta":{{"eventId":"s-1"}}}}}}"# ) }; let finish = |id: &str| { format!( r#"{{"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"subagent_finished","subagent_id":"{id}","child_session_id":"c{id}","status":"completed","tool_calls":0,"turns":0,"duration_ms":0}},"_meta":{{"eventId":"s-2"}}}}}}"# ) }; // `a` spawns and finishes (paired); `b` only spawns (orphan). let raw = format!( "{}\n{}\n{}\n", spawn("a", "ca"), finish("a"), spawn("b", "cb") ); let prepared = prepare_replay_lines(&raw, None); assert_eq!( prepared.unfinished_subagents, vec![("b".to_string(), "cb".to_string())] ); } /// Legacy lines put `sessionId`/`update` at the top level (no `params` /// envelope); orphan detection must still pair them. #[test] fn collect_unfinished_subagents_handles_legacy_top_level_lines() { let lines = vec![ r#"{"sessionId":"s","update":{"sessionUpdate":"subagent_spawned","subagent_id":"a","parent_session_id":"s","child_session_id":"ca","subagent_type":"general-purpose","description":"task"}}"#, r#"{"sessionId":"s","update":{"sessionUpdate":"subagent_finished","subagent_id":"a","child_session_id":"ca","status":"completed","tool_calls":0,"turns":0,"duration_ms":0}}"#, r#"{"sessionId":"s","update":{"sessionUpdate":"subagent_spawned","subagent_id":"b","parent_session_id":"s","child_session_id":"cb","subagent_type":"general-purpose","description":"task"}}"#, ]; // `a` is paired (spawn+finish); `b` only spawned → orphan. assert_eq!( collect_unfinished_subagents(&lines), vec![("b".to_string(), "cb".to_string())] ); } /// Resume idempotency seam: the finish the stream reconcile emits must /// re-pair the orphan's spawn on the next resume (emit→serialize→collect), /// so a second resume doesn't re-emit. Guards a `SubagentFinished` shape drift. #[test] fn collect_pairs_a_reconcile_emitted_finish_with_its_spawn() { use crate::extensions::notification::{SessionNotification, SessionUpdate}; let spawn = r#"{"sessionId":"s","update":{"sessionUpdate":"subagent_spawned","subagent_id":"sa","parent_session_id":"s","child_session_id":"ca","subagent_type":"general-purpose","description":"task"}}"#.to_string(); // Build the finish exactly as the stream reconcile emits it. let finish = serde_json::to_string(&SessionNotification { session_id: acp::SessionId::new("s"), update: SessionUpdate::SubagentFinished { subagent_id: "sa".into(), child_session_id: "ca".into(), status: "cancelled".into(), error: Some("interrupted by process restart".into()), tool_calls: 0, turns: 0, duration_ms: 0, tokens_used: 0, output: None, will_wake: false, }, meta: None, }) .unwrap(); assert!( collect_unfinished_subagents(&[spawn.as_str(), finish.as_str()]).is_empty(), "the emitted finish must re-pair the spawn so a 2nd resume doesn't re-emit" ); } // ── collect_assistant_text / collect_tool_metadata tests ────────────────── #[test] fn collect_assistant_text_extracts_chunks() { let lines = vec![ acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}"#, ), acp_envelope( r#"{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"world"}}"#, ), ]; let updates: Vec<_> = lines .into_iter() .map(|s| Ok(serde_json::from_str(&s).unwrap())) .collect(); let result = collect_assistant_text(updates.into_iter()); assert_eq!(result, vec!["hello world"]); } #[test] fn collect_assistant_text_caps_at_100k() { // Two 60k chunks with non-ASCII, separator, and truncation let chunk1 = "x".repeat(60_000) + "café"; // 60k + 5 bytes (café is 5 UTF-8 bytes) let chunk2 = "日本語".repeat(20_000); // 60k bytes (3 bytes per char) let lines = vec![ acp_envelope(&format!( r#"{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{chunk1}"}}}}"# )), acp_envelope(&format!( r#"{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{chunk2}"}}}}"# )), ]; let updates: Vec<_> = lines .into_iter() .map(|s| Ok(serde_json::from_str(&s).unwrap())) .collect(); let result = collect_assistant_text(updates.into_iter()); let total: usize = result.iter().map(|s| s.len()).sum(); assert!(total <= 100_000, "got {total} chars"); // Verify non-ASCII content is present (not corrupted by truncation) assert!( result.iter().any(|s| s.contains("café")), "non-ASCII should be preserved" ); } #[test] fn collect_tool_metadata_extracts_title_and_paths() { let line = acp_envelope( r#"{"sessionUpdate":"tool_call","toolCallId":"tc1","title":"Read `/tmp/foo.rs`","kind":"read","locations":[{"path":"/tmp/foo.rs"}]}"#, ); let updates: Vec<_> = vec![Ok(serde_json::from_str(&line).unwrap())]; let result = collect_tool_metadata(updates.into_iter()); assert!(result.contains(&"Read `/tmp/foo.rs`".to_string())); assert!(result.contains(&"/tmp/foo.rs".to_string())); } #[test] fn collect_tool_metadata_caps_at_200_calls() { let mut lines = Vec::new(); for i in 0..250 { lines.push(acp_envelope(&format!( r#"{{"sessionUpdate":"tool_call","toolCallId":"tc{i}","title":"tool_{i}","kind":"exec","locations":[]}}"#, ))); } let updates: Vec<_> = lines .into_iter() .map(|s| Ok(serde_json::from_str(&s).unwrap())) .collect(); let result = collect_tool_metadata(updates.into_iter()); // Should cap at 200 tool calls (title + paths, but paths empty so just titles) let titles: Vec<_> = result.iter().filter(|s| s.starts_with("tool_")).collect(); assert_eq!(titles.len(), 200); } #[test] fn from_str_unknown_xai_variant_deserializes_via_envelope() { // Simulates an updates.jsonl line containing a removed variant (e.g. git_branch_update). // SessionUpdateEnvelope::from_str must not error — the Unknown catch-all absorbs it. let line = xai_envelope(r#"{"sessionUpdate":"git_branch_update","branch":"main"}"#); let update = SessionUpdateEnvelope::from_str(&line).unwrap(); match update { SessionUpdate::Xai(notif) => { assert_eq!( notif.update, crate::extensions::notification::SessionUpdate::Unknown ); } SessionUpdate::Acp(_) => panic!("expected Xai variant"), } } #[test] fn from_str_known_xai_variant_still_works() { let line = xai_envelope(r#"{"sessionUpdate":"memory_flush_started"}"#); let update = SessionUpdateEnvelope::from_str(&line).unwrap(); match update { SessionUpdate::Xai(notif) => { assert_eq!( notif.update, crate::extensions::notification::SessionUpdate::MemoryFlushStarted ); } SessionUpdate::Acp(_) => panic!("expected Xai variant"), } } /// Session files written before the `kigi/` extension-method rename carry /// the legacy `_…/session/update` method name. The read-side alias must /// keep those lines loading as extension updates; the write side always /// emits the current [`XAI_SESSION_UPDATE_METHOD`]. #[test] fn from_str_accepts_legacy_pre_rebrand_method_name() { let line = format!( r#"{{"timestamp":1,"method":"{LEGACY_XAI_SESSION_UPDATE_METHOD}","params":{{"sessionId":"s","update":{{"sessionUpdate":"memory_flush_started"}}}}}}"# ); let update = SessionUpdateEnvelope::from_str(&line).unwrap(); match &update { SessionUpdate::Xai(notif) => { assert_eq!( notif.update, crate::extensions::notification::SessionUpdate::MemoryFlushStarted ); } SessionUpdate::Acp(_) => panic!("expected Xai variant for legacy method name"), } // Both spellings classify as the extension rail; an unrelated method // does not. assert!(is_ext_session_update_method(XAI_SESSION_UPDATE_METHOD)); assert!(is_ext_session_update_method( LEGACY_XAI_SESSION_UPDATE_METHOD )); assert!(!is_ext_session_update_method("session/update")); // Write side: envelopes produced today carry the current name only. let reserialized = serde_json::to_string(&update).unwrap(); assert!(reserialized.contains(XAI_SESSION_UPDATE_METHOD)); assert!(!reserialized.contains(LEGACY_XAI_SESSION_UPDATE_METHOD)); } }