feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)

This commit is contained in:
2026-09-16 23:20:31 +02:00
parent 83b78f438f
commit 943c4525d7
30 changed files with 1794 additions and 109 deletions

View File

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

View File

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

View File

@ -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<NodeId>,
size: PtySize,
transcript_restore_expected: bool,
pending_resume_preamble: Option<String>,
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

View File

@ -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<Arc<dyn CustomConversationIndex>>;
}
/// Application service for custom CLI conversation ownership and checkpoints.
pub struct CustomConversationService {
indexes: Arc<dyn CustomConversationIndexProvider>,
records: Arc<dyn RecordTurnProvider>,
}
impl CustomConversationService {
/// Builds the service from project-scoped providers.
#[must_use]
pub fn new(
indexes: Arc<dyn CustomConversationIndexProvider>,
records: Arc<dyn RecordTurnProvider>,
) -> 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<Vec<CustomConversationEntry>, 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<CustomConversationEntry, AppError> {
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::<Vec<_>>().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);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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<ConversationId>,
/// Deferred bounded handoff injected into the first post-resume user prompt.
pending_resume_preamble: Option<String>,
/// Launch-scoped managed-temp handle retained for release.
managed_temp_handle: Option<String>,
/// 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<String>,
) {
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<ConversationId> {
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<String> {
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

View File

@ -74,6 +74,40 @@ fn id<T>(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<AgentBusyState>,
cancelled: Mutex<Vec<(RuntimeAgentKey, TicketId)>>,

View File

@ -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);

View File

@ -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<String>,
},
/// Placeholder for ticket #164.
ProfileSwitch {
@ -1085,6 +1088,7 @@ impl From<SlashCommandEffect> 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<TurnViewDto>,
/// 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<String>,
/// 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<OpenTerminalOutput> 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<LaunchAgentOutput> 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<usize>,
}
/// 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<domain::CustomConversationEntry> 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<CustomConversationSummaryDto>,
}
/// 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<TerminalSession> 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,
}
}
}

View File

@ -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<HashMap<String, Arc<FsCustomConversationIndex>>>,
}
impl CustomConversationIndexProvider for AppCustomConversationIndexProvider {
fn custom_conversation_index_for(
&self,
root: &domain::project::ProjectPath,
) -> Option<Arc<dyn domain::CustomConversationIndex>> {
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<dyn domain::CustomConversationIndex>)
}
}
/// 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<GetAppExitWorkGuardState>,
/// Human paginated read of a conversation's full transcript (lot LS6).
pub read_conversation_page: Arc<ReadConversationPage>,
/// Durable listing, ownership and Prompt/Final checkpoints for custom CLI chats.
pub custom_conversations: Arc<CustomConversationService>,
/// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
pub rotate_conversation_log: Arc<RotateConversationLog>,
/// Rebinds an already-running agent's live session to a visible cell (Lot D).
@ -4202,6 +4224,11 @@ impl BackendCore {
as Arc<dyn application::ConversationArchiveProvider>;
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<dyn CustomConversationIndexProvider>,
Arc::new(AppRecordTurnProvider) as Arc<dyn RecordTurnProvider>,
));
let rotate_conversation_log = Arc::new(RotateConversationLog::new(
Arc::clone(&archive_provider),
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
@ -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,

View File

@ -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<Vec<CustomConversationEntry>, StoreError>;
/// Finds an indexed conversation by logical id.
async fn get(
&self,
conversation_id: ConversationId,
) -> Result<Option<CustomConversationEntry>, StoreError>;
/// Inserts the first non-empty prompt or updates the last-activity timestamp.
async fn upsert(&self, entry: CustomConversationEntry) -> Result<(), StoreError>;
}

View File

@ -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,

View File

@ -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<tokio::sync::Mutex<()>>,
}
impl FsCustomConversationIndex {
/// Creates an index rooted below `<project>/.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<Vec<CustomConversationEntry>, 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<Vec<CustomConversationEntry>, StoreError> {
let _guard = self.lock.lock().await;
let mut entries = self
.read_entries()
.await?
.into_iter()
.filter(|entry| entry.agent_id == agent_id)
.collect::<Vec<_>>();
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<Option<CustomConversationEntry>, 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
}
}

View File

@ -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};

View File

@ -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;

View File

@ -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<Arc<dyn CustomConversationIndex>> {
Some(Arc::new(FsCustomConversationIndex::new(root)))
}
}
struct ConversationRecordProvider;
impl application::RecordTurnProvider for ConversationRecordProvider {
fn record_turn_for(&self, root: &ProjectPath) -> Option<Arc<application::RecordTurn>> {
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);

View File

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