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:
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal file
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user