feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)

This commit is contained in:
2026-09-16 23:20:31 +02:00
parent 83b78f438f
commit 943c4525d7
30 changed files with 1794 additions and 109 deletions

View File

@ -0,0 +1,124 @@
//! Filesystem adapter for the custom CLI conversation catalogue.
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::io::AsyncWriteExt;
use domain::ports::StoreError;
use domain::ProjectPath;
use domain::{AgentId, ConversationId, CustomConversationEntry, CustomConversationIndex};
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
const INDEX_FILE: &str = "custom-index.json";
/// JSON-backed project-scoped custom conversation catalogue.
pub struct FsCustomConversationIndex {
path: PathBuf,
lock: Arc<tokio::sync::Mutex<()>>,
}
impl FsCustomConversationIndex {
/// Creates an index rooted below `<project>/.ideai/conversations/`.
#[must_use]
pub fn new(root: &ProjectPath) -> Self {
Self {
path: PathBuf::from(root.as_str())
.join(IDEAI_DIR)
.join(CONVERSATIONS_DIR)
.join(INDEX_FILE),
lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
async fn read_entries(&self) -> Result<Vec<CustomConversationEntry>, StoreError> {
match tokio::fs::read(&self.path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|error| StoreError::Serialization(error.to_string())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(StoreError::Io(error.to_string())),
}
}
async fn write_entries(&self, entries: &[CustomConversationEntry]) -> Result<(), StoreError> {
let parent = self.path.parent().expect("custom index path has parent");
tokio::fs::create_dir_all(parent)
.await
.map_err(|error| StoreError::Io(error.to_string()))?;
let bytes = serde_json::to_vec_pretty(entries)
.map_err(|error| StoreError::Serialization(error.to_string()))?;
let temporary = self.path.with_extension("json.tmp");
let mut file = tokio::fs::File::create(&temporary)
.await
.map_err(|error| StoreError::Io(error.to_string()))?;
file.write_all(&bytes)
.await
.map_err(|error| StoreError::Io(error.to_string()))?;
file.sync_all()
.await
.map_err(|error| StoreError::Io(error.to_string()))?;
drop(file);
tokio::fs::rename(&temporary, &self.path)
.await
.map_err(|error| StoreError::Io(error.to_string()))
}
}
#[async_trait]
impl CustomConversationIndex for FsCustomConversationIndex {
async fn list(
&self,
agent_id: AgentId,
limit: usize,
) -> Result<Vec<CustomConversationEntry>, StoreError> {
let _guard = self.lock.lock().await;
let mut entries = self
.read_entries()
.await?
.into_iter()
.filter(|entry| entry.agent_id == agent_id)
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_ms
.cmp(&left.updated_at_ms)
.then_with(|| right.created_at_ms.cmp(&left.created_at_ms))
});
entries.truncate(limit);
Ok(entries)
}
async fn get(
&self,
conversation_id: ConversationId,
) -> Result<Option<CustomConversationEntry>, StoreError> {
let _guard = self.lock.lock().await;
Ok(self
.read_entries()
.await?
.into_iter()
.find(|entry| entry.conversation_id == conversation_id))
}
async fn upsert(&self, entry: CustomConversationEntry) -> Result<(), StoreError> {
let _guard = self.lock.lock().await;
let mut entries = self.read_entries().await?;
if let Some(existing) = entries
.iter_mut()
.find(|candidate| candidate.conversation_id == entry.conversation_id)
{
// Ownership and the first-prompt title are immutable.
if existing.agent_id != entry.agent_id {
return Err(StoreError::Serialization(
"custom conversation ownership mismatch".to_owned(),
));
}
existing.updated_at_ms = existing.updated_at_ms.max(entry.updated_at_ms);
} else {
entries.push(entry);
}
self.write_entries(&entries).await
}
}

View File

@ -46,10 +46,12 @@ use domain::conversation_log::{
use domain::ports::StoreError;
use domain::project::ProjectPath;
mod custom_index;
mod handoff;
mod providers;
mod summarizer;
pub use custom_index::FsCustomConversationIndex;
pub use handoff::FsHandoffStore;
pub use providers::FsProviderSessionStore;
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};

View File

@ -62,8 +62,8 @@ pub use chat_drafts::FsChatDraftStore;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use conversation_log::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore,
HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW,
};
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;

View File

