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

@ -86,16 +86,18 @@ use backend::dto::{
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto,
DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto,
EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto,
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
CustomConversationListDto, CustomConversationSummaryDto, CustomProjectIconAssetDto,
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto,
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
LayoutDto, LayoutOperationDto, ListCustomConversationsRequestDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
@ -1585,13 +1587,32 @@ async fn ws_launch_agent(
send_launch_agent_attached(frame, state, tx, owned, output).await
}
struct WsLaunchOutput {
launch: application::LaunchAgentOutput,
history: Option<application::TurnPage>,
}
impl From<application::LaunchAgentOutput> for WsLaunchOutput {
fn from(launch: application::LaunchAgentOutput) -> Self {
Self {
launch,
history: None,
}
}
}
async fn send_launch_agent_attached(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
output: application::LaunchAgentOutput,
output: impl Into<WsLaunchOutput>,
) -> Result<(), ErrorDto> {
let output = output.into();
let WsLaunchOutput {
launch: output,
history,
} = output;
if let Some(structured) = output.structured.as_ref() {
let transcript_unavailable_reason =
ConversationTranscriptUnavailableReasonDto::for_scrollback(
@ -1608,6 +1629,7 @@ async fn send_launch_agent_attached(
output.engine_session_id.as_deref(),
Vec::new(),
transcript_unavailable_reason,
history,
))
.await
.map_err(|_| ErrorDto {
@ -1632,11 +1654,48 @@ async fn send_launch_agent_attached(
async fn execute_launch_agent_for_ws(
state: &Arc<ServerState>,
request: LaunchAgentRequestDto,
) -> Result<application::LaunchAgentOutput, ErrorDto> {
) -> Result<WsLaunchOutput, ErrorDto> {
let project = resolve_project_readonly(&request.project_id, &state.app).await?;
let project_id = project.id;
let agent_id = parse_agent_id(&request.agent_id)?;
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
let history = if request.cell_kind == Some(backend::dto::CellKind::Chat) {
if let Some(raw) = request.conversation_id.as_deref() {
let conversation = Uuid::parse_str(raw)
.map(domain::ConversationId::from_uuid)
.map_err(|_| {
ErrorDto::from(application::AppError::Invalid(format!(
"invalid conversation id: {raw}"
)))
})?;
state
.app
.custom_conversations
.assert_owned(&project.root, agent_id, conversation)
.await
.map_err(ErrorDto::from)?;
Some(
state
.app
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root.clone(),
conversation,
cursor: domain::PageCursor {
anchor: None,
direction: domain::PageDirection::Backward,
},
limit: 50,
})
.await
.map_err(ErrorDto::from)?,
)
} else {
None
}
} else {
None
};
let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: backend::mcp_endpoint::mcp_endpoint(&project.id)
@ -1722,7 +1781,10 @@ async fn execute_launch_agent_for_ws(
});
}
Ok(output)
Ok(WsLaunchOutput {
launch: output,
history,
})
}
async fn ws_attach_terminal(
@ -1749,6 +1811,10 @@ async fn ws_attach_terminal(
assigned_conversation_id: None,
engine_session_id: None,
cell_kind: backend::dto::CellKind::Pty,
history: Vec::new(),
history_has_more: false,
history_next_anchor: None,
resumed: false,
};
let next_seq = u64::from(!scrollback.is_empty());
tx.send(ServerFrame::attached(
@ -1917,6 +1983,7 @@ async fn ws_attach_chat(
session.conversation_id().as_deref(),
Vec::new(),
transcript_unavailable_reason,
None,
))
.await
.map_err(|_| ErrorDto {
@ -1949,6 +2016,52 @@ async fn ws_send_chat(
message: "structured chat session not found".to_owned(),
})?;
let session_meta = state.app.structured_sessions.meta_for_session(&sid);
let custom_context = match (
session_meta.as_ref(),
state.app.structured_sessions.custom_conversation_id(&sid),
) {
(Some((project_id, agent_id, _, _)), Some(conversation_id)) => {
let project = state
.app
.project_store
.list_projects()
.await
.map_err(|error| ErrorDto::from(application::AppError::Store(error.to_string())))?
.into_iter()
.find(|project| project.id == *project_id)
.ok_or_else(|| {
ErrorDto::from(application::AppError::NotFound(format!(
"project {project_id}"
)))
})?;
Some((project.root, *agent_id, conversation_id))
}
_ => None,
};
let mut provider_prompt = prompt.clone();
if let Some((root, agent_id, conversation_id)) = &custom_context {
state
.app
.custom_conversations
.record_prompt(
root,
*agent_id,
*conversation_id,
u64::try_from(state.app.clock.now_millis()).unwrap_or_default(),
&prompt,
)
.await
.map_err(ErrorDto::from)?;
if let Some(summary) = state
.app
.structured_sessions
.take_pending_resume_preamble(&sid)
{
provider_prompt = format!(
"Contexte borné de la conversation reprise :\n\n{summary}\n\n---\n\nNouveau message utilisateur :\n{prompt}"
);
}
}
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
backend::StructuredHumanTurnGuard::start_with_source(
Arc::clone(&state.app.turn_watch_input),
@ -1984,7 +2097,7 @@ async fn ws_send_chat(
}
}
});
let stream_result = session.send_with_tap(&prompt, tap_tx).await;
let stream_result = session.send_with_tap(&provider_prompt, tap_tx).await;
let _ = tap_pump.join();
let mut turn_guard = turn_guard;
let mut stream = match stream_result {
@ -2010,6 +2123,21 @@ async fn ws_send_chat(
let mut resume = false;
let mut resume_prompt = None;
for event in stream {
if let ReplyEvent::Final { content } = &event {
if let Some((root, agent_id, conversation_id)) = &custom_context {
let _ = state
.app
.custom_conversations
.record_response(
root,
*agent_id,
*conversation_id,
u64::try_from(state.app.clock.now_millis()).unwrap_or_default(),
content,
)
.await;
}
}
let provider_terminal =
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
if provider_terminal {
@ -3699,6 +3827,9 @@ async fn invoke(
"git_init" => invoke_git_init(&request.args, &state.app).await,
"git_graph" => invoke_git_graph(&request.args, &state.app).await,
"read_conversation_page" => invoke_read_conversation_page(&request.args, &state.app).await,
"list_custom_agent_conversations" => {
invoke_list_custom_agent_conversations(&request.args, &state.app).await
}
"get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await,
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
@ -5863,6 +5994,27 @@ async fn invoke_read_conversation_page(
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_custom_agent_conversations(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<ListCustomConversationsRequestDto>(
"list_custom_agent_conversations",
args,
)?;
let project = resolve_project_readonly(&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();
serde_json::to_value(CustomConversationListDto { conversations }).map_err(serialization_error)
}
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
args.get("taskId")
.and_then(Value::as_str)
@ -6447,7 +6599,18 @@ impl ServerFrame {
engine_session_id: Option<&str>,
scrollback: Vec<ChatChunk>,
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
history: Option<application::TurnPage>,
) -> Self {
let history = history.map(|mut page| {
page.turns.retain(|turn| {
matches!(
turn.role,
domain::TurnRole::Prompt | domain::TurnRole::Response
)
});
TurnPageDto::from(page)
});
let resumed = history.is_some();
Self {
kind: "chat.attached".to_owned(),
reply_to: Some(request_id.to_owned()),
@ -6462,6 +6625,8 @@ impl ServerFrame {
"engineSessionId": engine_session_id,
"scrollback": scrollback,
"transcriptUnavailableReason": transcript_unavailable_reason,
"history": history,
"resumed": resumed,
}),
}
}
@ -7946,6 +8111,10 @@ mod tests {
assigned_conversation_id: None,
engine_session_id: None,
cell_kind: backend::dto::CellKind::Pty,
history: Vec::new(),
history_has_more: false,
history_next_anchor: None,
resumed: false,
},
vec![4, 5, 6],
1,