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:
@ -2516,6 +2516,19 @@ impl LaunchAgent {
|
||||
&input.project.root,
|
||||
)
|
||||
.await;
|
||||
// A resumed human custom CLI must render its durable history without eagerly
|
||||
// injecting the portable handoff into the provider context. Native provider
|
||||
// resume is already represented by `SessionPlan::Resume`; otherwise retain a
|
||||
// bounded one-shot preamble for the first subsequent `send`.
|
||||
let defer_custom_resume = input.require_structured && launch_conversation_id.is_some();
|
||||
let pending_resume_preamble =
|
||||
if defer_custom_resume && !matches!(session_plan, SessionPlan::Resume { .. }) {
|
||||
self.resolve_handoff(&input.project.root, launch_conversation_id.as_deref())
|
||||
.await
|
||||
.map(|handoff| handoff.summary_md)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut spec =
|
||||
self.runtime
|
||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||
@ -2529,7 +2542,11 @@ impl LaunchAgent {
|
||||
&manifest,
|
||||
&agent,
|
||||
content.clone(),
|
||||
launch_conversation_id.as_deref(),
|
||||
if defer_custom_resume {
|
||||
None
|
||||
} else {
|
||||
launch_conversation_id.as_deref()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
||||
@ -2731,13 +2748,22 @@ impl LaunchAgent {
|
||||
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
|
||||
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
|
||||
let pair_conversation_id = launch_conversation_id.clone().unwrap_or_else(|| {
|
||||
ConversationId::for_project_pair(
|
||||
input.project.id,
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(agent.id),
|
||||
)
|
||||
.to_string()
|
||||
if input.require_structured {
|
||||
self.ids.new_uuid().to_string()
|
||||
} else {
|
||||
ConversationId::for_project_pair(
|
||||
input.project.id,
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(agent.id),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
});
|
||||
let transcript_restore_expected = if input.require_structured {
|
||||
launch_conversation_id.is_some()
|
||||
} else {
|
||||
transcript_restore_expected(&session_plan)
|
||||
};
|
||||
return self
|
||||
.launch_structured(
|
||||
factory.as_ref(),
|
||||
@ -2752,7 +2778,8 @@ impl LaunchAgent {
|
||||
&input.project.root,
|
||||
input.node_id,
|
||||
size,
|
||||
transcript_restore_expected(&session_plan),
|
||||
transcript_restore_expected,
|
||||
pending_resume_preamble,
|
||||
&spec.env,
|
||||
None,
|
||||
structured_policy.as_ref(),
|
||||
@ -2857,6 +2884,7 @@ impl LaunchAgent {
|
||||
node_id: Option<NodeId>,
|
||||
size: PtySize,
|
||||
transcript_restore_expected: bool,
|
||||
pending_resume_preamble: Option<String>,
|
||||
env: &[(String, String)],
|
||||
sandbox: Option<&SandboxPlan>,
|
||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||
@ -2901,6 +2929,13 @@ impl LaunchAgent {
|
||||
launch_managed_temp.map(|temp| temp.handle),
|
||||
);
|
||||
structured.set_transcript_restore_expected(session_id, transcript_restore_expected);
|
||||
if let Ok(conversation_uuid) = uuid::Uuid::parse_str(&pair_conversation_id) {
|
||||
structured.bind_custom_conversation(
|
||||
session_id,
|
||||
ConversationId::from_uuid(conversation_uuid),
|
||||
pending_resume_preamble,
|
||||
);
|
||||
}
|
||||
|
||||
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
|
||||
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
||||
|
||||
196
crates/application/src/conversation/custom.rs
Normal file
196
crates/application/src/conversation/custom.rs
Normal file
@ -0,0 +1,196 @@
|
||||
//! Custom CLI conversation catalogue and checkpoint orchestration.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
AgentId, ConversationId, ConversationTurn, CustomConversationEntry, CustomConversationIndex,
|
||||
InputSource, ProjectPath, TurnId, TurnRole,
|
||||
};
|
||||
|
||||
use crate::{AppError, RecordTurnProvider};
|
||||
|
||||
/// Default and maximum number of conversation summaries returned to the UI.
|
||||
pub const CUSTOM_CONVERSATION_LIST_LIMIT: usize = 50;
|
||||
/// Maximum title length derived from the first prompt.
|
||||
pub const CUSTOM_CONVERSATION_TITLE_MAX_CHARS: usize = 80;
|
||||
|
||||
/// Resolves a project-scoped custom conversation index.
|
||||
pub trait CustomConversationIndexProvider: Send + Sync {
|
||||
/// Returns the index rooted at `root`.
|
||||
fn custom_conversation_index_for(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
) -> Option<Arc<dyn CustomConversationIndex>>;
|
||||
}
|
||||
|
||||
/// Application service for custom CLI conversation ownership and checkpoints.
|
||||
pub struct CustomConversationService {
|
||||
indexes: Arc<dyn CustomConversationIndexProvider>,
|
||||
records: Arc<dyn RecordTurnProvider>,
|
||||
}
|
||||
|
||||
impl CustomConversationService {
|
||||
/// Builds the service from project-scoped providers.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
indexes: Arc<dyn CustomConversationIndexProvider>,
|
||||
records: Arc<dyn RecordTurnProvider>,
|
||||
) -> Self {
|
||||
Self { indexes, records }
|
||||
}
|
||||
|
||||
/// Lists recent non-empty conversations owned by an agent.
|
||||
pub async fn list(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
agent_id: AgentId,
|
||||
limit: usize,
|
||||
) -> Result<Vec<CustomConversationEntry>, AppError> {
|
||||
let Some(index) = self.indexes.custom_conversation_index_for(root) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let limit = if limit == 0 {
|
||||
CUSTOM_CONVERSATION_LIST_LIMIT
|
||||
} else {
|
||||
limit.min(CUSTOM_CONVERSATION_LIST_LIMIT)
|
||||
};
|
||||
Ok(index.list(agent_id, limit).await?)
|
||||
}
|
||||
|
||||
/// Verifies that the logical conversation exists and belongs to `agent_id`.
|
||||
pub async fn assert_owned(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
agent_id: AgentId,
|
||||
conversation_id: ConversationId,
|
||||
) -> Result<CustomConversationEntry, AppError> {
|
||||
let index = self
|
||||
.indexes
|
||||
.custom_conversation_index_for(root)
|
||||
.ok_or_else(|| AppError::NotFound(format!("custom conversation {conversation_id}")))?;
|
||||
let entry = index
|
||||
.get(conversation_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("custom conversation {conversation_id}")))?;
|
||||
if entry.agent_id != agent_id {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"conversation {conversation_id} is not owned by agent {agent_id}"
|
||||
)));
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Persists an accepted user prompt, materializing the catalogue entry only now.
|
||||
pub async fn record_prompt(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
agent_id: AgentId,
|
||||
conversation_id: ConversationId,
|
||||
at_ms: u64,
|
||||
text: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let index = self
|
||||
.indexes
|
||||
.custom_conversation_index_for(root)
|
||||
.ok_or_else(|| AppError::NotFound("custom conversation index provider".to_owned()))?;
|
||||
let existing = index.get(conversation_id).await?;
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.agent_id != agent_id)
|
||||
{
|
||||
return Err(AppError::Invalid(format!(
|
||||
"conversation {conversation_id} is not owned by agent {agent_id}"
|
||||
)));
|
||||
}
|
||||
self.record(root, conversation_id, at_ms, TurnRole::Prompt, text)
|
||||
.await?;
|
||||
let created_at_ms = existing.as_ref().map_or(at_ms, |entry| entry.created_at_ms);
|
||||
let title = existing
|
||||
.as_ref()
|
||||
.map(|entry| entry.title.clone())
|
||||
.unwrap_or_else(|| conversation_title(text));
|
||||
index
|
||||
.upsert(CustomConversationEntry {
|
||||
conversation_id,
|
||||
agent_id,
|
||||
title,
|
||||
created_at_ms,
|
||||
updated_at_ms: at_ms,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists a final assistant response and advances last activity.
|
||||
pub async fn record_response(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
agent_id: AgentId,
|
||||
conversation_id: ConversationId,
|
||||
at_ms: u64,
|
||||
text: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let entry = self.assert_owned(root, agent_id, conversation_id).await?;
|
||||
self.record(root, conversation_id, at_ms, TurnRole::Response, text)
|
||||
.await?;
|
||||
let index = self
|
||||
.indexes
|
||||
.custom_conversation_index_for(root)
|
||||
.ok_or_else(|| AppError::NotFound("custom conversation index provider".to_owned()))?;
|
||||
index
|
||||
.upsert(CustomConversationEntry {
|
||||
updated_at_ms: at_ms,
|
||||
..entry
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
conversation_id: ConversationId,
|
||||
at_ms: u64,
|
||||
role: TurnRole,
|
||||
text: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let record = self
|
||||
.records
|
||||
.record_turn_for(root)
|
||||
.ok_or_else(|| AppError::NotFound("conversation recorder".to_owned()))?;
|
||||
record
|
||||
.record(
|
||||
conversation_id,
|
||||
ConversationTurn::new(
|
||||
TurnId::new_random(),
|
||||
conversation_id,
|
||||
at_ms,
|
||||
InputSource::Human,
|
||||
role,
|
||||
text,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_title(prompt: &str) -> String {
|
||||
let compact = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
compact
|
||||
.chars()
|
||||
.take(CUSTOM_CONVERSATION_TITLE_MAX_CHARS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_is_compact_and_bounded() {
|
||||
let input = format!(" hello\n\tworld {}", "x".repeat(100));
|
||||
let title = conversation_title(&input);
|
||||
assert!(title.starts_with("hello world "));
|
||||
assert_eq!(title.chars().count(), CUSTOM_CONVERSATION_TITLE_MAX_CHARS);
|
||||
}
|
||||
}
|
||||
@ -13,10 +13,16 @@
|
||||
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
|
||||
//! of the three ports.
|
||||
|
||||
mod custom;
|
||||
mod paginate;
|
||||
mod record;
|
||||
mod rotate;
|
||||
|
||||
pub use custom::{
|
||||
CustomConversationIndexProvider, CustomConversationService, CUSTOM_CONVERSATION_LIST_LIMIT,
|
||||
CUSTOM_CONVERSATION_TITLE_MAX_CHARS,
|
||||
};
|
||||
|
||||
pub use paginate::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, TurnPage,
|
||||
TurnSource, TurnView,
|
||||
|
||||
@ -90,8 +90,10 @@ pub use chat_drafts::{
|
||||
SaveChatDraftInput,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
ConversationArchiveProvider, CustomConversationIndexProvider, CustomConversationService,
|
||||
ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog,
|
||||
RotateConversationLogInput, TurnPage, TurnSource, TurnView, CUSTOM_CONVERSATION_LIST_LIMIT,
|
||||
CUSTOM_CONVERSATION_TITLE_MAX_CHARS,
|
||||
};
|
||||
pub use device::{
|
||||
normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput,
|
||||
|
||||
@ -536,6 +536,10 @@ struct StructuredEntry {
|
||||
/// The launch resumed a logical conversation already linked to the cell.
|
||||
/// A fresh in-memory chat bridge therefore cannot provide its transcript.
|
||||
transcript_restore_expected: bool,
|
||||
/// Logical custom CLI conversation, distinct from the provider session id.
|
||||
custom_conversation_id: Option<ConversationId>,
|
||||
/// Deferred bounded handoff injected into the first post-resume user prompt.
|
||||
pending_resume_preamble: Option<String>,
|
||||
/// Launch-scoped managed-temp handle retained for release.
|
||||
managed_temp_handle: Option<String>,
|
||||
/// Optional local-model-server usage guard retained for the live session.
|
||||
@ -674,6 +678,8 @@ impl StructuredSessions {
|
||||
agent_id,
|
||||
node_id,
|
||||
transcript_restore_expected: false,
|
||||
custom_conversation_id: None,
|
||||
pending_resume_preamble: None,
|
||||
managed_temp_handle,
|
||||
_model_server_guard: model_server_guard,
|
||||
turn_generation: 0,
|
||||
@ -834,6 +840,40 @@ impl StructuredSessions {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Binds a logical custom conversation and an optional one-shot resume preamble.
|
||||
pub fn bind_custom_conversation(
|
||||
&self,
|
||||
id: SessionId,
|
||||
conversation_id: ConversationId,
|
||||
pending_resume_preamble: Option<String>,
|
||||
) {
|
||||
if let Ok(mut entries) = self.entries.lock() {
|
||||
if let Some(entry) = entries.get_mut(&id) {
|
||||
entry.custom_conversation_id = Some(conversation_id);
|
||||
entry.pending_resume_preamble = pending_resume_preamble;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the logical custom conversation bound to a live session.
|
||||
#[must_use]
|
||||
pub fn custom_conversation_id(&self, id: &SessionId) -> Option<ConversationId> {
|
||||
self.entries.lock().ok().and_then(|entries| {
|
||||
entries
|
||||
.get(id)
|
||||
.and_then(|entry| entry.custom_conversation_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes the deferred resume preamble. It can therefore be injected at most once.
|
||||
pub fn take_pending_resume_preamble(&self, id: &SessionId) -> Option<String> {
|
||||
self.entries.lock().ok().and_then(|mut entries| {
|
||||
entries
|
||||
.get_mut(id)
|
||||
.and_then(|entry| entry.pending_resume_preamble.take())
|
||||
})
|
||||
}
|
||||
|
||||
/// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`.
|
||||
///
|
||||
/// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer
|
||||
|
||||
@ -74,6 +74,40 @@ fn id<T>(value: u128, make: impl FnOnce(Uuid) -> T) -> T {
|
||||
make(Uuid::from_u128(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_conversation_binding_keeps_logical_id_and_consumes_resume_once() {
|
||||
let session_id = id(1, SessionId::from_uuid);
|
||||
let project_id = id(2, ProjectId::from_uuid);
|
||||
let agent_id = id(3, AgentId::from_uuid);
|
||||
let node_id = id(4, NodeId::from_uuid);
|
||||
let conversation_id = domain::ConversationId::from_uuid(Uuid::from_u128(5));
|
||||
let sessions = StructuredSessions::new();
|
||||
sessions.insert_in_project(
|
||||
project_id,
|
||||
Arc::new(FakeSession::new(session_id, [])),
|
||||
agent_id,
|
||||
node_id,
|
||||
);
|
||||
|
||||
sessions.bind_custom_conversation(
|
||||
session_id,
|
||||
conversation_id,
|
||||
Some("bounded handoff".to_owned()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sessions.custom_conversation_id(&session_id),
|
||||
Some(conversation_id)
|
||||
);
|
||||
assert_eq!(
|
||||
sessions
|
||||
.take_pending_resume_preamble(&session_id)
|
||||
.as_deref(),
|
||||
Some("bounded handoff")
|
||||
);
|
||||
assert_eq!(sessions.take_pending_resume_preamble(&session_id), None);
|
||||
}
|
||||
|
||||
struct TurnAccounting {
|
||||
state: Mutex<AgentBusyState>,
|
||||
cancelled: Mutex<Vec<(RuntimeAgentKey, TicketId)>>,
|
||||
|
||||
@ -918,6 +918,44 @@ async fn global_idea_cli_preference_applies_to_a_new_human_launch() {
|
||||
assert_eq!(f.pty.spawn_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_custom_chat_gets_a_fresh_logical_conversation_without_persisting_it() {
|
||||
let f = launch_fixture(
|
||||
structured_profile(pid(9)),
|
||||
FakeFactory::new(500, Some("engine-conv")),
|
||||
);
|
||||
let mut input = launch_input(f.agent.id);
|
||||
input.require_structured = true;
|
||||
|
||||
let output = f.launch.execute(input).await.unwrap();
|
||||
let assigned = output
|
||||
.assigned_conversation_id
|
||||
.as_deref()
|
||||
.and_then(|raw| Uuid::parse_str(raw).ok())
|
||||
.map(ConversationId::from_uuid)
|
||||
.expect("fresh logical conversation id");
|
||||
|
||||
assert_eq!(assigned, ConversationId::from_uuid(Uuid::from_u128(1)));
|
||||
assert_ne!(
|
||||
assigned,
|
||||
ConversationId::for_project_pair(
|
||||
project_id(),
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(f.agent.id),
|
||||
),
|
||||
"each explicit custom chat is a distinct logical thread"
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.custom_conversation_id(&sid(500)),
|
||||
Some(assigned)
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.take_pending_resume_preamble(&sid(500)),
|
||||
None,
|
||||
"a fresh chat has no resume context"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_native_tui_overrides_the_global_idea_cli_preference() {
|
||||
let f = launch_fixture_with_preference(DefaultAgentTerminalInterface::IdeaCli);
|
||||
|
||||
Reference in New Issue
Block a user