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:
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user