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

@ -54,17 +54,18 @@ use crate::dto::{
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, CustomProjectIconAssetContentDto, CustomProjectIconAssetDto,
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
ImportChatAttachmentsResponseDto, ImportCustomProjectIconAssetRequestDto,
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
CreateTemplateRequestDto, CustomConversationListDto, CustomConversationSummaryDto,
CustomProjectIconAssetContentDto, CustomProjectIconAssetDto, CustomProjectIconUsageDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto,
ListCustomConversationsRequestDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
@ -192,15 +193,94 @@ pub async fn execute_slash_command(
let effect = match output.effect {
SlashCommandEffect::Help { commands } => SlashCommandEffectDto::Help { commands },
SlashCommandEffect::CleanConversation { session_id } => {
if state.structured_sessions.session(&session_id).is_none() {
return Err(ErrorDto::from(AppError::NotFound(format!(
"structured session {session_id}"
))));
}
let cleared_chunks = state.chat_bridge.clear_scrollback(&session_id);
let (project_id, agent_id, node_id, _) = state
.structured_sessions
.meta_for_session(&session_id)
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"structured session {session_id}"
)))
})?;
let runtime_key = domain::RuntimeAgentKey::new(project_id, agent_id);
let context = state
.resume_contexts
.lock()
.ok()
.and_then(|contexts| contexts.get(&runtime_key).cloned())
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"launch context for structured session {session_id}"
)))
})?;
let removed = state
.structured_sessions
.remove_runtime(&session_id)
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!(
"structured session {session_id}"
)))
})?;
removed
.session
.shutdown()
.await
.map_err(|error| ErrorDto::from(AppError::Process(error.to_string())))?;
if let Some(handle) = removed.managed_temp_handle {
let _ = state
.release_managed_temp
.execute(application::ReleaseManagedTempInput { handle })
.await;
}
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project_id)
.as_cli_arg()
.to_owned(),
project_id: project_id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
});
let relaunched = state
.launch_agent
.execute(LaunchAgentInput {
project: context.project.clone(),
agent_id,
rows: context.rows,
cols: context.cols,
node_id: Some(node_id),
conversation_id: None,
mcp_runtime,
allow_structured_alongside_pty: false,
require_structured: true,
terminal_interface: Some(domain::DefaultAgentTerminalInterface::IdeaCli),
fresh_start: true,
managed_temp_exposure: application::ManagedTempExposureRequest::default(),
})
.await
.map_err(ErrorDto::from)?;
let replacement = relaunched
.structured
.as_ref()
.ok_or_else(|| {
ErrorDto::from(AppError::Process(
"/clean did not relaunch a structured session".to_owned(),
))
})?
.session_id;
state.chat_bridge.rebind_session(session_id, replacement);
if let Ok(mut contexts) = state.resume_contexts.lock() {
contexts.insert(
runtime_key,
crate::state::ResumeContext {
conversation_id: relaunched.assigned_conversation_id.clone(),
..context
},
);
}
SlashCommandEffectDto::CleanConversation {
session_id: session_id.to_string(),
session_id: replacement.to_string(),
cleared_chunks,
conversation_id: relaunched.assigned_conversation_id,
}
}
SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch {
@ -2264,6 +2344,25 @@ pub async fn read_conversation_page(
.map_err(ErrorDto::from)
}
/// Lists durable non-empty custom CLI conversations owned by an agent.
#[tauri::command]
pub async fn list_custom_agent_conversations(
request: ListCustomConversationsRequestDto,
state: State<'_, AppState>,
) -> Result<CustomConversationListDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let conversations = state
.custom_conversations
.list(&project.root, agent_id, request.limit.unwrap_or(0))
.await
.map_err(ErrorDto::from)?
.into_iter()
.map(CustomConversationSummaryDto::from)
.collect();
Ok(CustomConversationListDto { conversations })
}
/// `list_live_agents` — list every agent that currently owns a live session
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
/// disable an agent already running in another cell (the "one live session per
@ -2509,6 +2608,39 @@ pub async fn launch_agent(
let project = resolve_project(&request.project_id, &state).await?;
let project_id = project.id;
let agent_id = parse_agent_id(&request.agent_id)?;
let resumed_page = if request.cell_kind == Some(CellKind::Chat) {
if let Some(raw) = request.conversation_id.as_deref() {
let conversation_id = uuid::Uuid::parse_str(raw)
.map(domain::ConversationId::from_uuid)
.map_err(|_| {
ErrorDto::from(AppError::Invalid(format!("invalid conversation id: {raw}")))
})?;
state
.custom_conversations
.assert_owned(&project.root, agent_id, conversation_id)
.await
.map_err(ErrorDto::from)?;
Some(
state
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root.clone(),
conversation: conversation_id,
cursor: domain::PageCursor {
anchor: None,
direction: domain::PageDirection::Backward,
},
limit: 50,
})
.await
.map_err(ErrorDto::from)?,
)
} else {
None
}
} else {
None
};
// The hosting cell drives the singleton-invariant guard. Parse it when the
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
// already-live agent is refused).
@ -2708,7 +2840,24 @@ pub async fn launch_agent(
}
}
Ok(TerminalSessionDto::from(output))
let mut response = TerminalSessionDto::from(output);
if let Some(page) = resumed_page {
response.history = page
.turns
.into_iter()
.filter(|turn| {
matches!(
turn.role,
domain::TurnRole::Prompt | domain::TurnRole::Response
)
})
.map(crate::dto::TurnViewDto::from)
.collect();
response.history_has_more = page.has_more;
response.history_next_anchor = page.next_anchor.map(|id| id.to_string());
response.resumed = true;
}
Ok(response)
}
/// `change_agent_profile` — hot-swap an agent's runtime profile (§15.1).
@ -2968,13 +3117,48 @@ pub async fn agent_send(
}
};
let attachment_refs = chat_prompt_attachment_refs(&imported_attachments);
let prompt_for_model =
let mut prompt_for_model =
prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs);
let attachment_labels = imported_attachments
.iter()
.map(|attachment| attachment.filename.clone())
.collect();
let session_meta = state.structured_sessions.meta_for_session(&sid);
let custom_context = match (
session_meta.as_ref(),
state.structured_sessions.custom_conversation_id(&sid),
) {
(Some((project_id, agent_id, _, _)), Some(conversation_id)) => {
let project = state
.project_store
.list_projects()
.await
.map_err(|error| ErrorDto::from(AppError::Store(error.to_string())))?
.into_iter()
.find(|project| project.id == *project_id)
.ok_or_else(|| {
ErrorDto::from(AppError::NotFound(format!("project {project_id}")))
})?;
Some((project.root, *agent_id, conversation_id))
}
_ => None,
};
if let Some((root, agent_id, conversation_id)) = &custom_context {
let at_ms = u64::try_from(state.clock.now_millis()).unwrap_or_default();
if let Err(error) = state
.custom_conversations
.record_prompt(root, *agent_id, *conversation_id, at_ms, &prompt)
.await
{
state.structured_sessions.finish_turn(&sid, turn_generation);
return Err(ErrorDto::from(error));
}
if let Some(summary) = state.structured_sessions.take_pending_resume_preamble(&sid) {
prompt_for_model = format!(
"Contexte borné de la conversation reprise :\n\n{summary}\n\n---\n\nNouveau message utilisateur :\n{prompt_for_model}"
);
}
}
if let Some((project_id, agent_id, _, _)) = &session_meta {
state.chat_bridge.bind_owner(sid, *project_id, *agent_id);
}
@ -3109,6 +3293,7 @@ pub async fn agent_send(
let structured = std::sync::Arc::clone(&state.structured_sessions);
let runtime = tokio::runtime::Handle::current();
let meta = session_meta;
let custom_conversations = std::sync::Arc::clone(&state.custom_conversations);
std::thread::spawn(move || {
let mut terminal_visible = false;
let mut settler = crate::chat::ProgressSettler::default();
@ -3123,6 +3308,17 @@ pub async fn agent_send(
break 'logical_turn;
}
let provider_terminal = crate::chat::reply_event_ends_turn(&event);
if let domain::ports::ReplyEvent::Final { content } = &event {
if let Some((root, agent_id, conversation_id)) = &custom_context {
let _ = runtime.block_on(custom_conversations.record_response(
root,
*agent_id,
*conversation_id,
u64::try_from(clock.now_millis()).unwrap_or_default(),
content,
));
}
}
if provider_terminal {
concrete_terminal = true;
if let Some(guard) = turn_guard.as_mut() {

View File

@ -353,6 +353,7 @@ pub fn run() {
tickets::ticket_unassign_sprint,
commands::get_project_work_state,
commands::read_conversation_page,
commands::list_custom_agent_conversations,
commands::list_live_agents,
commands::attach_live_agent,
commands::stop_live_agent,