From 943c4525d77aceafda73ce1e3a8a2f6d0f35f4fe Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 16 Sep 2026 23:20:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(domain,application,infrastructure,backend,?= =?UTF-8?q?web-server,app-tauri,frontend):=20persistance=20et=20reprise=20?= =?UTF-8?q?des=20conversations=20de=20CLI=20custom=20par=20agent=20?= =?UTF-8?q?=E2=80=94=20index=20durable=20/.ideai/conversations/cus?= =?UTF-8?q?tom-index.json=20born=C3=A9=20aux=20conversations=20non=20vides?= =?UTF-8?q?=20par=20agent=20(conversation=5Fid=20logique,=20titre=20d?= =?UTF-8?q?=C3=A9riv=C3=A9=20de=20la=20premi=C3=A8re=20invite,=20created/u?= =?UTF-8?q?pdated=5Fat=5Fms),=20port=20CustomConversationIndex=20+=20adapt?= =?UTF-8?q?ateur=20FsCustomConversationIndex,=20persistence=20des=20messag?= =?UTF-8?q?es=20utilisateur=20et=20finals=20agent=20dans=20le=20log=20de?= =?UTF-8?q?=20conversation=20existant,=20DTO/commandes=20app-tauri=20et=20?= =?UTF-8?q?routes=20web-server=20pour=20lister/rouvrir,=20CustomAgentChatV?= =?UTF-8?q?iew:=20ouverture=20par=20d=C3=A9faut=20sur=20une=20nouvelle=20c?= =?UTF-8?q?onversation,=20menu=20reprise=20d'une=20conversation=20pass?= =?UTF-8?q?=C3=A9e=20ou=20nouvelle=20conversation,=20replay=20de=20l'histo?= =?UTF-8?q?rique=20sauvegard=C3=A9=20(user+finals)=20=C3=A0=20la=20r=C3=A9?= =?UTF-8?q?ouverture,=20affichage=20normal=20des=20nouveaux=20messages=20a?= =?UTF-8?q?pr=C3=A8s=20reprise,=20/clean=20ouvre=20une=20nouvelle=20conver?= =?UTF-8?q?sation,=20aucune=20entr=C3=A9e=20cr=C3=A9=C3=A9e=20si=20la=20co?= =?UTF-8?q?nversation=20courante=20est=20vide,=20injection=20de=20l'histor?= =?UTF-8?q?ique=20dans=20le=20contexte=20agent=20diff=C3=A9r=C3=A9e=20au?= =?UTF-8?q?=20premier=20nouveau=20message=20apr=C3=A8s=20reprise=20?= =?UTF-8?q?=E2=80=94=20#363=20(QA=20verte:=20git=20diff=20--check,=20cargo?= =?UTF-8?q?=20fmt=20--all=20--=20--check,=20cargo=20tests=20cibl=C3=A9s=20?= =?UTF-8?q?infrastructure/application/web-server/app-tauri/backend,=20npm?= =?UTF-8?q?=20run=20typecheck,=20tests=20frontend=20cibl=C3=A9s=20et=20Cus?= =?UTF-8?q?tomAgentChatView,=20npm=20run=20build)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/app-tauri/src/commands.rs | 234 +++++++++++- crates/app-tauri/src/lib.rs | 1 + crates/application/src/agent/lifecycle.rs | 51 ++- crates/application/src/conversation/custom.rs | 196 ++++++++++ crates/application/src/conversation/mod.rs | 6 + crates/application/src/lib.rs | 6 +- crates/application/src/terminal/registry.rs | 40 ++ crates/application/tests/cancel_agent_chat.rs | 34 ++ .../application/tests/structured_launch_d3.rs | 38 ++ crates/backend/src/dto.rs | 74 ++++ crates/backend/src/lib.rs | 74 ++-- crates/domain/src/custom_conversation.rs | 43 +++ crates/domain/src/lib.rs | 2 + .../src/conversation_log/custom_index.rs | 124 +++++++ .../src/conversation_log/mod.rs | 2 + crates/infrastructure/src/lib.rs | 4 +- .../infrastructure/tests/conversation_log.rs | 161 +++++++- crates/web-server/src/lib.rs | 195 +++++++++- frontend/src/adapters/agent.test.ts | 32 ++ frontend/src/adapters/agent.ts | 25 ++ frontend/src/adapters/http/frames.ts | 6 +- frontend/src/adapters/http/streamGateways.ts | 22 ++ frontend/src/adapters/mock/index.ts | 10 + frontend/src/domain/index.ts | 7 +- .../agents/CustomAgentChatView.test.tsx | 118 +++++- .../features/agents/CustomAgentChatView.tsx | 348 ++++++++++++++++-- .../features/layout/LayoutGrid.chat.test.tsx | 28 +- frontend/src/features/layout/LayoutGrid.tsx | 1 + frontend/src/features/web/WebAgentCell.tsx | 1 + frontend/src/ports/index.ts | 20 + 30 files changed, 1794 insertions(+), 109 deletions(-) create mode 100644 crates/application/src/conversation/custom.rs create mode 100644 crates/domain/src/custom_conversation.rs create mode 100644 crates/infrastructure/src/conversation_log/custom_index.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d829e4b8..b44995dd 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { + 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() { diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 482c013b..62bd48f7 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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, diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 47798a37..98ff6d26 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -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, size: PtySize, transcript_restore_expected: bool, + pending_resume_preamble: Option, 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 diff --git a/crates/application/src/conversation/custom.rs b/crates/application/src/conversation/custom.rs new file mode 100644 index 00000000..7b4e4d69 --- /dev/null +++ b/crates/application/src/conversation/custom.rs @@ -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>; +} + +/// Application service for custom CLI conversation ownership and checkpoints. +pub struct CustomConversationService { + indexes: Arc, + records: Arc, +} + +impl CustomConversationService { + /// Builds the service from project-scoped providers. + #[must_use] + pub fn new( + indexes: Arc, + records: Arc, + ) -> 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, 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 { + 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::>().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); + } +} diff --git a/crates/application/src/conversation/mod.rs b/crates/application/src/conversation/mod.rs index fe4fab25..a7ab2329 100644 --- a/crates/application/src/conversation/mod.rs +++ b/crates/application/src/conversation/mod.rs @@ -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, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 94e8bb9a..0fdc7458 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 7ac2f91c..14238179 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -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, + /// Deferred bounded handoff injected into the first post-resume user prompt. + pending_resume_preamble: Option, /// Launch-scoped managed-temp handle retained for release. managed_temp_handle: Option, /// 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, + ) { + 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 { + 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 { + 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 diff --git a/crates/application/tests/cancel_agent_chat.rs b/crates/application/tests/cancel_agent_chat.rs index 8cc04a31..62bbafcc 100644 --- a/crates/application/tests/cancel_agent_chat.rs +++ b/crates/application/tests/cancel_agent_chat.rs @@ -74,6 +74,40 @@ fn id(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, cancelled: Mutex>, diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 60abc8d8..b2696a54 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -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); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 6ee90a31..46c42377 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1054,6 +1054,9 @@ pub enum SlashCommandEffectDto { /// Number of retained chunks removed by the backend bridge. #[serde(rename = "clearedChunks")] cleared_chunks: usize, + /// Fresh ephemeral logical conversation id; it is indexed only on first send. + #[serde(rename = "conversationId", skip_serializing_if = "Option::is_none")] + conversation_id: Option, }, /// Placeholder for ticket #164. ProfileSwitch { @@ -1085,6 +1088,7 @@ impl From for SlashCommandEffectDto { SlashCommandEffect::CleanConversation { session_id } => Self::CleanConversation { session_id: session_id.to_string(), cleared_chunks: 0, + conversation_id: None, }, SlashCommandEffect::ProfileSwitch { session_id } => Self::ProfileSwitch { session_id: session_id.to_string(), @@ -1444,6 +1448,18 @@ pub struct TerminalSessionDto { /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO /// always serialises with `cellKind: "pty"`). pub cell_kind: CellKind, + /// Durable human-visible history loaded for an explicitly resumed custom chat. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub history: Vec, + /// Whether older history pages exist before `history`. + #[serde(default)] + pub history_has_more: bool, + /// Anchor for the next older history page. + #[serde(skip_serializing_if = "Option::is_none")] + pub history_next_anchor: Option, + /// True when this launch explicitly reopened an indexed custom conversation. + #[serde(default)] + pub resumed: bool, } /// Whether a session's hosting cell renders as a structured chat view or a raw @@ -1469,6 +1485,10 @@ impl From for TerminalSessionDto { assigned_conversation_id: None, engine_session_id: None, cell_kind: CellKind::Pty, + history: Vec::new(), + history_has_more: false, + history_next_anchor: None, + resumed: false, } } } @@ -3569,10 +3589,60 @@ impl From for TerminalSessionDto { assigned_conversation_id, engine_session_id, cell_kind, + history: Vec::new(), + history_has_more: false, + history_next_anchor: None, + resumed: false, } } } +/// Request for the durable custom CLI conversation menu. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCustomConversationsRequestDto { + /// Owning project. + pub project_id: String, + /// Owning agent. + pub agent_id: String, + /// Requested maximum, defaulting and clamped to 50. + #[serde(default)] + pub limit: Option, +} + +/// One conversation menu row. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomConversationSummaryDto { + /// Logical conversation id. + pub conversation_id: String, + /// First-prompt title. + pub title: String, + /// Creation timestamp. + pub created_at_ms: u64, + /// Last durable activity timestamp. + pub updated_at_ms: u64, +} + +impl From for CustomConversationSummaryDto { + fn from(entry: domain::CustomConversationEntry) -> Self { + Self { + conversation_id: entry.conversation_id.to_string(), + title: entry.title, + created_at_ms: entry.created_at_ms, + updated_at_ms: entry.updated_at_ms, + } + } +} + +/// Recent custom CLI conversations for one agent. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomConversationListDto { + /// Newest-first, non-empty rows. + pub conversations: Vec, +} + /// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The /// frontend sends `{ request: { projectId, agentId } }`. #[derive(Debug, Clone, Deserialize)] @@ -3708,6 +3778,10 @@ impl From for TerminalSessionDto { assigned_conversation_id: None, engine_session_id: None, cell_kind: CellKind::Pty, + history: Vec::new(), + history_has_more: false, + history_next_anchor: None, + resumed: false, } } } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 2f3bf787..cb223815 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -21,15 +21,15 @@ use application::{ ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory, - CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, - DeleteCustomProjectIconAsset, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, - DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, - DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, - DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState, - GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage, GetLiveStateLean, - GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, - GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, - GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, + CreateProject, CreateSkill, CreateSprint, CreateTemplate, CustomConversationIndexProvider, + CustomConversationService, DeleteAgent, DeleteCustomProjectIconAsset, DeleteEmbedderProfile, + DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile, + DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, + DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, + FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage, + GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, + GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, ImportCustomProjectIconAsset, InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, @@ -107,20 +107,20 @@ use infrastructure::{ CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, - FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomProjectIconAssetStore, - FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, - FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore, - FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, - FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, - FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, - FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore, - FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, - FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, - HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, - IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, - LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, - LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, - MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, + FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomConversationIndex, + FsCustomProjectIconAssetStore, FsDeviceSessionStore, FsEmbedderProfileStore, + FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, + FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, + FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, + FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, + FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, + FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, + FsUserSettingsStore, FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, + HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, + HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, + LocalManagedProcess, LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider, + McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, @@ -496,6 +496,26 @@ impl RecordTurnProvider for AppRecordTurnProvider { } } +/// Resolves the durable custom CLI conversation catalogue for a project root. +#[derive(Default)] +struct AppCustomConversationIndexProvider { + indexes: Mutex>>, +} + +impl CustomConversationIndexProvider for AppCustomConversationIndexProvider { + fn custom_conversation_index_for( + &self, + root: &domain::project::ProjectPath, + ) -> Option> { + let mut indexes = self.indexes.lock().ok()?; + let index = indexes + .entry(root.as_str().to_owned()) + .or_insert_with(|| Arc::new(FsCustomConversationIndex::new(root))) + .clone(); + Some(index as Arc) + } +} + /// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider) /// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi /// [`domain::ConversationArchive`]) ciblant le **project root** courant. @@ -2317,6 +2337,8 @@ pub struct BackendCore { pub get_app_exit_work_guard_state: Arc, /// Human paginated read of a conversation's full transcript (lot LS6). pub read_conversation_page: Arc, + /// Durable listing, ownership and Prompt/Final checkpoints for custom CLI chats. + pub custom_conversations: Arc, /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). pub rotate_conversation_log: Arc, /// Rebinds an already-running agent's live session to a visible cell (Lot D). @@ -4202,6 +4224,11 @@ impl BackendCore { as Arc; let read_conversation_page = Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider))); + let custom_conversations = Arc::new(CustomConversationService::new( + Arc::new(AppCustomConversationIndexProvider::default()) + as Arc, + Arc::new(AppRecordTurnProvider) as Arc, + )); let rotate_conversation_log = Arc::new(RotateConversationLog::new( Arc::clone(&archive_provider), Arc::new(AppHandoffProvider) as Arc, @@ -4617,6 +4644,7 @@ impl BackendCore { get_project_work_state, get_app_exit_work_guard_state, read_conversation_page, + custom_conversations, rotate_conversation_log, attach_live_agent, stop_live_agent, diff --git a/crates/domain/src/custom_conversation.rs b/crates/domain/src/custom_conversation.rs new file mode 100644 index 00000000..1cf2c6ff --- /dev/null +++ b/crates/domain/src/custom_conversation.rs @@ -0,0 +1,43 @@ +//! Durable catalogue of user-facing custom CLI conversations. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::ports::StoreError; +use crate::{AgentId, ConversationId}; + +/// One non-empty custom CLI conversation owned by an agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomConversationEntry { + /// Stable logical conversation id used by log, handoff and provider-session stores. + pub conversation_id: ConversationId, + /// Agent that owns the conversation. + pub agent_id: AgentId, + /// Title derived from the first accepted user prompt. + pub title: String, + /// Creation timestamp in epoch milliseconds. + pub created_at_ms: u64, + /// Most recent persisted human/final activity in epoch milliseconds. + pub updated_at_ms: u64, +} + +/// Project-scoped durable index for non-empty custom CLI conversations. +#[async_trait] +pub trait CustomConversationIndex: Send + Sync { + /// Lists conversations owned by `agent_id`, newest first and capped by `limit`. + async fn list( + &self, + agent_id: AgentId, + limit: usize, + ) -> Result, StoreError>; + + /// Finds an indexed conversation by logical id. + async fn get( + &self, + conversation_id: ConversationId, + ) -> Result, StoreError>; + + /// Inserts the first non-empty prompt or updates the last-activity timestamp. + async fn upsert(&self, entry: CustomConversationEntry) -> Result<(), StoreError>; +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 26bc9c6b..43e4750a 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -37,6 +37,7 @@ pub mod chat_attachment; pub mod chat_draft; pub mod conversation; pub mod conversation_log; +pub mod custom_conversation; pub mod device; pub mod error; pub mod events; @@ -177,6 +178,7 @@ pub use conversation_log::{ TurnSlice, HANDOFF_SUMMARY_MAX_CHARS, MAX_ARCHIVE_SEGMENTS, PAGE_DEFAULT_LIMIT, PAGE_MAX_LIMIT, ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS, }; +pub use custom_conversation::{CustomConversationEntry, CustomConversationIndex}; pub use device::{ AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash, diff --git a/crates/infrastructure/src/conversation_log/custom_index.rs b/crates/infrastructure/src/conversation_log/custom_index.rs new file mode 100644 index 00000000..7a2a14be --- /dev/null +++ b/crates/infrastructure/src/conversation_log/custom_index.rs @@ -0,0 +1,124 @@ +//! Filesystem adapter for the custom CLI conversation catalogue. + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::io::AsyncWriteExt; + +use domain::ports::StoreError; +use domain::ProjectPath; +use domain::{AgentId, ConversationId, CustomConversationEntry, CustomConversationIndex}; + +use super::{CONVERSATIONS_DIR, IDEAI_DIR}; + +const INDEX_FILE: &str = "custom-index.json"; + +/// JSON-backed project-scoped custom conversation catalogue. +pub struct FsCustomConversationIndex { + path: PathBuf, + lock: Arc>, +} + +impl FsCustomConversationIndex { + /// Creates an index rooted below `/.ideai/conversations/`. + #[must_use] + pub fn new(root: &ProjectPath) -> Self { + Self { + path: PathBuf::from(root.as_str()) + .join(IDEAI_DIR) + .join(CONVERSATIONS_DIR) + .join(INDEX_FILE), + lock: Arc::new(tokio::sync::Mutex::new(())), + } + } + + async fn read_entries(&self) -> Result, StoreError> { + match tokio::fs::read(&self.path).await { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|error| StoreError::Serialization(error.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(StoreError::Io(error.to_string())), + } + } + + async fn write_entries(&self, entries: &[CustomConversationEntry]) -> Result<(), StoreError> { + let parent = self.path.parent().expect("custom index path has parent"); + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| StoreError::Io(error.to_string()))?; + let bytes = serde_json::to_vec_pretty(entries) + .map_err(|error| StoreError::Serialization(error.to_string()))?; + let temporary = self.path.with_extension("json.tmp"); + let mut file = tokio::fs::File::create(&temporary) + .await + .map_err(|error| StoreError::Io(error.to_string()))?; + file.write_all(&bytes) + .await + .map_err(|error| StoreError::Io(error.to_string()))?; + file.sync_all() + .await + .map_err(|error| StoreError::Io(error.to_string()))?; + drop(file); + tokio::fs::rename(&temporary, &self.path) + .await + .map_err(|error| StoreError::Io(error.to_string())) + } +} + +#[async_trait] +impl CustomConversationIndex for FsCustomConversationIndex { + async fn list( + &self, + agent_id: AgentId, + limit: usize, + ) -> Result, StoreError> { + let _guard = self.lock.lock().await; + let mut entries = self + .read_entries() + .await? + .into_iter() + .filter(|entry| entry.agent_id == agent_id) + .collect::>(); + entries.sort_by(|left, right| { + right + .updated_at_ms + .cmp(&left.updated_at_ms) + .then_with(|| right.created_at_ms.cmp(&left.created_at_ms)) + }); + entries.truncate(limit); + Ok(entries) + } + + async fn get( + &self, + conversation_id: ConversationId, + ) -> Result, StoreError> { + let _guard = self.lock.lock().await; + Ok(self + .read_entries() + .await? + .into_iter() + .find(|entry| entry.conversation_id == conversation_id)) + } + + async fn upsert(&self, entry: CustomConversationEntry) -> Result<(), StoreError> { + let _guard = self.lock.lock().await; + let mut entries = self.read_entries().await?; + if let Some(existing) = entries + .iter_mut() + .find(|candidate| candidate.conversation_id == entry.conversation_id) + { + // Ownership and the first-prompt title are immutable. + if existing.agent_id != entry.agent_id { + return Err(StoreError::Serialization( + "custom conversation ownership mismatch".to_owned(), + )); + } + existing.updated_at_ms = existing.updated_at_ms.max(entry.updated_at_ms); + } else { + entries.push(entry); + } + self.write_entries(&entries).await + } +} diff --git a/crates/infrastructure/src/conversation_log/mod.rs b/crates/infrastructure/src/conversation_log/mod.rs index 85a2e10c..4471184f 100644 --- a/crates/infrastructure/src/conversation_log/mod.rs +++ b/crates/infrastructure/src/conversation_log/mod.rs @@ -46,10 +46,12 @@ use domain::conversation_log::{ use domain::ports::StoreError; use domain::project::ProjectPath; +mod custom_index; mod handoff; mod providers; mod summarizer; +pub use custom_index::FsCustomConversationIndex; pub use handoff::FsHandoffStore; pub use providers::FsProviderSessionStore; pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW}; diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index f577c881..f071c0d5 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -62,8 +62,8 @@ pub use chat_drafts::FsChatDraftStore; pub use clock::SystemClock; pub use conversation::InMemoryConversationRegistry; pub use conversation_log::{ - FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, - TURN_LINE_MAX_CHARS, WINDOW, + FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore, + HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW, }; pub use eventbus::TokioBroadcastEventBus; pub use fileguard::RwFileGuard; diff --git a/crates/infrastructure/tests/conversation_log.rs b/crates/infrastructure/tests/conversation_log.rs index 0af2d9ab..bec3ee69 100644 --- a/crates/infrastructure/tests/conversation_log.rs +++ b/crates/infrastructure/tests/conversation_log.rs @@ -15,6 +15,7 @@ //! requires, and is cleaned up on drop. No extra dependency is pulled in. use std::path::PathBuf; +use std::sync::Arc; use domain::conversation::ConversationId; use domain::conversation_log::{ @@ -25,12 +26,36 @@ use domain::conversation_log::{ use domain::input::InputSource; use domain::ports::StoreError; use domain::project::ProjectPath; +use domain::{AgentId, CustomConversationEntry, CustomConversationIndex}; use infrastructure::{ - FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer, - TURN_LINE_MAX_CHARS, WINDOW, + FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore, + HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW, }; use uuid::Uuid; +struct CustomIndexProvider; + +impl application::CustomConversationIndexProvider for CustomIndexProvider { + fn custom_conversation_index_for( + &self, + root: &ProjectPath, + ) -> Option> { + Some(Arc::new(FsCustomConversationIndex::new(root))) + } +} + +struct ConversationRecordProvider; + +impl application::RecordTurnProvider for ConversationRecordProvider { + fn record_turn_for(&self, root: &ProjectPath) -> Option> { + Some(Arc::new(application::RecordTurn::new( + Arc::new(FsConversationLog::new(root)), + Arc::new(FsHandoffStore::new(root)), + Arc::new(HeuristicHandoffSummarizer::new()), + ))) + } +} + // --------------------------------------------------------------------------- // Test scaffolding // --------------------------------------------------------------------------- @@ -77,6 +102,138 @@ impl TempDir { .join("providers.json.tmp") } } + +#[tokio::test] +async fn custom_index_is_durable_owned_sorted_and_capped() { + let tmp = TempDir::new(); + let root = tmp.project_path(); + let index = FsCustomConversationIndex::new(&root); + let agent_a = AgentId::from_uuid(Uuid::from_u128(10)); + let agent_b = AgentId::from_uuid(Uuid::from_u128(20)); + + index + .upsert(CustomConversationEntry { + conversation_id: conv_id(1), + agent_id: agent_a, + title: "first prompt".to_owned(), + created_at_ms: 100, + updated_at_ms: 100, + }) + .await + .unwrap(); + index + .upsert(CustomConversationEntry { + conversation_id: conv_id(2), + agent_id: agent_a, + title: "newer prompt".to_owned(), + created_at_ms: 200, + updated_at_ms: 300, + }) + .await + .unwrap(); + index + .upsert(CustomConversationEntry { + conversation_id: conv_id(3), + agent_id: agent_b, + title: "foreign".to_owned(), + created_at_ms: 400, + updated_at_ms: 400, + }) + .await + .unwrap(); + + let restarted = FsCustomConversationIndex::new(&root); + let rows = restarted.list(agent_a, 1).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].conversation_id, conv_id(2)); + assert_eq!(rows[0].title, "newer prompt"); + assert_eq!( + restarted.get(conv_id(3)).await.unwrap().unwrap().agent_id, + agent_b + ); +} + +#[tokio::test] +async fn custom_index_keeps_first_title_and_rejects_owner_change() { + let tmp = TempDir::new(); + let index = FsCustomConversationIndex::new(&tmp.project_path()); + let agent_a = AgentId::from_uuid(Uuid::from_u128(10)); + let agent_b = AgentId::from_uuid(Uuid::from_u128(20)); + let original = CustomConversationEntry { + conversation_id: conv_id(1), + agent_id: agent_a, + title: "original".to_owned(), + created_at_ms: 100, + updated_at_ms: 100, + }; + index.upsert(original.clone()).await.unwrap(); + index + .upsert(CustomConversationEntry { + title: "must not replace".to_owned(), + updated_at_ms: 200, + ..original + }) + .await + .unwrap(); + let stored = index.get(conv_id(1)).await.unwrap().unwrap(); + assert_eq!(stored.title, "original"); + assert_eq!(stored.updated_at_ms, 200); + + let error = index + .upsert(CustomConversationEntry { + agent_id: agent_b, + ..stored + }) + .await + .unwrap_err(); + assert!(matches!(error, StoreError::Serialization(_))); +} + +#[tokio::test] +async fn custom_service_materializes_on_prompt_records_final_and_checks_owner() { + let tmp = TempDir::new(); + let root = tmp.project_path(); + let service = application::CustomConversationService::new( + Arc::new(CustomIndexProvider), + Arc::new(ConversationRecordProvider), + ); + let agent = AgentId::from_uuid(Uuid::from_u128(10)); + let foreign = AgentId::from_uuid(Uuid::from_u128(20)); + let conversation = conv_id(99); + + assert!(service.list(&root, agent, 50).await.unwrap().is_empty()); + assert!(!tmp + .0 + .join(".ideai/conversations/custom-index.json") + .exists()); + + service + .record_prompt(&root, agent, conversation, 100, " First\n user prompt ") + .await + .unwrap(); + service + .record_response(&root, agent, conversation, 200, "Final answer") + .await + .unwrap(); + + let rows = service.list(&root, agent, 50).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].title, "First user prompt"); + assert_eq!(rows[0].updated_at_ms, 200); + let turns = FsConversationLog::new(&root) + .read(conversation, None) + .await + .unwrap(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].role, TurnRole::Prompt); + assert_eq!(turns[1].role, TurnRole::Response); + + let error = service + .assert_owned(&root, foreign, conversation) + .await + .unwrap_err(); + assert!(matches!(error, application::AppError::Invalid(_))); +} impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index fc297962..4aafdae3 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -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, +} + +impl From for WsLaunchOutput { + fn from(launch: application::LaunchAgentOutput) -> Self { + Self { + launch, + history: None, + } + } +} + async fn send_launch_agent_attached( frame: &ClientFrame, state: &Arc, tx: &mpsc::Sender, owned: &Arc>>, - output: application::LaunchAgentOutput, + output: impl Into, ) -> 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, request: LaunchAgentRequestDto, -) -> Result { +) -> Result { 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 { + let request = required_request::( + "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 { args.get("taskId") .and_then(Value::as_str) @@ -6447,7 +6599,18 @@ impl ServerFrame { engine_session_id: Option<&str>, scrollback: Vec, transcript_unavailable_reason: Option, + history: Option, ) -> 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, diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index cbe9dfbd..2035480e 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -369,6 +369,12 @@ describe("TauriAgentGateway invoke payloads", () => { cellKind: "chat", assignedConversationId: "conversation-1", transcriptUnavailableReason: "missingTranscript", + history: [ + { id: "turn-1", atMs: 42, role: "prompt", source: { kind: "human" }, text: "Hello", textLen: 5 }, + ], + historyHasMore: true, + historyNextAnchor: "turn-0", + resumed: true, }); const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", { @@ -397,6 +403,32 @@ describe("TauriAgentGateway invoke payloads", () => { cellKind: "chat", assignedConversationId: "conversation-1", transcriptUnavailableReason: "missingTranscript", + history: [ + { id: "turn-1", atMs: 42, role: "prompt", source: { kind: "human" }, text: "Hello", textLen: 5 }, + ], + historyHasMore: true, + historyNextAnchor: "turn-0", + resumed: true, + }); + }); + + it("lists custom conversations with the nested backend request contract", async () => { + invoke.mockResolvedValueOnce({ + conversations: [ + { + conversationId: "conversation-1", + title: "First prompt", + createdAtMs: 10, + updatedAtMs: 20, + }, + ], + }); + + await expect( + new TauriAgentGateway().listCustomAgentConversations("proj-1", "agent-2", 25), + ).resolves.toHaveLength(1); + expect(invoke).toHaveBeenCalledWith("list_custom_agent_conversations", { + request: { projectId: "proj-1", agentId: "agent-2", limit: 25 }, }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 86a34e8f..deee107b 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -32,6 +32,7 @@ import type { ChatDraftScope, ChatDraftSnapshot, ConversationDetails, + CustomAgentConversationSummary, CreateAgentInput, LiveAgent, OpenTerminalOptions, @@ -55,6 +56,10 @@ interface LaunchAgentResponse { cellKind?: "chat" | "pty"; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; + history?: import("@/domain").TurnView[]; + historyHasMore?: boolean; + historyNextAnchor?: string; + resumed?: boolean; transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"]; } @@ -306,12 +311,32 @@ export class TauriAgentGateway implements AgentGateway { ...(res.assignedConversationId ? { assignedConversationId: res.assignedConversationId } : {}), + ...(res.history ? { history: res.history } : {}), + ...(res.historyHasMore !== undefined + ? { historyHasMore: res.historyHasMore } + : {}), + ...(res.historyNextAnchor + ? { historyNextAnchor: res.historyNextAnchor } + : {}), + ...(res.resumed !== undefined ? { resumed: res.resumed } : {}), ...(res.transcriptUnavailableReason ? { transcriptUnavailableReason: res.transcriptUnavailableReason } : {}), }; } + async listCustomAgentConversations( + projectId: string, + agentId: string, + limit?: number, + ): Promise { + const response = await invoke<{ conversations: CustomAgentConversationSummary[] }>( + "list_custom_agent_conversations", + { request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) } }, + ); + return response.conversations; + } + async reattachAgentChat( sessionId: string, onChunk: (chunk: ReplyChunk) => void, diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts index 5be6cac2..2b6cec20 100644 --- a/frontend/src/adapters/http/frames.ts +++ b/frontend/src/adapters/http/frames.ts @@ -14,7 +14,7 @@ * survives JSON. */ -import type { ReplyChunk } from "@/domain"; +import type { ReplyChunk, TurnView } from "@/domain"; // --------------------------------------------------------------------------- // Base64 helpers (binary PTY bytes over JSON frames) @@ -156,4 +156,8 @@ export interface ChatAttachedPayload { engineSessionId?: string; scrollback?: ReplyChunk[]; transcriptUnavailableReason?: "missingTranscript" | "emptyConversation"; + history?: TurnView[]; + historyHasMore?: boolean; + historyNextAnchor?: string; + resumed?: boolean; } diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index 46d0a6cc..c6ce0d09 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -48,6 +48,7 @@ import type { ChatDraftScope, ChatDraftSnapshot, ConversationDetails, + CustomAgentConversationSummary, CreateAgentInput, CreateTicketInput, LiveAgent, @@ -389,9 +390,30 @@ export class HttpAgentGateway implements AgentGateway { ...(payload.transcriptUnavailableReason ? { transcriptUnavailableReason: payload.transcriptUnavailableReason } : {}), + ...(payload.history ? { history: payload.history } : {}), + ...(payload.historyHasMore !== undefined + ? { historyHasMore: payload.historyHasMore } + : {}), + ...(payload.historyNextAnchor + ? { historyNextAnchor: payload.historyNextAnchor } + : {}), + ...(payload.resumed !== undefined ? { resumed: payload.resumed } : {}), }; } + async listCustomAgentConversations( + projectId: string, + agentId: string, + limit?: number, + ): Promise { + const response = await this.http.invoke<{ + conversations: CustomAgentConversationSummary[]; + }>("list_custom_agent_conversations", { + request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) }, + }); + return response.conversations; + } + async reattachAgentChat( sessionId: string, onChunk: (chunk: ReplyChunk) => void, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 001ddbdf..78edf70b 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -118,6 +118,7 @@ import type { ConversationGateway, ConversationPageRequest, ConversationDetails, + CustomAgentConversationSummary, CloneProfileFromSeedInput, CloneOpenCodeProfileFromSeedInput, CreateAgentInput, @@ -961,6 +962,14 @@ export class MockAgentGateway implements AgentGateway { }; } + async listCustomAgentConversations( + _projectId: string, + _agentId: string, + _limit?: number, + ): Promise { + return []; + } + async reattachAgentChat( sessionId: string, _onChunk: (chunk: ReplyChunk) => void, @@ -1057,6 +1066,7 @@ export class MockAgentGateway implements AgentGateway { kind: "cleanConversation", sessionId: options.sessionId, clearedChunks: 0, + conversationId: `mock-conversation-${options.sessionId}-${Date.now()}`, }, }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 38ec872d..8d2a045f 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1922,7 +1922,12 @@ export interface SlashCommand { export type SlashCommandEffect = | { kind: "help"; commands: SlashCommand[] } - | { kind: "cleanConversation"; sessionId: string; clearedChunks: number } + | { + kind: "cleanConversation"; + sessionId: string; + clearedChunks: number; + conversationId?: string; + } | { kind: "profileSwitch"; sessionId: string } | { kind: "pluginCallback"; diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 894f257b..57a3d2bf 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -5585,7 +5585,7 @@ describe("CustomAgentChatView", () => { expect(shell.className).toContain("h-full"); expect(shell.className).toContain("min-h-0"); expect(shell.className).toContain("overflow-hidden"); - expect(toolbar.className).toContain("overflow-hidden"); + expect(toolbar.className).not.toContain("overflow-hidden"); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); expect(scroll.className).toContain("flex-1"); expect(scroll.className).toContain("basis-0"); @@ -6322,4 +6322,120 @@ describe("CustomAgentChatView", () => { ); expect(agent.launchAgentChat).not.toHaveBeenCalled(); }); + + it("lists and resumes durable custom conversations without recreating technical history", async () => { + const history = [ + { id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Saved question", textLen: 14 }, + { id: "tool", atMs: 11, role: "toolActivity" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Hidden tool", textLen: 11 }, + { id: "r1", atMs: 12, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Saved final", textLen: 11 }, + ]; + const agent = { + launchAgentChat: vi.fn() + .mockResolvedValueOnce({ sessionId: "fresh-session", assignedConversationId: "fresh-conversation" }) + .mockResolvedValueOnce({ + sessionId: "resumed-session", + assignedConversationId: "past-conversation", + history, + historyHasMore: false, + resumed: true, + }), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), + listCustomAgentConversations: vi.fn(async () => [{ + conversationId: "past-conversation", + title: "Saved question", + createdAtMs: 10, + updatedAtMs: 12, + }]), + sendAgentChat: vi.fn(async (_sid: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => { + onChunk({ kind: "final", content: "New final" }); + }), + cancelAgentChat: vi.fn(async () => ({ sessionId: "resumed-session", outcome: "cancelled" as const })), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways}> + + , + ); + + await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); + expect(agent.launchAgentChat.mock.calls[0]?.[2].conversationId).toBeUndefined(); + fireEvent.click(screen.getByRole("button", { name: "Conversations" })); + const past = await screen.findByRole("menuitem", { name: /Saved question/ }); + fireEvent.click(past); + + expect(await screen.findByText("Conversation reprise")).toBeTruthy(); + expect(screen.getByText("Saved question")).toBeTruthy(); + expect(screen.getByText("Saved final")).toBeTruthy(); + expect(screen.queryByText("Hidden tool")).toBeNull(); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + expect(await screen.findByText("Conversation poursuivie")).toBeTruthy(); + expect(await screen.findByText("New final")).toBeTruthy(); + }); + + it("uses /clean for a new conversation and disables conversation changes during a turn", async () => { + let finishSend: (() => void) | undefined; + const agent = { + launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session", assignedConversationId: "conversation-1" })), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })), + listCustomAgentConversations: vi.fn(async () => [{ + conversationId: "past-conversation", + title: "Past conversation", + createdAtMs: 10, + updatedAtMs: 20, + }]), + sendAgentChat: vi.fn(() => new Promise((resolve) => { finishSend = resolve; })), + executeSlashCommand: vi.fn(async () => ({ + command: { name: "/clean", shortDescription: "Clean", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const }, + effect: { kind: "cleanConversation" as const, sessionId: "clean-session", clearedChunks: 0, conversationId: "conversation-2" }, + })), + cancelAgentChat: vi.fn(async () => ({ sessionId: "chat-session", outcome: "cancelled" as const })), + closeAgentChat: vi.fn(async () => {}), + }; + const onConversationId = vi.fn(); + render( + null) } } as unknown as Gateways}> + + , + ); + await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "Conversations" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Nouvelle conversation" })); + await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", { + sessionId: "chat-session", + })); + expect(await screen.findByText("Nouvelle conversation prête.")).toBeTruthy(); + expect(onConversationId).toHaveBeenCalledWith("conversation-2"); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Working" } }); + fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); + fireEvent.click(screen.getByRole("button", { name: "Conversations" })); + expect(await screen.findByText("Arrêtez la réponse avant de changer de conversation.")).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Nouvelle conversation" }).hasAttribute("disabled")).toBe(true); + expect((await screen.findByRole("menuitem", { name: /Past conversation/ })).hasAttribute("disabled")).toBe(true); + finishSend?.(); + }); }); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 3882b87d..29f29b62 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -29,6 +29,7 @@ import type { ReplyProgressSource, ReplyProgressStage, SlashCommand, + TurnView, } from "@/domain"; import { useGateways } from "@/app/di"; import { useI18n } from "@/i18n"; @@ -56,6 +57,7 @@ import type { ChatAttachmentInput, ChatDraftScope, ConversationTranscriptUnavailableReason, + CustomAgentConversationSummary, ProjectIconRef, } from "@/ports"; @@ -69,6 +71,8 @@ export interface CustomAgentChatViewProps { nodeId?: string; sessionId: string | null; conversationId: string | null; + /** Start blank when there is no live session; durable threads reopen via the menu. */ + freshOnOpen?: boolean; composerHeightPx?: number; composerDefaultHeightPx?: number; onComposerHeightPxChange?: (heightPx: number) => void; @@ -87,6 +91,7 @@ type ChatTurn = | { role: "final"; text: string; receivedAtMs?: number | null } | { role: "error"; text: string; receivedAtMs?: number | null } | { role: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null } + | { role: "separator"; text: string } | { role: "unknown"; text: string; receivedAtMs?: number | null }; interface CachedChatView { @@ -488,6 +493,25 @@ function emptyChatViewState(): ChatViewState { return { turns: [], progressIndex: new Map(), progressScope: 0 }; } +function durableHistoryTurns(history: TurnView[]): ChatTurn[] { + return history.flatMap((turn): ChatTurn[] => { + if (turn.role === "prompt") { + return [{ role: "user", text: turn.text, receivedAtMs: turn.atMs }]; + } + if (turn.role === "response") { + return [{ role: "final", text: turn.text, receivedAtMs: turn.atMs }]; + } + return []; + }); +} + +function conversationActivityLabel(atMs: number): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(atMs)); +} + function conversationCacheKey( projectId: string, agentId: string, @@ -999,6 +1023,7 @@ export function CustomAgentChatView({ nodeId, sessionId, conversationId, + freshOnOpen = false, composerHeightPx = FALLBACK_COMPOSER_HEIGHT, composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT, onComposerHeightPxChange, @@ -1008,13 +1033,13 @@ export function CustomAgentChatView({ onSessionId, onConversationId, }: CustomAgentChatViewProps) { - const { agent, profile: profileGateway, project, system, workState } = useGateways(); + const { agent, conversation, profile: profileGateway, project, system, workState } = useGateways(); const { t, tr } = useI18n(); const pluginRuntime = usePluginRuntime(); const initialConversationCacheKey = conversationCacheKey( projectId, agentId, - conversationId, + sessionId || !freshOnOpen ? conversationId : null, ); const [turns, setTurns] = useState( () => @@ -1044,6 +1069,14 @@ export function CustomAgentChatView({ () => new Set(), ); const [currentSession, setCurrentSession] = useState(sessionId); + // A newly opened custom CLI intentionally starts blank. A conversation id + // persisted on the layout is only reused when it still has a live session; + // durable threads are reopened explicitly from the Conversations menu. + const [activeConversationId, setActiveConversationId] = useState( + sessionId || !freshOnOpen ? conversationId : null, + ); + const activeConversationIdRef = useRef(activeConversationId); + activeConversationIdRef.current = activeConversationId; const [externalSessionId, setExternalSessionId] = useState(sessionId); const initialDraftScope = explicitDraftScope ?? ( layoutId && nodeId ? { projectId, layoutId, nodeId } : null @@ -1077,7 +1110,9 @@ export function CustomAgentChatView({ const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [profileDialog, setProfileDialog] = useState(null); - const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); + const [opening, setOpening] = useState( + Boolean(sessionId || (!freshOnOpen && conversationId)), + ); const [openRetryNonce, setOpenRetryNonce] = useState(0); const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState< ConversationTranscriptUnavailableReason | "conversationNotFound" | null @@ -1088,6 +1123,16 @@ export function CustomAgentChatView({ const [stopTerminalOverride, setStopTerminalOverride] = useState(false); const [error, setError] = useState(null); const [projectIcon, setProjectIcon] = useState(null); + const [conversationMenuOpen, setConversationMenuOpen] = useState(false); + const [conversationList, setConversationList] = useState([]); + const [conversationListLoading, setConversationListLoading] = useState(false); + const [conversationListError, setConversationListError] = useState(null); + const [conversationNotice, setConversationNotice] = useState(null); + const [resumedConversation, setResumedConversation] = useState(false); + const [historyHasMore, setHistoryHasMore] = useState(false); + const [historyNextAnchor, setHistoryNextAnchor] = useState(); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); const composerRef = useRef(null); const chatRootRef = useRef(null); const turnsRef = useRef(turns); @@ -1253,8 +1298,8 @@ export function CustomAgentChatView({ [agentId, layoutId, nodeId, projectId], ); const currentConversationCacheKey = useMemo( - () => conversationCacheKey(projectId, agentId, conversationId), - [agentId, conversationId, projectId], + () => conversationCacheKey(projectId, agentId, activeConversationId), + [activeConversationId, agentId, projectId], ); const composerHistoryKey = useMemo( () => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId), @@ -1817,6 +1862,12 @@ export function CustomAgentChatView({ onSessionIdRef.current(nextSessionId); }, []); + const publishConversationId = useCallback((nextConversationId: string | null) => { + activeConversationIdRef.current = nextConversationId; + setActiveConversationId(nextConversationId); + onConversationIdRef.current(nextConversationId); + }, []); + const reattachStructuredSession = useCallback( async ( sid: string, @@ -1857,7 +1908,7 @@ export function CustomAgentChatView({ publishSessionId(reattached.sessionId); } if (reattached.conversationId) { - onConversationIdRef.current(reattached.conversationId); + publishConversationId(reattached.conversationId); } if (options.applyScrollback) { scrollbackChunkCountRef.current = reattached.scrollback.length; @@ -1881,7 +1932,7 @@ export function CustomAgentChatView({ } return reattached.sessionId; }, - [agent, agentId, applyChatViewState, projectId, publishSessionId, receive], + [agent, agentId, applyChatViewState, projectId, publishConversationId, publishSessionId, receive], ); useEffect(() => { @@ -1926,7 +1977,7 @@ export function CustomAgentChatView({ console.debug("[ticket149] recoverStructuredSession:start", { timestamp: new Date().toISOString(), currentSession: sessionRef.current, - conversationId, + conversationId: activeConversationIdRef.current, options, }); let retryAttachNotFound = Boolean(options.retryAttachNotFound); @@ -1937,7 +1988,7 @@ export function CustomAgentChatView({ cols: 80, conversationId: options.conversationId === undefined - ? conversationId ?? undefined + ? activeConversationIdRef.current ?? undefined : options.conversationId ?? undefined, ...(nodeId ? { nodeId } : {}), }; @@ -1973,14 +2024,26 @@ export function CustomAgentChatView({ setCurrentSession(launched.sessionId); publishSessionId(launched.sessionId); if (launched.assignedConversationId) { - onConversationIdRef.current(launched.assignedConversationId); + publishConversationId(launched.assignedConversationId); + } + if (launched.resumed) { + const replayed = { + ...emptyChatViewState(), + turns: durableHistoryTurns(launched.history ?? []), + }; + applyChatViewState(replayed); + setResumedConversation(true); + setConversationNotice(null); + setHistoryHasMore(Boolean(launched.historyHasMore)); + setHistoryNextAnchor(launched.historyNextAnchor); + setHistoryError(null); } if (launched.transcriptUnavailableReason) { setTranscriptUnavailableReason(launched.transcriptUnavailableReason); } try { const recoveredSessionId = await reattachStructuredSession(launched.sessionId, { - applyScrollback: options.applyScrollback, + applyScrollback: launched.resumed ? false : options.applyScrollback, }); console.debug("[ticket149] recoverStructuredSession:success", { timestamp: new Date().toISOString(), @@ -2012,8 +2075,9 @@ export function CustomAgentChatView({ agentId, cwd, nodeId, - conversationId, reattachStructuredSession, + applyChatViewState, + publishConversationId, publishSessionId, ], ); @@ -2139,8 +2203,13 @@ export function CustomAgentChatView({ error: instrumentationError(e), }); } - } else if (currentConversationCacheKey) { - const cached = chatConversationViewCache.get(currentConversationCacheKey); + } else { + const cacheKey = conversationCacheKey( + projectId, + agentId, + activeConversationIdRef.current, + ); + const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined; if (cached) { scrollbackChunkCountRef.current = cached.chunkCount; applyChatViewState({ @@ -2165,7 +2234,7 @@ export function CustomAgentChatView({ } } await recoverStructuredSession({ - applyScrollback: Boolean(conversationId), + applyScrollback: Boolean(activeConversationIdRef.current), retryAttachNotFound: true, }); if (cancelled) return; @@ -2181,7 +2250,7 @@ export function CustomAgentChatView({ sessionId: externalSessionId, error: instrumentationError(e), }); - if (!cancelled && conversationId && isNotFound(e)) { + if (!cancelled && activeConversationIdRef.current && isNotFound(e)) { setTranscriptUnavailableReason("conversationNotFound"); setCurrentSession(null); publishSessionId(null); @@ -2204,8 +2273,6 @@ export function CustomAgentChatView({ recoverStructuredSession, publishSessionId, applyChatViewState, - currentConversationCacheKey, - conversationId, openRetryNonce, ]); @@ -2242,6 +2309,85 @@ export function CustomAgentChatView({ setOpenRetryNonce((value) => value + 1); } + const refreshConversationList = useCallback(async () => { + if (!agent.listCustomAgentConversations) { + setConversationListError( + tr("Liste des conversations indisponible.", "Conversation list unavailable."), + ); + return; + } + setConversationListLoading(true); + setConversationListError(null); + try { + setConversationList( + await agent.listCustomAgentConversations(projectId, agentId, 50), + ); + } catch (e) { + setConversationListError(describe(e)); + } finally { + setConversationListLoading(false); + } + }, [agent, agentId, projectId, tr]); + + useEffect(() => { + if (conversationMenuOpen) void refreshConversationList(); + }, [conversationMenuOpen, refreshConversationList]); + + async function reopenConversation(nextConversationId: string) { + if (effectiveBusy || opening || nextConversationId === activeConversationId) return; + setConversationMenuOpen(false); + setOpening(true); + setError(null); + try { + const sid = sessionRef.current; + if (sid) await agent.closeAgentChat?.(sid); + setCurrentSession(null); + setExternalSessionId(null); + publishSessionId(null); + publishConversationId(nextConversationId); + applyChatViewState(emptyChatViewState()); + setResumedConversation(false); + setConversationNotice(null); + setHistoryHasMore(false); + setHistoryNextAnchor(undefined); + setOpenRetryNonce((value) => value + 1); + } catch (e) { + setError({ message: describe(e), openFailure: false }); + } finally { + setOpening(false); + } + } + + async function startNewConversation() { + if (effectiveBusy || opening) return; + setConversationMenuOpen(false); + await executeSlashCommand({ name: "/clean", arguments: [] }); + } + + async function loadOlderHistory() { + if (!activeConversationId || !historyNextAnchor || historyLoading) return; + setHistoryLoading(true); + setHistoryError(null); + try { + const page = await conversation.readPage(projectId, activeConversationId, { + anchor: historyNextAnchor, + direction: "backward", + limit: 50, + }); + const older = durableHistoryTurns(page.turns); + updateChatViewState((previous) => ({ + ...previous, + turns: [...older, ...previous.turns], + })); + setHistoryHasMore(page.hasMore); + setHistoryNextAnchor(page.nextAnchor); + } catch (e) { + setHistoryError(describe(e)); + } finally { + setHistoryLoading(false); + } + } + async function openProfileDialog() { const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) => Boolean(candidate.structuredAdapter), @@ -2363,6 +2509,17 @@ export function CustomAgentChatView({ } if (result.effect.kind === "cleanConversation") { applyChatViewState(emptyChatViewState()); + setCurrentSession(result.effect.sessionId); + setExternalSessionId(result.effect.sessionId); + publishSessionId(result.effect.sessionId); + publishConversationId(result.effect.conversationId ?? null); + setResumedConversation(false); + setHistoryHasMore(false); + setHistoryNextAnchor(undefined); + setHistoryError(null); + setConversationNotice( + tr("Nouvelle conversation prête.", "New conversation ready."), + ); return; } if (result.effect.kind === "profileSwitch") { @@ -2549,10 +2706,18 @@ export function CustomAgentChatView({ threadScroll.followLatest(); setBusy(true); setError(null); + const continuingResumedConversation = resumedConversation; + if (continuingResumedConversation) { + setResumedConversation(false); + setConversationNotice(null); + } updateChatViewState((prev) => ({ ...prev, turns: [ ...prev.turns, + ...(continuingResumedConversation + ? [{ role: "separator" as const, text: tr("Conversation poursuivie", "Conversation continued") }] + : []), { role: "user", text: displayText, @@ -2801,7 +2966,7 @@ export function CustomAgentChatView({
{agentName}
@@ -2809,6 +2974,92 @@ export function CustomAgentChatView({ {tr("CLI personnalisée", "Custom CLI")} · {profile.name}
+
+ + {conversationMenuOpen && ( +
+ {effectiveBusy && ( +

+ {tr( + "Arrêtez la réponse avant de changer de conversation.", + "Stop the response before changing conversations.", + )} +

+ )} + +
+
+ {conversationListLoading ? ( +
+ + {tr("Chargement des conversations…", "Loading conversations…")} +
+ ) : conversationListError ? ( +
+ {tr("Impossible de charger les conversations.", "Unable to load conversations.")} + +
+ ) : conversationList.length === 0 ? ( +

+ {tr("Aucune conversation passée.", "No past conversations.")} +

+ ) : ( + conversationList.map((item) => { + const isOpen = item.conversationId === activeConversationId; + return ( + + ); + }) + )} +
+
+ )} +
{hasPreviousFinal && ( + )} + {historyError && ( +

{historyError}

+ )} {opening && displayTurns.length === 0 ? (
@@ -3026,9 +3310,20 @@ export function CustomAgentChatView({
) : displayTurns.length === 0 ? ( -

- {t("custom.empty")} -

+
+ + {tr("Nouvelle conversation", "New conversation")} + + + {t("custom.empty")} + {" "} + + {tr( + "Vous pouvez aussi ouvrir une conversation passée depuis le menu Conversations.", + "You can also open a past conversation from the Conversations menu.", + )} + +
) : ( displayTurns.map((turn, index) => ( void; }) { const { tr } = useI18n(); + if (turn.role === "separator") { + return ( +
+ + {turn.text} + +
+ ); + } if (turn.role === "restart") { return (
{ expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); }); - it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", async () => { + it("starts a fresh custom CLI conversation when returning without a live session (#363)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); @@ -506,15 +506,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => { }); window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({ - sessionId: "chat-resumed", + sessionId: "chat-fresh", cellKind: "chat" as const, + assignedConversationId: "conversation-fresh", }); const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({ - sessionId: "chat-resumed", - scrollback: [ - { kind: "userPrompt", text: "known prompt" }, - { kind: "final", content: "known answer" }, - ], + sessionId: "chat-fresh", + scrollback: [], }); renderGrid({ @@ -525,25 +523,25 @@ describe("LayoutGrid custom agent CLI (#147)", () => { system, } as unknown as Gateways); - expect(await screen.findByText("known answer")).toBeTruthy(); - expect(screen.getByText("known prompt")).toBeTruthy(); + expect(await screen.findByText("Nouvelle conversation")).toBeTruthy(); + expect(screen.queryByText("known prompt")).toBeNull(); expect(launchChat).toHaveBeenCalledWith( "p1", created.id, expect.objectContaining({ nodeId: leafId, - conversationId: "conversation-known", + conversationId: undefined, }), ); - expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function)); + expect(reattachChat).toHaveBeenCalledWith("chat-fresh", expect.any(Function)); await waitFor(async () => { const updated = await layout.loadLayout("p1"); const leaf = leaves(updated)[0]; - expect(leaf.conversationId).toBe("conversation-known"); - expect(leaf.session).toBe("chat-resumed"); + expect(leaf.conversationId).toBe("conversation-fresh"); + expect(leaf.session).toBe("chat-fresh"); }); - expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée.")) - .toBeNull(); + expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée.")) + .toBeTruthy(); }); it("relaunches a stopped custom CLI agent from scratch (#197)", async () => { diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index ed6a7b0e..35ae8aa4 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -1695,6 +1695,7 @@ function LeafView({ ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( ; + /** Lists the latest durable custom-CLI conversations for one agent. */ + listCustomAgentConversations?( + projectId: string, + agentId: string, + limit?: number, + ): Promise; /** * Re-attaches a custom chat view to a still-live structured session, replaying * retained reply chunks before subsequent live chunks arrive.