feat(tui): add session artifact metadata
This commit is contained in:
@@ -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<Option<PathBuf>> = 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<Utc>,
|
||||
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<PathBuf> {
|
||||
#[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<PathBuf>) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
|
||||
@@ -1296,6 +1296,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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -723,11 +723,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());
|
||||
@@ -818,6 +824,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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Message>,
|
||||
system_prompt: Option<SystemPrompt>,
|
||||
model: String,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub enum Op {
|
||||
|
||||
/// Sync engine session state (used for resume/load)
|
||||
SyncSession {
|
||||
session_id: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
system_prompt: Option<SystemPrompt>,
|
||||
model: String,
|
||||
|
||||
@@ -13,6 +13,7 @@ use tempfile::NamedTempFile;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
mod acp_server;
|
||||
mod artifacts;
|
||||
mod audit;
|
||||
mod auto_reasoning;
|
||||
mod automation_manager;
|
||||
|
||||
@@ -1935,6 +1935,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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -114,6 +115,10 @@ pub struct SavedSession {
|
||||
/// `/attach` mentions. Optional for backward-compatible session loads.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub context_references: Vec<SessionContextReference>,
|
||||
/// 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<ArtifactRecord>,
|
||||
}
|
||||
|
||||
/// Manager for session persistence operations
|
||||
@@ -378,7 +383,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
|
||||
@@ -551,7 +561,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
|
||||
@@ -587,6 +617,7 @@ pub fn create_saved_session_with_mode(
|
||||
truncation_note,
|
||||
),
|
||||
context_references: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,6 +881,7 @@ mod tests {
|
||||
},
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
};
|
||||
manager.save_session(&session).expect("save");
|
||||
}
|
||||
@@ -876,6 +908,7 @@ mod tests {
|
||||
},
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
};
|
||||
manager.save_session(&session).expect("save empty");
|
||||
}
|
||||
@@ -1044,6 +1077,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");
|
||||
@@ -1403,6 +1461,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
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
apply_spillover_inner(result, tool_id, None)
|
||||
}
|
||||
|
||||
/// Apply spillover and emit a session-scoped artifact reference.
|
||||
///
|
||||
/// The legacy `~/.deepseek/tool_outputs/<tool-id>.txt` file is still written
|
||||
/// so `retrieve_tool_result ref=<tool-id>` keeps working during the
|
||||
/// transition. The canonical artifact content is also written under
|
||||
/// `~/.deepseek/sessions/<session-id>/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<PathBuf> {
|
||||
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<ArtifactSpilloverContext<'_>>,
|
||||
) -> Option<PathBuf> {
|
||||
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<PathBuf
|
||||
};
|
||||
let (head, path) = outcome;
|
||||
let path_str = path.display().to_string();
|
||||
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=<text>` \
|
||||
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=<text>` \
|
||||
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<PathBuf
|
||||
let prior = std::mem::replace(metadata, serde_json::json!({}));
|
||||
if let Some(obj) = metadata.as_object_mut() {
|
||||
obj.insert("_prior".into(), prior);
|
||||
obj.insert(
|
||||
"spillover_path".into(),
|
||||
serde_json::Value::String(path.display().to_string()),
|
||||
);
|
||||
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.display().to_string()),
|
||||
);
|
||||
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.display().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(path)
|
||||
artifact_path
|
||||
.map(|(absolute_path, _, _)| absolute_path)
|
||||
.or(Some(path))
|
||||
}
|
||||
|
||||
/// Sanitise a tool call id for use as a filename. Keeps ASCII
|
||||
@@ -299,32 +448,44 @@ fn sanitise_id(id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<F, R>(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<PathBuf>,
|
||||
prior_artifacts: Option<PathBuf>,
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -8,6 +8,7 @@ use ratatui::layout::Rect;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::artifacts::ArtifactRecord;
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::config::{
|
||||
ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key,
|
||||
@@ -792,6 +793,8 @@ pub struct App {
|
||||
pub backtrack: crate::tui::backtrack::BacktrackState,
|
||||
/// Current session ID for auto-save updates
|
||||
pub current_session_id: Option<String>,
|
||||
/// Metadata-only registry of large tool outputs produced in this session.
|
||||
pub session_artifacts: Vec<ArtifactRecord>,
|
||||
/// Trust mode - allow access outside workspace
|
||||
pub trust_mode: bool,
|
||||
/// Ordered list of footer items the user wants visible. Sourced from
|
||||
@@ -1353,6 +1356,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
|
||||
@@ -1900,13 +1904,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);
|
||||
@@ -3775,6 +3780,7 @@ pub enum AppAction {
|
||||
#[allow(dead_code)] // For explicit /load command
|
||||
LoadSession(PathBuf),
|
||||
SyncSession {
|
||||
session_id: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
system_prompt: Option<SystemPrompt>,
|
||||
model: String,
|
||||
@@ -4342,6 +4348,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());
|
||||
@@ -4359,6 +4366,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();
|
||||
@@ -4373,6 +4381,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();
|
||||
@@ -4387,6 +4396,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());
|
||||
|
||||
@@ -385,6 +385,66 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
|
||||
}
|
||||
}
|
||||
|
||||
fn record_spillover_artifact_if_any(
|
||||
app: &mut App,
|
||||
id: &str,
|
||||
name: &str,
|
||||
result: &Result<ToolResult, ToolError>,
|
||||
) {
|
||||
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.
|
||||
|
||||
@@ -45,7 +45,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,
|
||||
@@ -369,6 +369,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(),
|
||||
@@ -1045,11 +1046,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 {
|
||||
@@ -1839,6 +1842,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(),
|
||||
@@ -2625,6 +2629,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(),
|
||||
@@ -3007,17 +3012,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
|
||||
}
|
||||
}
|
||||
@@ -4192,6 +4211,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(),
|
||||
@@ -4491,14 +4511,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,
|
||||
@@ -4746,6 +4774,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(),
|
||||
@@ -5687,6 +5716,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(),
|
||||
@@ -5819,6 +5849,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(),
|
||||
@@ -6080,6 +6111,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() {
|
||||
|
||||
@@ -708,6 +708,7 @@ fn saved_session_with_messages(messages: Vec<Message>) -> SavedSession {
|
||||
messages,
|
||||
system_prompt: None,
|
||||
context_references: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2764,6 +2765,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
|
||||
|
||||
Reference in New Issue
Block a user