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