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:
@ -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