diff --git a/crates/tui/src/artifacts.rs b/crates/tui/src/artifacts.rs new file mode 100644 index 00000000..620f8018 --- /dev/null +++ b/crates/tui/src/artifacts.rs @@ -0,0 +1,301 @@ +//! Session-scoped artifact metadata. +//! +//! Large tool outputs are written under the owning session directory and saved +//! sessions keep a durable metadata index for resume/listing flows. + +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub const ARTIFACTS_DIR_NAME: &str = "artifacts"; + +#[cfg(test)] +static TEST_ARTIFACT_SESSIONS_ROOT: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(test)] +pub(crate) static TEST_ARTIFACT_SESSIONS_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + ToolOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactRecord { + pub id: String, + pub kind: ArtifactKind, + #[serde(default)] + pub session_id: String, + pub tool_call_id: String, + pub tool_name: String, + pub created_at: DateTime, + pub byte_size: u64, + pub preview: String, + pub storage_path: PathBuf, +} + +fn sanitize_id_component(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn is_valid_session_id(session_id: &str) -> bool { + !session_id.is_empty() + && session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +#[must_use] +pub fn artifact_id_for_tool_call(tool_call_id: &str) -> String { + format!("art_{}", sanitize_id_component(tool_call_id)) +} + +#[must_use] +pub fn session_artifact_relative_path(artifact_id: &str) -> PathBuf { + PathBuf::from(ARTIFACTS_DIR_NAME).join(format!("{artifact_id}.txt")) +} + +fn artifact_sessions_root() -> Option { + #[cfg(test)] + if let Some(root) = TEST_ARTIFACT_SESSIONS_ROOT + .lock() + .unwrap_or_else(|err| err.into_inner()) + .clone() + { + return Some(root); + } + + Some(dirs::home_dir()?.join(".deepseek").join("sessions")) +} + +#[cfg(test)] +pub(crate) fn set_test_artifact_sessions_root(root: Option) -> Option { + let mut guard = TEST_ARTIFACT_SESSIONS_ROOT + .lock() + .unwrap_or_else(|err| err.into_inner()); + std::mem::replace(&mut *guard, root) +} + +#[must_use] +pub fn session_artifact_absolute_path(session_id: &str, relative_path: &Path) -> Option { + if !is_valid_session_id(session_id) { + return None; + } + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return None; + } + Some( + artifact_sessions_root()? + .join(session_id) + .join(relative_path), + ) +} + +pub fn write_session_artifact( + session_id: &str, + artifact_id: &str, + content: &str, +) -> io::Result<(PathBuf, PathBuf)> { + let relative_path = session_artifact_relative_path(artifact_id); + let absolute_path = + session_artifact_absolute_path(session_id, &relative_path).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "could not resolve session artifact path (missing home directory)", + ) + })?; + if let Some(parent) = absolute_path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::utils::write_atomic(&absolute_path, content.as_bytes())?; + Ok((absolute_path, relative_path)) +} + +fn preview_text(content: &str, max_chars: usize) -> String { + let mut preview: String = content.chars().take(max_chars).collect(); + if content.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +pub fn record_tool_output_artifact( + session_id: &str, + tool_call_id: &str, + tool_name: &str, + storage_path: impl Into, + content: &str, +) -> ArtifactRecord { + let storage_path = storage_path.into(); + let byte_size = std::fs::metadata(&storage_path) + .map(|metadata| metadata.len()) + .unwrap_or_else(|_| content.len() as u64); + record_tool_output_artifact_with_size( + session_id, + tool_call_id, + tool_name, + storage_path, + byte_size, + &preview_text(content, 200), + ) +} + +pub fn record_tool_output_artifact_with_size( + session_id: &str, + tool_call_id: &str, + tool_name: &str, + storage_path: impl Into, + byte_size: u64, + preview: &str, +) -> ArtifactRecord { + ArtifactRecord { + id: artifact_id_for_tool_call(tool_call_id), + kind: ArtifactKind::ToolOutput, + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + created_at: Utc::now(), + byte_size, + preview: preview_text(preview, 200), + storage_path: storage_path.into(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptArtifactRef { + pub artifact_id: String, + pub tool_name: String, + pub tool_call_id: String, + pub byte_size: u64, + pub storage_path: PathBuf, + pub preview: String, +} + +impl From<&ArtifactRecord> for TranscriptArtifactRef { + fn from(record: &ArtifactRecord) -> Self { + Self { + artifact_id: record.id.clone(), + tool_name: record.tool_name.clone(), + tool_call_id: record.tool_call_id.clone(), + byte_size: record.byte_size, + storage_path: record.storage_path.clone(), + preview: record.preview.clone(), + } + } +} + +#[must_use] +pub fn render_transcript_artifact_ref(reference: &TranscriptArtifactRef) -> String { + format!( + "[artifact: {tool}]\n\ + id: {id}\n\ + tool: {tool}\n\ + tool_call_id: {tool_call_id}\n\ + size: {size}\n\ + path: {path}\n\ + preview: {preview}", + tool = reference.tool_name, + id = reference.artifact_id, + tool_call_id = reference.tool_call_id, + size = format_byte_size(reference.byte_size), + path = format_artifact_relative_path(&reference.storage_path), + preview = reference.preview.replace('\n', " "), + ) +} + +#[must_use] +pub fn format_artifact_relative_path(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + +#[must_use] +pub fn format_byte_size(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + if bytes >= MIB { + format!("{} MB", bytes.div_ceil(MIB)) + } else if bytes >= KIB { + format!("{} KB", bytes.div_ceil(KIB)) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestArtifactSessionsRoot { + prior: Option, + } + + impl Drop for TestArtifactSessionsRoot { + fn drop(&mut self) { + set_test_artifact_sessions_root(self.prior.take()); + } + } + + fn set_test_sessions_root(root: PathBuf) -> TestArtifactSessionsRoot { + TestArtifactSessionsRoot { + prior: set_test_artifact_sessions_root(Some(root)), + } + } + + #[test] + fn transcript_ref_renders_relative_paths_with_forward_slashes() { + let reference = TranscriptArtifactRef { + artifact_id: "art_call-big".to_string(), + tool_name: "exec_shell".to_string(), + tool_call_id: "call-big".to_string(), + byte_size: 1024, + storage_path: PathBuf::from(r"artifacts\art_call-big.txt"), + preview: "checking crate".to_string(), + }; + + let rendered = render_transcript_artifact_ref(&reference); + + assert!(rendered.contains("path: artifacts/art_call-big.txt")); + } + + #[test] + fn session_artifact_absolute_path_uses_test_sessions_root() { + let _guard = TEST_ARTIFACT_SESSIONS_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); + let tmp = tempfile::tempdir().unwrap(); + let _root = set_test_sessions_root(tmp.path().join("sessions")); + + let path = session_artifact_absolute_path( + "session-123", + &PathBuf::from("artifacts").join("art_call-big.txt"), + ) + .expect("path"); + + assert_eq!( + path, + tmp.path() + .join("sessions") + .join("session-123") + .join("artifacts") + .join("art_call-big.txt") + ); + } +} diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 8529233d..0f05fa6a 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -79,6 +79,7 @@ pub fn clear(app: &mut App) -> CommandResult { CommandResult::with_message_and_action( message, AppAction::SyncSession { + session_id: None, messages: Vec::new(), system_prompt: None, model: app.model.clone(), @@ -428,6 +429,18 @@ mod tests { app.session.total_conversation_tokens = 100; app.tool_log.push("test".to_string()); app.current_session_id = Some("existing-session".to_string()); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "existing-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "tool output".to_string(), + storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), + }); let result = clear(&mut app); assert!(result.message.is_some()); @@ -437,6 +450,7 @@ mod tests { assert!(app.tool_log.is_empty()); assert!(app.tool_cells.is_empty()); assert!(app.tool_details_by_cell.is_empty()); + assert!(app.session_artifacts.is_empty()); assert!(app.current_session_id.is_none()); assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); } diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/debug.rs index b5851900..4b190225 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/debug.rs @@ -1612,6 +1612,7 @@ pub fn patch_undo(app: &mut App) -> CommandResult { CommandResult::with_message_and_action( summary, AppAction::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index 20f68459..2563c533 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -20,7 +20,7 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { }; let messages = app.api_messages.clone(); - let session = create_saved_session_with_mode( + let mut session = create_saved_session_with_mode( &messages, &app.model, &app.workspace, @@ -28,6 +28,7 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { app.system_prompt.as_ref(), Some(app.mode.label()), ); + session.artifacts = app.session_artifacts.clone(); let sessions_dir = save_path .parent() @@ -111,6 +112,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); app.current_session_id = Some(session.metadata.id.clone()); + app.session_artifacts = session.artifacts.clone(); if let Some(sp) = session.system_prompt { app.system_prompt = Some(crate::models::SystemPrompt::Text(sp)); } @@ -124,6 +126,7 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { session.metadata.message_count ), crate::tui::app::AppAction::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -329,6 +332,32 @@ mod tests { assert!(save_path.exists()); } + #[test] + fn save_preserves_artifact_registry() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("artifact_session.json"); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 512_000, + preview: "cargo test output".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + let saved: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); + assert_eq!(saved.artifacts, app.session_artifacts); + } + #[test] fn test_save_with_default_path_uses_workspace() { let tmpdir = TempDir::new().unwrap(); @@ -418,6 +447,46 @@ mod tests { assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); } + #[test] + fn load_restores_artifact_registry() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app + .session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "checking crate".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + let save_path = tmpdir.path().join("artifact_load.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_stale".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "stale-session".to_string(), + tool_call_id: "stale".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 1, + preview: "stale".to_string(), + storage_path: tmpdir.path().join("stale.txt"), + }); + + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert_eq!(app.session_artifacts, saved_app.session_artifacts); + } + #[test] fn load_resets_cache_history_and_cost() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fb94639d..04b72ac5 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -726,11 +726,17 @@ impl Engine { .await; } Op::SyncSession { + session_id, messages, system_prompt, model, workspace, } => { + if let Some(session_id) = session_id { + self.session.id = session_id; + } else if messages.is_empty() && system_prompt.is_none() { + self.session.id = uuid::Uuid::new_v4().to_string(); + } self.session.messages = messages; self.session.compaction_summary_prompt = extract_compaction_summary_prompt(system_prompt.clone()); @@ -821,6 +827,7 @@ impl Engine { let _ = self .tx_event .send(Event::SessionUpdated { + session_id: self.session.id.clone(), messages: self.session.messages.clone(), system_prompt: self.session.system_prompt.clone(), model: self.session.model.clone(), diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index a8ec0047..e9f843f1 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1240,6 +1240,7 @@ impl Engine { let lock = tool_exec_lock.clone(); let mcp_pool = mcp_pool.clone(); let tx_event = self.tx_event.clone(); + let session_id = self.session.id.clone(); let started_at = Instant::now(); tool_tasks.push(async move { @@ -1262,7 +1263,12 @@ impl Engine { // correlate large-output episodes with disk usage. if let Ok(tool_result) = result.as_mut() && let Some(path) = - crate::tools::truncate::apply_spillover(tool_result, &plan.id) + crate::tools::truncate::apply_spillover_with_artifact( + tool_result, + &plan.id, + &plan.name, + &session_id, + ) { emit_tool_audit(json!({ "event": "tool.spillover", @@ -1568,14 +1574,18 @@ impl Engine { // #500: spill outsized tool outputs to disk before the // result fans out to the model context and the UI cell. - // Both consumers see the same truncated content + the - // `spillover_path` metadata pointing at the full file. + // Both consumers see the same artifact reference block + + // metadata pointing at the session-owned full file. // Emit a discrete `tool.spillover` audit event so // operators can correlate large-output episodes with // disk-usage growth in `~/.deepseek/tool_outputs/`. if let Ok(tool_result) = result.as_mut() - && let Some(path) = - crate::tools::truncate::apply_spillover(tool_result, &tool_id) + && let Some(path) = crate::tools::truncate::apply_spillover_with_artifact( + tool_result, + &tool_id, + &tool_name, + &self.session.id, + ) { emit_tool_audit(json!({ "event": "tool.spillover", diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 74d6cd44..41ca417f 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -244,6 +244,7 @@ pub enum Event { /// text block, and that assistant message still has to be persisted for /// later `reasoning_content` replay. SessionUpdated { + session_id: String, messages: Vec, system_prompt: Option, model: String, diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index fbc0f4d5..f3b127fa 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -64,6 +64,7 @@ pub enum Op { /// Sync engine session state (used for resume/load) SyncSession { + session_id: Option, messages: Vec, system_prompt: Option, model: String, diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ddc95c13..6d99848c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -13,6 +13,7 @@ use tempfile::NamedTempFile; use wait_timeout::ChildExt; mod acp_server; +mod artifacts; mod audit; mod auto_reasoning; mod automation_manager; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 3abb6273..0f992a14 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1971,6 +1971,7 @@ impl RuntimeThreadManager { if !session_messages.is_empty() || sys_prompt.is_some() { engine .send(Op::SyncSession { + session_id: None, messages: session_messages, system_prompt: sys_prompt, model: thread.model.clone(), diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index d3b4abf4..cfb07403 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -6,6 +6,7 @@ //! - Resuming sessions by ID //! - Managing session lifecycle +use crate::artifacts::ArtifactRecord; use crate::models::{ContentBlock, Message, SystemPrompt}; use crate::tui::file_mention::ContextReference; use crate::utils::write_atomic; @@ -139,6 +140,10 @@ pub struct SavedSession { /// `/attach` mentions. Optional for backward-compatible session loads. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub context_references: Vec, + /// Metadata registry of large outputs produced during this session. + /// Artifact contents are stored in the session-owned artifact directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, } /// Manager for session persistence operations @@ -405,7 +410,12 @@ impl SessionManager { /// Delete a session by ID pub fn delete_session(&self, id: &str) -> std::io::Result<()> { let path = self.validated_session_path(id)?; - fs::remove_file(path) + fs::remove_file(path)?; + let session_dir = self.sessions_dir.join(id.trim()); + if session_dir.exists() { + fs::remove_dir_all(session_dir)?; + } + Ok(()) } /// Clean up old sessions to stay within `MAX_SESSIONS` limit @@ -578,7 +588,27 @@ pub fn create_saved_session_with_mode( system_prompt: Option<&SystemPrompt>, mode: Option<&str>, ) -> SavedSession { - let id = Uuid::new_v4().to_string(); + create_saved_session_with_id_and_mode( + Uuid::new_v4().to_string(), + messages, + model, + workspace, + total_tokens, + system_prompt, + mode, + ) +} + +/// Create a new `SavedSession` using a caller-owned session id. +pub fn create_saved_session_with_id_and_mode( + id: String, + messages: &[Message], + model: &str, + workspace: &Path, + total_tokens: u64, + system_prompt: Option<&SystemPrompt>, + mode: Option<&str>, +) -> SavedSession { let now = Utc::now(); // Generate title from first user message @@ -614,6 +644,7 @@ pub fn create_saved_session_with_mode( truncation_note, ), context_references: Vec::new(), + artifacts: Vec::new(), } } @@ -877,6 +908,7 @@ mod tests { }, system_prompt: None, context_references: Vec::new(), + artifacts: Vec::new(), }; manager.save_session(&session).expect("save"); } @@ -903,6 +935,7 @@ mod tests { }, system_prompt: None, context_references: Vec::new(), + artifacts: Vec::new(), }; manager.save_session(&session).expect("save empty"); } @@ -1071,6 +1104,31 @@ mod tests { assert!(manager.load_session(&session_id).is_err()); } + #[test] + fn delete_session_removes_artifact_directory() { + let tmp = tempdir().expect("tempdir"); + let sessions_dir = tmp.path().join("sessions"); + let manager = SessionManager::new(sessions_dir.clone()).expect("new"); + + let session = create_saved_session( + &[make_test_message("user", "artifact session")], + "test-model", + tmp.path(), + 100, + None, + ); + let session_id = session.metadata.id.clone(); + let artifact_dir = sessions_dir.join(&session_id).join("artifacts"); + fs::create_dir_all(&artifact_dir).expect("artifact dir"); + fs::write(artifact_dir.join("art_call.txt"), "raw output").expect("artifact file"); + + manager.save_session(&session).expect("save"); + manager.delete_session(&session_id).expect("delete"); + + assert!(!sessions_dir.join(format!("{session_id}.json")).exists()); + assert!(!sessions_dir.join(&session_id).exists()); + } + #[test] fn test_session_id_rejects_invalid_characters() { let tmp = tempdir().expect("tempdir"); @@ -1436,6 +1494,57 @@ mod tests { assert_eq!(extracted.title, "weird { title } with braces"); } + #[test] + fn saved_session_deserializes_without_artifacts_as_empty_registry() { + let json = r#"{ + "schema_version": 1, + "metadata": { + "id": "legacy-session", + "title": "legacy", + "created_at": "2026-05-08T00:00:00Z", + "updated_at": "2026-05-08T00:00:00Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v4-pro", + "workspace": "/tmp" + }, + "messages": [], + "system_prompt": null + }"#; + + let session: SavedSession = serde_json::from_str(json).expect("legacy session loads"); + assert!(session.artifacts.is_empty()); + } + + #[test] + fn save_and_load_session_preserves_artifact_metadata() { + let tmp = tempdir().expect("tempdir"); + let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); + let mut session = create_saved_session( + &[make_test_message("user", "run tests")], + "deepseek-v4-pro", + Path::new("/tmp"), + 0, + None, + ); + session.artifacts.push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: session.metadata.id.clone(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: Utc::now(), + byte_size: 512_000, + preview: "cargo test output".to_string(), + storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), + }); + + manager.save_session(&session).expect("save"); + let loaded = manager.load_session(&session.metadata.id).expect("load"); + + assert_eq!(loaded.artifacts, session.artifacts); + } + // ---- #406 prune_sessions_older_than ---- // // The helper is a building block for the auto-archive design: it diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index e6c66641..dd73d2aa 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -225,17 +225,55 @@ pub const SPILLOVER_HEAD_BYTES: usize = 32 * 1024; /// Error results (`success == false`) are skipped: error messages /// are typically short, and turning them into a "see file" pointer /// would just hide the error from the model's reasoning. +#[allow(dead_code)] pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option { + apply_spillover_inner(result, tool_id, None) +} + +/// Apply spillover and emit a session-scoped artifact reference. +/// +/// The legacy `~/.deepseek/tool_outputs/.txt` file is still written +/// so `retrieve_tool_result ref=` keeps working during the +/// transition. The canonical artifact content is also written under +/// `~/.deepseek/sessions//artifacts/`, and the inline tool result +/// becomes a fixed-format artifact reference block. +pub fn apply_spillover_with_artifact( + result: &mut ToolResult, + tool_id: &str, + tool_name: &str, + session_id: &str, +) -> Option { + apply_spillover_inner( + result, + tool_id, + Some(ArtifactSpilloverContext { + tool_name, + session_id, + }), + ) +} + +struct ArtifactSpilloverContext<'a> { + tool_name: &'a str, + session_id: &'a str, +} + +fn apply_spillover_inner( + result: &mut ToolResult, + tool_id: &str, + artifact_context: Option>, +) -> Option { if !result.success { return None; } if result.content.len() <= SPILLOVER_THRESHOLD_BYTES { return None; } - let total = result.content.len(); + let original_content = result.content.clone(); + let total = original_content.len(); let outcome = match maybe_spillover( tool_id, - &result.content, + &original_content, SPILLOVER_THRESHOLD_BYTES, SPILLOVER_HEAD_BYTES, ) { @@ -253,19 +291,91 @@ pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option` \ - if you need the elided output.]", - head_kib = head.len() / 1024, - total_kib = total / 1024, - ); - result.content = format!("{head}{footer}"); + + let mut artifact_path = None; + if let Some(context) = artifact_context { + let artifact_id = crate::artifacts::artifact_id_for_tool_call(tool_id); + match crate::artifacts::write_session_artifact( + context.session_id, + &artifact_id, + &original_content, + ) { + Ok((absolute_path, relative_path)) => { + let record = crate::artifacts::record_tool_output_artifact( + context.session_id, + tool_id, + context.tool_name, + relative_path.clone(), + &original_content, + ); + let transcript_ref = crate::artifacts::TranscriptArtifactRef::from(&record); + result.content = crate::artifacts::render_transcript_artifact_ref(&transcript_ref); + artifact_path = Some((absolute_path, relative_path, record)); + } + Err(err) => { + tracing::warn!( + target: "spillover", + ?err, + tool_id, + "session artifact write failed; falling back to legacy spillover footer" + ); + } + } + } + + if artifact_path.is_none() { + let footer = format!( + "\n\n[Output truncated: {head_kib} KiB of {total_kib} KiB shown. \ + Full output saved to {path_str}. Use \ + `retrieve_tool_result ref={tool_id} mode=tail` or \ + `retrieve_tool_result ref={tool_id} mode=query query=` \ + if you need the elided output.]", + head_kib = head.len() / 1024, + total_kib = total / 1024, + ); + result.content = format!("{head}{footer}"); + } + let metadata = result.metadata.get_or_insert_with(|| serde_json::json!({})); if let Some(obj) = metadata.as_object_mut() { - obj.insert("spillover_path".into(), serde_json::Value::String(path_str)); + if let Some((absolute_path, relative_path, record)) = artifact_path.as_ref() { + obj.insert( + "spillover_path".into(), + serde_json::Value::String(absolute_path.display().to_string()), + ); + obj.insert( + "legacy_spillover_path".into(), + serde_json::Value::String(path_str), + ); + obj.insert( + "artifact_id".into(), + serde_json::Value::String(record.id.clone()), + ); + obj.insert( + "artifact_session_id".into(), + serde_json::Value::String(record.session_id.clone()), + ); + obj.insert( + "artifact_relative_path".into(), + serde_json::Value::String(crate::artifacts::format_artifact_relative_path( + relative_path, + )), + ); + obj.insert( + "artifact_path".into(), + serde_json::Value::String(absolute_path.display().to_string()), + ); + obj.insert( + "artifact_byte_size".into(), + serde_json::Value::Number(serde_json::Number::from(record.byte_size)), + ); + obj.insert( + "artifact_preview".into(), + serde_json::Value::String(record.preview.clone()), + ); + } else { + obj.insert("spillover_path".into(), serde_json::Value::String(path_str)); + } } else { // Pre-existing metadata that wasn't a JSON object (rare, // possibly an array). Replace with an object so we can @@ -274,13 +384,52 @@ pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option Option { } } -/// Override the spillover root for tests so they don't pollute the -/// user's real `~/.deepseek/` directory. Wraps the body with a -/// temporary `HOME` override that gets restored on drop. +/// Override the storage roots for tests so they don't pollute the +/// user's real `~/.deepseek/` directory. This uses explicit test hooks instead +/// of `$HOME` because Windows home-dir resolution can ignore environment +/// overrides and return the runner profile directory. #[cfg(test)] fn with_test_home(home: &Path, f: F) -> R where F: FnOnce() -> R, { - // SAFETY: tests in this module serialize through `TEST_GUARD` - // because they share process-wide `$HOME`. Without the guard, - // parallel tests could observe each other's overrides. - let prior = std::env::var_os("HOME"); - // SAFETY: caller holds the test guard. - unsafe { - std::env::set_var("HOME", home); + let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); + + struct StorageRootOverride { + prior_spillover: Option, + prior_artifacts: Option, } - let out = f(); - // SAFETY: caller holds the test guard. - unsafe { - if let Some(p) = prior { - std::env::set_var("HOME", p); - } else { - std::env::remove_var("HOME"); + + impl Drop for StorageRootOverride { + fn drop(&mut self) { + set_test_spillover_root(self.prior_spillover.take()); + crate::artifacts::set_test_artifact_sessions_root(self.prior_artifacts.take()); } } - out + + // Tests in this module serialize spillover through `TEST_GUARD`; the + // artifact guard above protects the session-artifact root shared with + // artifacts.rs tests. + let prior_spillover = + set_test_spillover_root(Some(home.join(".deepseek").join(SPILLOVER_DIR_NAME))); + let prior_artifacts = crate::artifacts::set_test_artifact_sessions_root(Some( + home.join(".deepseek").join("sessions"), + )); + let _restore = StorageRootOverride { + prior_spillover, + prior_artifacts, + }; + f() } #[cfg(test)] @@ -332,15 +493,44 @@ mod tests { use super::*; use tempfile::tempdir; - /// Tests in this module serialize through this guard because - /// they mutate process-global `$HOME`. Without it, cargo's - /// parallel runner would observe interleaved overrides. + /// Tests in this module serialize through this guard because they mutate + /// process-global test storage roots. Without it, cargo's parallel runner + /// would observe interleaved overrides. fn setup() -> std::sync::MutexGuard<'static, ()> { super::TEST_SPILLOVER_GUARD .lock() .unwrap_or_else(|e| e.into_inner()) } + #[test] + fn with_test_home_overrides_storage_roots_without_home_resolution() { + let _g = setup(); + let tmp = tempdir().unwrap(); + + with_test_home(tmp.path(), || { + assert_eq!( + spillover_root().as_deref(), + Some(tmp.path().join(".deepseek").join("tool_outputs").as_path()) + ); + assert_eq!( + crate::artifacts::session_artifact_absolute_path( + "session-123", + &PathBuf::from("artifacts").join("art_call-big.txt") + ) + .as_deref(), + Some( + tmp.path() + .join(".deepseek") + .join("sessions") + .join("session-123") + .join("artifacts") + .join("art_call-big.txt") + .as_path() + ) + ); + }); + } + #[test] fn sanitise_id_keeps_safe_chars_and_drops_dangerous() { assert_eq!(super::sanitise_id("abc-123_x"), Some("abc-123_x".into())); @@ -572,6 +762,65 @@ mod tests { }); } + #[test] + fn apply_spillover_with_artifact_writes_session_file_and_ref_block() { + let _g = setup(); + let tmp = tempdir().unwrap(); + with_test_home(tmp.path(), || { + let big = "checking crate ... error[E0425]: cannot find value\n".repeat(4_000); + let mut result = ToolResult::success(big.clone()); + let path = + apply_spillover_with_artifact(&mut result, "call-big", "exec_shell", "session-123") + .expect("should spill"); + + let session_artifact = tmp + .path() + .join(".deepseek") + .join("sessions") + .join("session-123") + .join("artifacts") + .join("art_call-big.txt"); + assert_eq!(path, session_artifact); + assert_eq!(fs::read_to_string(&session_artifact).unwrap(), big); + assert!( + tmp.path() + .join(".deepseek/tool_outputs/call-big.txt") + .exists(), + "legacy spillover file should remain during transition" + ); + + assert!(result.content.starts_with("[artifact: exec_shell]")); + assert!(result.content.contains("id: art_call-big")); + assert!(result.content.contains("tool_call_id: call-big")); + assert!( + result + .content + .contains("path: artifacts/art_call-big.txt") + ); + assert!(!result.content.contains("Output truncated:")); + + let metadata = result.metadata.expect("metadata stamped"); + assert_eq!( + metadata + .get("artifact_id") + .and_then(serde_json::Value::as_str), + Some("art_call-big") + ); + assert_eq!( + metadata + .get("artifact_relative_path") + .and_then(serde_json::Value::as_str), + Some("artifacts/art_call-big.txt") + ); + assert_eq!( + metadata + .get("artifact_session_id") + .and_then(serde_json::Value::as_str), + Some("session-123") + ); + }); + } + #[test] fn apply_spillover_preserves_existing_metadata() { let _g = setup(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 78e67377..7b1d695c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -8,6 +8,7 @@ use ratatui::layout::Rect; use serde_json::Value; use thiserror::Error; +use crate::artifacts::ArtifactRecord; use crate::client::PromptInspection; use crate::compaction::CompactionConfig; use crate::config::{ @@ -800,6 +801,8 @@ pub struct App { pub backtrack: crate::tui::backtrack::BacktrackState, /// Current session ID for auto-save updates pub current_session_id: Option, + /// Metadata-only registry of large tool outputs produced in this session. + pub session_artifacts: Vec, /// Trust mode - allow access outside workspace pub trust_mode: bool, /// Ordered list of footer items the user wants visible. Sourced from @@ -1362,6 +1365,7 @@ impl App { view_stack: ViewStack::new(), backtrack: crate::tui::backtrack::BacktrackState::new(), current_session_id: None, + session_artifacts: Vec::new(), trust_mode: initial_mode == AppMode::Yolo, status_items: config .tui @@ -1909,13 +1913,14 @@ impl App { self.needs_redraw = true; } - /// Clear the history and its revision tracking. Used by /clear, session - /// reset, and other "wipe and reload" flows. + /// Clear the history and its session-scoped side indexes. Used by /clear, + /// session reset, and other "wipe and reload" flows. pub fn clear_history(&mut self) { self.history.clear(); self.history_revisions.clear(); self.context_references_by_cell.clear(); self.session_context_references.clear(); + self.session_artifacts.clear(); self.collapsed_cells.clear(); self.collapsed_cell_map.clear(); self.history_version = self.history_version.wrapping_add(1); @@ -3810,6 +3815,7 @@ pub enum AppAction { #[allow(dead_code)] // For explicit /load command LoadSession(PathBuf), SyncSession { + session_id: Option, messages: Vec, system_prompt: Option, model: String, @@ -4406,6 +4412,7 @@ mod tests { #[test] fn history_search_filters_matches_and_skips_duplicates() { let mut app = App::new(test_options(false), &Config::default()); + app.input_history.clear(); app.input_history.push("alpha one".to_string()); app.input_history.push("beta two".to_string()); app.input_history.push("alpha one".to_string()); @@ -4423,6 +4430,7 @@ mod tests { #[test] fn history_search_matches_unicode_case_insensitively() { let mut app = App::new(test_options(false), &Config::default()); + app.input_history.clear(); app.input_history.push("CAFÉ prompt".to_string()); app.start_history_search(); @@ -4437,6 +4445,7 @@ mod tests { #[test] fn history_search_accepts_match_without_submitting() { let mut app = App::new(test_options(false), &Config::default()); + app.input_history.clear(); app.input_history.push("older prompt".to_string()); app.start_history_search(); @@ -4451,6 +4460,7 @@ mod tests { #[test] fn history_search_cancel_restores_pre_search_draft() { let mut app = App::new(test_options(false), &Config::default()); + app.input_history.clear(); app.input = "current draft".to_string(); app.cursor_position = 7; app.input_history.push("older prompt".to_string()); diff --git a/crates/tui/src/tui/tool_routing.rs b/crates/tui/src/tui/tool_routing.rs index 2ce64703..ef527ff0 100644 --- a/crates/tui/src/tui/tool_routing.rs +++ b/crates/tui/src/tui/tool_routing.rs @@ -385,6 +385,66 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result, +) { + let Ok(tool_result) = result else { return }; + if !tool_result.success { + return; + } + let Some(path) = tool_result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("spillover_path")) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + else { + return; + }; + let metadata = tool_result.metadata.as_ref(); + let session_id = metadata + .and_then(|metadata| metadata.get("artifact_session_id")) + .and_then(serde_json::Value::as_str) + .or(app.current_session_id.as_deref()) + .unwrap_or(""); + let storage_path = metadata + .and_then(|metadata| metadata.get("artifact_relative_path")) + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + .unwrap_or_else(|| path.clone()); + let content_for_preview = metadata + .and_then(|metadata| metadata.get("artifact_preview")) + .and_then(serde_json::Value::as_str) + .unwrap_or(&tool_result.content); + let byte_size = metadata + .and_then(|metadata| metadata.get("artifact_byte_size")) + .and_then(serde_json::Value::as_u64) + .unwrap_or_else(|| { + std::fs::metadata(&storage_path) + .map(|metadata| metadata.len()) + .unwrap_or(tool_result.content.len() as u64) + }); + if app + .session_artifacts + .iter() + .any(|artifact| artifact.tool_call_id == id && artifact.storage_path == storage_path) + { + return; + } + app.session_artifacts + .push(crate::artifacts::record_tool_output_artifact_with_size( + session_id, + id, + name, + storage_path, + byte_size, + content_for_preview, + )); +} + pub(super) fn handle_tool_call_complete( app: &mut App, id: &str, @@ -399,6 +459,7 @@ pub(super) fn handle_tool_call_complete( // spawn their own LLM calls (RLM, summarizers, retrieval helpers) // get accrued without needing a per-tool hook (#524). accrue_child_token_cost_if_any(app, result); + record_spillover_artifact_if_any(app, id, name, result); // Exploring entries land in the per-tool map regardless of whether they // live in the active cell or in finalized history; the path is the same. diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 47e12a8d..13284920 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -48,7 +48,7 @@ use crate::palette; use crate::prompts; use crate::session_manager::{ OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager, - create_saved_session_with_mode, update_session, + create_saved_session_with_id_and_mode, create_saved_session_with_mode, update_session, }; use crate::task_manager::{ NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, @@ -366,6 +366,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -1041,11 +1042,13 @@ async fn run_event_loop( app.status_message = Some(message); } EngineEvent::SessionUpdated { + session_id, messages, system_prompt, model, workspace, } => { + app.current_session_id = Some(session_id); app.api_messages = messages; app.system_prompt = system_prompt; if app.auto_model { @@ -1846,6 +1849,7 @@ async fn run_event_loop( if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -2632,6 +2636,7 @@ async fn run_event_loop( app.edit_in_progress = false; let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -3061,17 +3066,31 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { ); updated.metadata.mode = Some(app.mode.as_setting().to_string()); updated.context_references = app.session_context_references.clone(); + updated.artifacts = app.session_artifacts.clone(); updated } else { - let mut session = create_saved_session_with_mode( - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.as_setting()), - ); + let mut session = if let Some(existing_id) = app.current_session_id.as_ref() { + create_saved_session_with_id_and_mode( + existing_id.clone(), + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.as_setting()), + ) + } else { + create_saved_session_with_mode( + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.as_setting()), + ) + }; session.context_references = app.session_context_references.clone(); + session.artifacts = app.session_artifacts.clone(); session } } @@ -4247,6 +4266,7 @@ async fn switch_provider( if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -4546,14 +4566,22 @@ async fn apply_command_result( app.status_message = Some(format!("Session loaded from {}", path.display())); } AppAction::SyncSession { + session_id, messages, system_prompt, model, workspace, } => { + let mut session_id = session_id; let is_full_reset = messages.is_empty() && system_prompt.is_none(); + if is_full_reset && session_id.is_none() { + let new_session_id = uuid::Uuid::new_v4().to_string(); + app.current_session_id = Some(new_session_id.clone()); + session_id = Some(new_session_id); + } let _ = engine_handle .send(Op::SyncSession { + session_id, messages, system_prompt, model, @@ -4845,6 +4873,7 @@ async fn apply_command_result( if !app.api_messages.is_empty() { let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -5823,6 +5852,7 @@ async fn handle_view_events( let recovered = apply_loaded_session(app, &session); let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -5959,6 +5989,7 @@ async fn handle_view_events( apply_backtrack(app, depth); let _ = engine_handle .send(Op::SyncSession { + session_id: app.current_session_id.clone(), messages: app.api_messages.clone(), system_prompt: app.system_prompt.clone(), model: app.model.clone(), @@ -6220,6 +6251,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); app.current_session_id = Some(session.metadata.id.clone()); + app.session_artifacts = session.artifacts.clone(); app.workspace_context = None; app.workspace_context_refreshed_at = None; if let Some(sp) = session.system_prompt.as_ref() { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index fb2e3359..57b61567 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1032,6 +1032,7 @@ fn saved_session_with_messages(messages: Vec) -> SavedSession { messages, system_prompt: None, context_references: Vec::new(), + artifacts: Vec::new(), } } @@ -3099,6 +3100,86 @@ fn tool_child_usage_metadata_updates_live_cost_counter() { assert!(app.session.subagent_cost > 0.0); } +#[test] +fn spilled_tool_completion_records_session_artifact_metadata() { + let tmp = tempfile::tempdir().expect("tempdir"); + let spillover_path = tmp.path().join("call-big.txt"); + let raw = "checking crate ... error[E0425]: cannot find value\n".repeat(20); + std::fs::write(&spillover_path, &raw).expect("write spillover"); + let result = Ok( + crate::tools::spec::ToolResult::success("checking crate ...").with_metadata( + serde_json::json!({ + "spillover_path": spillover_path.display().to_string(), + "artifact_session_id": "session-123", + "artifact_relative_path": "artifacts/art_call-big.txt", + "artifact_byte_size": raw.len() as u64, + "artifact_preview": "checking crate ... error[E0425]: cannot find value", + }), + ), + ); + let mut app = create_test_app(); + app.current_session_id = Some("session-123".to_string()); + + handle_tool_call_complete(&mut app, "call-big", "exec_shell", &result); + + assert_eq!(app.session_artifacts.len(), 1); + let artifact = &app.session_artifacts[0]; + assert_eq!(artifact.kind, crate::artifacts::ArtifactKind::ToolOutput); + assert_eq!(artifact.session_id, "session-123"); + assert_eq!(artifact.tool_call_id, "call-big"); + assert_eq!(artifact.tool_name, "exec_shell"); + assert_eq!(artifact.byte_size, raw.len() as u64); + assert_eq!( + artifact.storage_path, + PathBuf::from("artifacts/art_call-big.txt") + ); + assert!(artifact.preview.starts_with("checking crate")); + + let manager = + crate::session_manager::SessionManager::new(tmp.path().join("sessions")).expect("manager"); + let snapshot = build_session_snapshot(&app, &manager); + assert_eq!(snapshot.artifacts, app.session_artifacts); +} + +#[test] +fn first_snapshot_preserves_current_session_id_for_artifact_ownership() { + let tmp = tempfile::tempdir().expect("tempdir"); + let manager = + crate::session_manager::SessionManager::new(tmp.path().join("sessions")).expect("manager"); + let mut app = create_test_app(); + app.current_session_id = Some("session-123".to_string()); + app.api_messages.push(text_message("user", "hello")); + + let snapshot = build_session_snapshot(&app, &manager); + + assert_eq!(snapshot.metadata.id, "session-123"); +} + +#[test] +fn apply_loaded_session_restores_artifact_registry() { + let mut app = create_test_app(); + let mut session = saved_session_with_messages(vec![ + text_message("user", "hello"), + text_message("assistant", "hi"), + ]); + session.artifacts.push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "session-123".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "hello".to_string(), + storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), + }); + + let recovered = apply_loaded_session(&mut app, &session); + + assert!(!recovered); + assert_eq!(app.session_artifacts, session.artifacts); +} + #[test] fn parallel_exploring_tool_starts_share_one_active_entry() { // Three exploring tools start in any order; they must collapse into one