@ -15,6 +15,7 @@
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
use std::path::PathBuf;
use std::sync::Arc;
use domain::conversation::ConversationId;
use domain::conversation_log::{
@ -25,12 +26,36 @@ use domain::conversation_log::{
use domain::input::InputSource;
use domain::ports::StoreError;
use domain::project::ProjectPath;
use domain::{AgentId, CustomConversationEntry, CustomConversationIndex};
use infrastructure::{
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
TURN_LINE_MAX_CHARS, WINDOW,
FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore,
HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW,
};
use uuid::Uuid;
struct CustomIndexProvider;
impl application::CustomConversationIndexProvider for CustomIndexProvider {
fn custom_conversation_index_for(
&self,
root: &ProjectPath,
) -> Option<Arc<dyn CustomConversationIndex>> {
Some(Arc::new(FsCustomConversationIndex::new(root)))
}
}
struct ConversationRecordProvider;
impl application::RecordTurnProvider for ConversationRecordProvider {
fn record_turn_for(&self, root: &ProjectPath) -> Option<Arc<application::RecordTurn>> {
Some(Arc::new(application::RecordTurn::new(
Arc::new(FsConversationLog::new(root)),
Arc::new(FsHandoffStore::new(root)),
Arc::new(HeuristicHandoffSummarizer::new()),
)))
}
}
// ---------------------------------------------------------------------------
// Test scaffolding
// ---------------------------------------------------------------------------
@ -77,6 +102,138 @@ impl TempDir {
.join("providers.json.tmp")
}
}
#[tokio::test]
async fn custom_index_is_durable_owned_sorted_and_capped() {
let tmp = TempDir::new();
let root = tmp.project_path();
let index = FsCustomConversationIndex::new(&root);
let agent_a = AgentId::from_uuid(Uuid::from_u128(10));
let agent_b = AgentId::from_uuid(Uuid::from_u128(20));
index
.upsert(CustomConversationEntry {
conversation_id: conv_id(1),
agent_id: agent_a,
title: "first prompt".to_owned(),
created_at_ms: 100,
updated_at_ms: 100,
})
.await
.unwrap();
index
.upsert(CustomConversationEntry {
conversation_id: conv_id(2),
agent_id: agent_a,
title: "newer prompt".to_owned(),
created_at_ms: 200,
updated_at_ms: 300,
})
.await
.unwrap();
index
.upsert(CustomConversationEntry {
conversation_id: conv_id(3),
agent_id: agent_b,
title: "foreign".to_owned(),
created_at_ms: 400,
updated_at_ms: 400,
})
.await
.unwrap();
let restarted = FsCustomConversationIndex::new(&root);
let rows = restarted.list(agent_a, 1).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].conversation_id, conv_id(2));
assert_eq!(rows[0].title, "newer prompt");
assert_eq!(
restarted.get(conv_id(3)).await.unwrap().unwrap().agent_id,
agent_b
);
}
#[tokio::test]
async fn custom_index_keeps_first_title_and_rejects_owner_change() {
let tmp = TempDir::new();
let index = FsCustomConversationIndex::new(&tmp.project_path());
let agent_a = AgentId::from_uuid(Uuid::from_u128(10));
let agent_b = AgentId::from_uuid(Uuid::from_u128(20));
let original = CustomConversationEntry {
conversation_id: conv_id(1),
agent_id: agent_a,
title: "original".to_owned(),
created_at_ms: 100,
updated_at_ms: 100,
};
index.upsert(original.clone()).await.unwrap();
index
.upsert(CustomConversationEntry {
title: "must not replace".to_owned(),
updated_at_ms: 200,
..original
})
.await
.unwrap();
let stored = index.get(conv_id(1)).await.unwrap().unwrap();
assert_eq!(stored.title, "original");
assert_eq!(stored.updated_at_ms, 200);
let error = index
.upsert(CustomConversationEntry {
agent_id: agent_b,
..stored
})
.await
.unwrap_err();
assert!(matches!(error, StoreError::Serialization(_)));
}
#[tokio::test]
async fn custom_service_materializes_on_prompt_records_final_and_checks_owner() {
let tmp = TempDir::new();
let root = tmp.project_path();
let service = application::CustomConversationService::new(
Arc::new(CustomIndexProvider),
Arc::new(ConversationRecordProvider),
);
let agent = AgentId::from_uuid(Uuid::from_u128(10));
let foreign = AgentId::from_uuid(Uuid::from_u128(20));
let conversation = conv_id(99);
assert!(service.list(&root, agent, 50).await.unwrap().is_empty());
assert!(!tmp
.0
.join(".ideai/conversations/custom-index.json")
.exists());
service
.record_prompt(&root, agent, conversation, 100, " First\n user prompt ")
.await
.unwrap();
service
.record_response(&root, agent, conversation, 200, "Final answer")
.await
.unwrap();
let rows = service.list(&root, agent, 50).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "First user prompt");
assert_eq!(rows[0].updated_at_ms, 200);
let turns = FsConversationLog::new(&root)
.read(conversation, None)
.await
.unwrap();
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].role, TurnRole::Prompt);
assert_eq!(turns[1].role, TurnRole::Response);
let error = service
.assert_owned(&root, foreign, conversation)
.await
.unwrap_err();
assert!(matches!(error, application::AppError::Invalid(_)));
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);