feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)
This commit is contained in:
@ -54,17 +54,18 @@ use crate::dto::{
|
|||||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||||
CreateTemplateRequestDto, CustomProjectIconAssetContentDto, CustomProjectIconAssetDto,
|
CreateTemplateRequestDto, CustomConversationListDto, CustomConversationSummaryDto,
|
||||||
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
CustomProjectIconAssetContentDto, CustomProjectIconAssetDto, CustomProjectIconUsageDto,
|
||||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
|
||||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
|
EmbedderProfileListDto, ErrorDto, ExecuteSlashCommandRequestDto,
|
||||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
ExecuteSlashCommandResponseDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
|
||||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||||
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
|
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
ImportChatAttachmentsResponseDto, ImportCustomProjectIconAssetRequestDto,
|
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
|
||||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto,
|
||||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto,
|
||||||
|
ListCustomConversationsRequestDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
||||||
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
||||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||||
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
|
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
|
||||||
@ -192,15 +193,94 @@ pub async fn execute_slash_command(
|
|||||||
let effect = match output.effect {
|
let effect = match output.effect {
|
||||||
SlashCommandEffect::Help { commands } => SlashCommandEffectDto::Help { commands },
|
SlashCommandEffect::Help { commands } => SlashCommandEffectDto::Help { commands },
|
||||||
SlashCommandEffect::CleanConversation { session_id } => {
|
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 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 {
|
SlashCommandEffectDto::CleanConversation {
|
||||||
session_id: session_id.to_string(),
|
session_id: replacement.to_string(),
|
||||||
cleared_chunks,
|
cleared_chunks,
|
||||||
|
conversation_id: relaunched.assigned_conversation_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch {
|
SlashCommandEffect::ProfileSwitch { session_id } => SlashCommandEffectDto::ProfileSwitch {
|
||||||
@ -2264,6 +2344,25 @@ pub async fn read_conversation_page(
|
|||||||
.map_err(ErrorDto::from)
|
.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
|
/// `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
|
/// (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
|
/// 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 = resolve_project(&request.project_id, &state).await?;
|
||||||
let project_id = project.id;
|
let project_id = project.id;
|
||||||
let agent_id = parse_agent_id(&request.agent_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
|
// The hosting cell drives the singleton-invariant guard. Parse it when the
|
||||||
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
|
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
|
||||||
// already-live agent is refused).
|
// 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).
|
/// `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 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);
|
prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs);
|
||||||
let attachment_labels = imported_attachments
|
let attachment_labels = imported_attachments
|
||||||
.iter()
|
.iter()
|
||||||
.map(|attachment| attachment.filename.clone())
|
.map(|attachment| attachment.filename.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let session_meta = state.structured_sessions.meta_for_session(&sid);
|
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 {
|
if let Some((project_id, agent_id, _, _)) = &session_meta {
|
||||||
state.chat_bridge.bind_owner(sid, *project_id, *agent_id);
|
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 structured = std::sync::Arc::clone(&state.structured_sessions);
|
||||||
let runtime = tokio::runtime::Handle::current();
|
let runtime = tokio::runtime::Handle::current();
|
||||||
let meta = session_meta;
|
let meta = session_meta;
|
||||||
|
let custom_conversations = std::sync::Arc::clone(&state.custom_conversations);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut terminal_visible = false;
|
let mut terminal_visible = false;
|
||||||
let mut settler = crate::chat::ProgressSettler::default();
|
let mut settler = crate::chat::ProgressSettler::default();
|
||||||
@ -3123,6 +3308,17 @@ pub async fn agent_send(
|
|||||||
break 'logical_turn;
|
break 'logical_turn;
|
||||||
}
|
}
|
||||||
let provider_terminal = crate::chat::reply_event_ends_turn(&event);
|
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 {
|
if provider_terminal {
|
||||||
concrete_terminal = true;
|
concrete_terminal = true;
|
||||||
if let Some(guard) = turn_guard.as_mut() {
|
if let Some(guard) = turn_guard.as_mut() {
|
||||||
|
|||||||
@ -353,6 +353,7 @@ pub fn run() {
|
|||||||
tickets::ticket_unassign_sprint,
|
tickets::ticket_unassign_sprint,
|
||||||
commands::get_project_work_state,
|
commands::get_project_work_state,
|
||||||
commands::read_conversation_page,
|
commands::read_conversation_page,
|
||||||
|
commands::list_custom_agent_conversations,
|
||||||
commands::list_live_agents,
|
commands::list_live_agents,
|
||||||
commands::attach_live_agent,
|
commands::attach_live_agent,
|
||||||
commands::stop_live_agent,
|
commands::stop_live_agent,
|
||||||
|
|||||||
@ -2516,6 +2516,19 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
)
|
)
|
||||||
.await;
|
.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 =
|
let mut spec =
|
||||||
self.runtime
|
self.runtime
|
||||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||||
@ -2529,7 +2542,11 @@ impl LaunchAgent {
|
|||||||
&manifest,
|
&manifest,
|
||||||
&agent,
|
&agent,
|
||||||
content.clone(),
|
content.clone(),
|
||||||
launch_conversation_id.as_deref(),
|
if defer_custom_resume {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
launch_conversation_id.as_deref()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
// 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 —
|
// 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.
|
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
|
||||||
let pair_conversation_id = launch_conversation_id.clone().unwrap_or_else(|| {
|
let pair_conversation_id = launch_conversation_id.clone().unwrap_or_else(|| {
|
||||||
ConversationId::for_project_pair(
|
if input.require_structured {
|
||||||
input.project.id,
|
self.ids.new_uuid().to_string()
|
||||||
ConversationParty::User,
|
} else {
|
||||||
ConversationParty::agent(agent.id),
|
ConversationId::for_project_pair(
|
||||||
)
|
input.project.id,
|
||||||
.to_string()
|
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
|
return self
|
||||||
.launch_structured(
|
.launch_structured(
|
||||||
factory.as_ref(),
|
factory.as_ref(),
|
||||||
@ -2752,7 +2778,8 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
transcript_restore_expected(&session_plan),
|
transcript_restore_expected,
|
||||||
|
pending_resume_preamble,
|
||||||
&spec.env,
|
&spec.env,
|
||||||
None,
|
None,
|
||||||
structured_policy.as_ref(),
|
structured_policy.as_ref(),
|
||||||
@ -2857,6 +2884,7 @@ impl LaunchAgent {
|
|||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
transcript_restore_expected: bool,
|
transcript_restore_expected: bool,
|
||||||
|
pending_resume_preamble: Option<String>,
|
||||||
env: &[(String, String)],
|
env: &[(String, String)],
|
||||||
sandbox: Option<&SandboxPlan>,
|
sandbox: Option<&SandboxPlan>,
|
||||||
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
structured_policy: Option<&StructuredProviderLaunchPolicy>,
|
||||||
@ -2901,6 +2929,13 @@ impl LaunchAgent {
|
|||||||
launch_managed_temp.map(|temp| temp.handle),
|
launch_managed_temp.map(|temp| temp.handle),
|
||||||
);
|
);
|
||||||
structured.set_transcript_restore_expected(session_id, transcript_restore_expected);
|
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) ──
|
// ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
|
||||||
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur
|
||||||
|
|||||||
196
crates/application/src/conversation/custom.rs
Normal file
196
crates/application/src/conversation/custom.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,10 +13,16 @@
|
|||||||
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
|
//! the PTY or `app-tauri` (that is P6b). It is fully testable with in-memory fakes
|
||||||
//! of the three ports.
|
//! of the three ports.
|
||||||
|
|
||||||
|
mod custom;
|
||||||
mod paginate;
|
mod paginate;
|
||||||
mod record;
|
mod record;
|
||||||
mod rotate;
|
mod rotate;
|
||||||
|
|
||||||
|
pub use custom::{
|
||||||
|
CustomConversationIndexProvider, CustomConversationService, CUSTOM_CONVERSATION_LIST_LIMIT,
|
||||||
|
CUSTOM_CONVERSATION_TITLE_MAX_CHARS,
|
||||||
|
};
|
||||||
|
|
||||||
pub use paginate::{
|
pub use paginate::{
|
||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, TurnPage,
|
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, TurnPage,
|
||||||
TurnSource, TurnView,
|
TurnSource, TurnView,
|
||||||
|
|||||||
@ -90,8 +90,10 @@ pub use chat_drafts::{
|
|||||||
SaveChatDraftInput,
|
SaveChatDraftInput,
|
||||||
};
|
};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
ConversationArchiveProvider, CustomConversationIndexProvider, CustomConversationService,
|
||||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog,
|
||||||
|
RotateConversationLogInput, TurnPage, TurnSource, TurnView, CUSTOM_CONVERSATION_LIST_LIMIT,
|
||||||
|
CUSTOM_CONVERSATION_TITLE_MAX_CHARS,
|
||||||
};
|
};
|
||||||
pub use device::{
|
pub use device::{
|
||||||
normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput,
|
normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput,
|
||||||
|
|||||||
@ -536,6 +536,10 @@ struct StructuredEntry {
|
|||||||
/// The launch resumed a logical conversation already linked to the cell.
|
/// The launch resumed a logical conversation already linked to the cell.
|
||||||
/// A fresh in-memory chat bridge therefore cannot provide its transcript.
|
/// A fresh in-memory chat bridge therefore cannot provide its transcript.
|
||||||
transcript_restore_expected: bool,
|
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.
|
/// Launch-scoped managed-temp handle retained for release.
|
||||||
managed_temp_handle: Option<String>,
|
managed_temp_handle: Option<String>,
|
||||||
/// Optional local-model-server usage guard retained for the live session.
|
/// Optional local-model-server usage guard retained for the live session.
|
||||||
@ -674,6 +678,8 @@ impl StructuredSessions {
|
|||||||
agent_id,
|
agent_id,
|
||||||
node_id,
|
node_id,
|
||||||
transcript_restore_expected: false,
|
transcript_restore_expected: false,
|
||||||
|
custom_conversation_id: None,
|
||||||
|
pending_resume_preamble: None,
|
||||||
managed_temp_handle,
|
managed_temp_handle,
|
||||||
_model_server_guard: model_server_guard,
|
_model_server_guard: model_server_guard,
|
||||||
turn_generation: 0,
|
turn_generation: 0,
|
||||||
@ -834,6 +840,40 @@ impl StructuredSessions {
|
|||||||
.unwrap_or(false)
|
.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`.
|
/// 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
|
/// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer
|
||||||
|
|||||||
@ -74,6 +74,40 @@ fn id<T>(value: u128, make: impl FnOnce(Uuid) -> T) -> T {
|
|||||||
make(Uuid::from_u128(value))
|
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 {
|
struct TurnAccounting {
|
||||||
state: Mutex<AgentBusyState>,
|
state: Mutex<AgentBusyState>,
|
||||||
cancelled: Mutex<Vec<(RuntimeAgentKey, TicketId)>>,
|
cancelled: Mutex<Vec<(RuntimeAgentKey, TicketId)>>,
|
||||||
|
|||||||
@ -918,6 +918,44 @@ async fn global_idea_cli_preference_applies_to_a_new_human_launch() {
|
|||||||
assert_eq!(f.pty.spawn_count(), 0);
|
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]
|
#[tokio::test]
|
||||||
async fn explicit_native_tui_overrides_the_global_idea_cli_preference() {
|
async fn explicit_native_tui_overrides_the_global_idea_cli_preference() {
|
||||||
let f = launch_fixture_with_preference(DefaultAgentTerminalInterface::IdeaCli);
|
let f = launch_fixture_with_preference(DefaultAgentTerminalInterface::IdeaCli);
|
||||||
|
|||||||
@ -1054,6 +1054,9 @@ pub enum SlashCommandEffectDto {
|
|||||||
/// Number of retained chunks removed by the backend bridge.
|
/// Number of retained chunks removed by the backend bridge.
|
||||||
#[serde(rename = "clearedChunks")]
|
#[serde(rename = "clearedChunks")]
|
||||||
cleared_chunks: usize,
|
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.
|
/// Placeholder for ticket #164.
|
||||||
ProfileSwitch {
|
ProfileSwitch {
|
||||||
@ -1085,6 +1088,7 @@ impl From<SlashCommandEffect> for SlashCommandEffectDto {
|
|||||||
SlashCommandEffect::CleanConversation { session_id } => Self::CleanConversation {
|
SlashCommandEffect::CleanConversation { session_id } => Self::CleanConversation {
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
cleared_chunks: 0,
|
cleared_chunks: 0,
|
||||||
|
conversation_id: None,
|
||||||
},
|
},
|
||||||
SlashCommandEffect::ProfileSwitch { session_id } => Self::ProfileSwitch {
|
SlashCommandEffect::ProfileSwitch { session_id } => Self::ProfileSwitch {
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
@ -1444,6 +1448,18 @@ pub struct TerminalSessionDto {
|
|||||||
/// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO
|
/// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO
|
||||||
/// always serialises with `cellKind: "pty"`).
|
/// always serialises with `cellKind: "pty"`).
|
||||||
pub cell_kind: CellKind,
|
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
|
/// 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,
|
assigned_conversation_id: None,
|
||||||
engine_session_id: None,
|
engine_session_id: None,
|
||||||
cell_kind: CellKind::Pty,
|
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,
|
assigned_conversation_id,
|
||||||
engine_session_id,
|
engine_session_id,
|
||||||
cell_kind,
|
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
|
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
|
||||||
/// frontend sends `{ request: { projectId, agentId } }`.
|
/// frontend sends `{ request: { projectId, agentId } }`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@ -3708,6 +3778,10 @@ impl From<TerminalSession> for TerminalSessionDto {
|
|||||||
assigned_conversation_id: None,
|
assigned_conversation_id: None,
|
||||||
engine_session_id: None,
|
engine_session_id: None,
|
||||||
cell_kind: CellKind::Pty,
|
cell_kind: CellKind::Pty,
|
||||||
|
history: Vec::new(),
|
||||||
|
history_has_more: false,
|
||||||
|
history_next_anchor: None,
|
||||||
|
resumed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,15 +21,15 @@ use application::{
|
|||||||
ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab,
|
ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab,
|
||||||
CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
||||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
||||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent,
|
CreateProject, CreateSkill, CreateSprint, CreateTemplate, CustomConversationIndexProvider,
|
||||||
DeleteCustomProjectIconAsset, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
CustomConversationService, DeleteAgent, DeleteCustomProjectIconAsset, DeleteEmbedderProfile,
|
||||||
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
|
||||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand,
|
||||||
GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage, GetLiveStateLean,
|
FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage,
|
||||||
GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState,
|
GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions,
|
||||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
||||||
ImportCustomProjectIconAsset, InspectConversation, InstallPluginFromArchive,
|
ImportCustomProjectIconAsset, InspectConversation, InstallPluginFromArchive,
|
||||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||||
@ -107,20 +107,20 @@ use infrastructure::{
|
|||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||||
DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe,
|
DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe,
|
||||||
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore,
|
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore,
|
||||||
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomProjectIconAssetStore,
|
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomConversationIndex,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
FsCustomProjectIconAssetStore, FsDeviceSessionStore, FsEmbedderProfileStore,
|
||||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
||||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore,
|
||||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore,
|
||||||
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore,
|
||||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore,
|
||||||
FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer,
|
FsUserSettingsStore, FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository,
|
||||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
|
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||||
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
|
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem,
|
||||||
LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer,
|
LocalManagedProcess, LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider,
|
||||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
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)
|
/// Implémente [`ConversationArchiveProvider`](application::ConversationArchiveProvider)
|
||||||
/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi
|
/// (lot LS6) en matérialisant un [`FsConversationLog`] (qui implémente aussi
|
||||||
/// [`domain::ConversationArchive`]) ciblant le **project root** courant.
|
/// [`domain::ConversationArchive`]) ciblant le **project root** courant.
|
||||||
@ -2317,6 +2337,8 @@ pub struct BackendCore {
|
|||||||
pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
|
pub get_app_exit_work_guard_state: Arc<GetAppExitWorkGuardState>,
|
||||||
/// Human paginated read of a conversation's full transcript (lot LS6).
|
/// Human paginated read of a conversation's full transcript (lot LS6).
|
||||||
pub read_conversation_page: Arc<ReadConversationPage>,
|
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).
|
/// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6).
|
||||||
pub rotate_conversation_log: Arc<RotateConversationLog>,
|
pub rotate_conversation_log: Arc<RotateConversationLog>,
|
||||||
/// Rebinds an already-running agent's live session to a visible cell (Lot D).
|
/// 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>;
|
as Arc<dyn application::ConversationArchiveProvider>;
|
||||||
let read_conversation_page =
|
let read_conversation_page =
|
||||||
Arc::new(ReadConversationPage::new(Arc::clone(&archive_provider)));
|
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(
|
let rotate_conversation_log = Arc::new(RotateConversationLog::new(
|
||||||
Arc::clone(&archive_provider),
|
Arc::clone(&archive_provider),
|
||||||
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
|
Arc::new(AppHandoffProvider) as Arc<dyn application::HandoffProvider>,
|
||||||
@ -4617,6 +4644,7 @@ impl BackendCore {
|
|||||||
get_project_work_state,
|
get_project_work_state,
|
||||||
get_app_exit_work_guard_state,
|
get_app_exit_work_guard_state,
|
||||||
read_conversation_page,
|
read_conversation_page,
|
||||||
|
custom_conversations,
|
||||||
rotate_conversation_log,
|
rotate_conversation_log,
|
||||||
attach_live_agent,
|
attach_live_agent,
|
||||||
stop_live_agent,
|
stop_live_agent,
|
||||||
|
|||||||
43
crates/domain/src/custom_conversation.rs
Normal file
43
crates/domain/src/custom_conversation.rs
Normal 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>;
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ pub mod chat_attachment;
|
|||||||
pub mod chat_draft;
|
pub mod chat_draft;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
|
pub mod custom_conversation;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
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,
|
TurnSlice, HANDOFF_SUMMARY_MAX_CHARS, MAX_ARCHIVE_SEGMENTS, PAGE_DEFAULT_LIMIT, PAGE_MAX_LIMIT,
|
||||||
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
|
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
|
||||||
};
|
};
|
||||||
|
pub use custom_conversation::{CustomConversationEntry, CustomConversationIndex};
|
||||||
|
|
||||||
pub use device::{
|
pub use device::{
|
||||||
AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash,
|
AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash,
|
||||||
|
|||||||
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal file
124
crates/infrastructure/src/conversation_log/custom_index.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,10 +46,12 @@ use domain::conversation_log::{
|
|||||||
use domain::ports::StoreError;
|
use domain::ports::StoreError;
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
|
mod custom_index;
|
||||||
mod handoff;
|
mod handoff;
|
||||||
mod providers;
|
mod providers;
|
||||||
mod summarizer;
|
mod summarizer;
|
||||||
|
|
||||||
|
pub use custom_index::FsCustomConversationIndex;
|
||||||
pub use handoff::FsHandoffStore;
|
pub use handoff::FsHandoffStore;
|
||||||
pub use providers::FsProviderSessionStore;
|
pub use providers::FsProviderSessionStore;
|
||||||
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};
|
pub use summarizer::{HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW};
|
||||||
|
|||||||
@ -62,8 +62,8 @@ pub use chat_drafts::FsChatDraftStore;
|
|||||||
pub use clock::SystemClock;
|
pub use clock::SystemClock;
|
||||||
pub use conversation::InMemoryConversationRegistry;
|
pub use conversation::InMemoryConversationRegistry;
|
||||||
pub use conversation_log::{
|
pub use conversation_log::{
|
||||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore,
|
||||||
TURN_LINE_MAX_CHARS, WINDOW,
|
HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW,
|
||||||
};
|
};
|
||||||
pub use eventbus::TokioBroadcastEventBus;
|
pub use eventbus::TokioBroadcastEventBus;
|
||||||
pub use fileguard::RwFileGuard;
|
pub use fileguard::RwFileGuard;
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
|
//! requires, and is cleaned up on drop. No extra dependency is pulled in.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::conversation::ConversationId;
|
use domain::conversation::ConversationId;
|
||||||
use domain::conversation_log::{
|
use domain::conversation_log::{
|
||||||
@ -25,12 +26,36 @@ use domain::conversation_log::{
|
|||||||
use domain::input::InputSource;
|
use domain::input::InputSource;
|
||||||
use domain::ports::StoreError;
|
use domain::ports::StoreError;
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
use domain::{AgentId, CustomConversationEntry, CustomConversationIndex};
|
||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
FsConversationLog, FsHandoffStore, FsProviderSessionStore, HeuristicHandoffSummarizer,
|
FsConversationLog, FsCustomConversationIndex, FsHandoffStore, FsProviderSessionStore,
|
||||||
TURN_LINE_MAX_CHARS, WINDOW,
|
HeuristicHandoffSummarizer, TURN_LINE_MAX_CHARS, WINDOW,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
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
|
// Test scaffolding
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -77,6 +102,138 @@ impl TempDir {
|
|||||||
.join("providers.json.tmp")
|
.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 {
|
impl Drop for TempDir {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_dir_all(&self.0);
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
|||||||
@ -86,16 +86,18 @@ use backend::dto::{
|
|||||||
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
|
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
|
||||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||||
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||||
CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto,
|
CustomConversationListDto, CustomConversationSummaryDto, CustomProjectIconAssetDto,
|
||||||
DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto,
|
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||||
EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto,
|
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
|
||||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
|
||||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||||
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||||
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
|
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
|
||||||
LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
LayoutDto, LayoutOperationDto, ListCustomConversationsRequestDto, ListLayoutsDto,
|
||||||
MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto,
|
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto,
|
||||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
|
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||||
|
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
|
||||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||||
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
|
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
|
||||||
@ -1585,13 +1587,32 @@ async fn ws_launch_agent(
|
|||||||
send_launch_agent_attached(frame, state, tx, owned, output).await
|
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(
|
async fn send_launch_agent_attached(
|
||||||
frame: &ClientFrame,
|
frame: &ClientFrame,
|
||||||
state: &Arc<ServerState>,
|
state: &Arc<ServerState>,
|
||||||
tx: &mpsc::Sender<ServerFrame>,
|
tx: &mpsc::Sender<ServerFrame>,
|
||||||
owned: &Arc<Mutex<Vec<WsAttachment>>>,
|
owned: &Arc<Mutex<Vec<WsAttachment>>>,
|
||||||
output: application::LaunchAgentOutput,
|
output: impl Into<WsLaunchOutput>,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<(), ErrorDto> {
|
||||||
|
let output = output.into();
|
||||||
|
let WsLaunchOutput {
|
||||||
|
launch: output,
|
||||||
|
history,
|
||||||
|
} = output;
|
||||||
if let Some(structured) = output.structured.as_ref() {
|
if let Some(structured) = output.structured.as_ref() {
|
||||||
let transcript_unavailable_reason =
|
let transcript_unavailable_reason =
|
||||||
ConversationTranscriptUnavailableReasonDto::for_scrollback(
|
ConversationTranscriptUnavailableReasonDto::for_scrollback(
|
||||||
@ -1608,6 +1629,7 @@ async fn send_launch_agent_attached(
|
|||||||
output.engine_session_id.as_deref(),
|
output.engine_session_id.as_deref(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
transcript_unavailable_reason,
|
transcript_unavailable_reason,
|
||||||
|
history,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ErrorDto {
|
.map_err(|_| ErrorDto {
|
||||||
@ -1632,11 +1654,48 @@ async fn send_launch_agent_attached(
|
|||||||
async fn execute_launch_agent_for_ws(
|
async fn execute_launch_agent_for_ws(
|
||||||
state: &Arc<ServerState>,
|
state: &Arc<ServerState>,
|
||||||
request: LaunchAgentRequestDto,
|
request: LaunchAgentRequestDto,
|
||||||
) -> Result<application::LaunchAgentOutput, ErrorDto> {
|
) -> Result<WsLaunchOutput, ErrorDto> {
|
||||||
let project = resolve_project_readonly(&request.project_id, &state.app).await?;
|
let project = resolve_project_readonly(&request.project_id, &state.app).await?;
|
||||||
let project_id = project.id;
|
let project_id = project.id;
|
||||||
let agent_id = parse_agent_id(&request.agent_id)?;
|
let agent_id = parse_agent_id(&request.agent_id)?;
|
||||||
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
|
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 {
|
let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
|
||||||
exe,
|
exe,
|
||||||
endpoint: backend::mcp_endpoint::mcp_endpoint(&project.id)
|
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(
|
async fn ws_attach_terminal(
|
||||||
@ -1749,6 +1811,10 @@ async fn ws_attach_terminal(
|
|||||||
assigned_conversation_id: None,
|
assigned_conversation_id: None,
|
||||||
engine_session_id: None,
|
engine_session_id: None,
|
||||||
cell_kind: backend::dto::CellKind::Pty,
|
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());
|
let next_seq = u64::from(!scrollback.is_empty());
|
||||||
tx.send(ServerFrame::attached(
|
tx.send(ServerFrame::attached(
|
||||||
@ -1917,6 +1983,7 @@ async fn ws_attach_chat(
|
|||||||
session.conversation_id().as_deref(),
|
session.conversation_id().as_deref(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
transcript_unavailable_reason,
|
transcript_unavailable_reason,
|
||||||
|
None,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ErrorDto {
|
.map_err(|_| ErrorDto {
|
||||||
@ -1949,6 +2016,52 @@ async fn ws_send_chat(
|
|||||||
message: "structured chat session not found".to_owned(),
|
message: "structured chat session not found".to_owned(),
|
||||||
})?;
|
})?;
|
||||||
let session_meta = state.app.structured_sessions.meta_for_session(&sid);
|
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, _, _)| {
|
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
|
||||||
backend::StructuredHumanTurnGuard::start_with_source(
|
backend::StructuredHumanTurnGuard::start_with_source(
|
||||||
Arc::clone(&state.app.turn_watch_input),
|
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 _ = tap_pump.join();
|
||||||
let mut turn_guard = turn_guard;
|
let mut turn_guard = turn_guard;
|
||||||
let mut stream = match stream_result {
|
let mut stream = match stream_result {
|
||||||
@ -2010,6 +2123,21 @@ async fn ws_send_chat(
|
|||||||
let mut resume = false;
|
let mut resume = false;
|
||||||
let mut resume_prompt = None;
|
let mut resume_prompt = None;
|
||||||
for event in stream {
|
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 =
|
let provider_terminal =
|
||||||
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
|
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
|
||||||
if provider_terminal {
|
if provider_terminal {
|
||||||
@ -3699,6 +3827,9 @@ async fn invoke(
|
|||||||
"git_init" => invoke_git_init(&request.args, &state.app).await,
|
"git_init" => invoke_git_init(&request.args, &state.app).await,
|
||||||
"git_graph" => invoke_git_graph(&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,
|
"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,
|
"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,
|
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
|
||||||
"cancel_background_task" => invoke_cancel_background_task(&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)
|
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> {
|
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
|
||||||
args.get("taskId")
|
args.get("taskId")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@ -6447,7 +6599,18 @@ impl ServerFrame {
|
|||||||
engine_session_id: Option<&str>,
|
engine_session_id: Option<&str>,
|
||||||
scrollback: Vec<ChatChunk>,
|
scrollback: Vec<ChatChunk>,
|
||||||
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
|
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
|
||||||
|
history: Option<application::TurnPage>,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
kind: "chat.attached".to_owned(),
|
kind: "chat.attached".to_owned(),
|
||||||
reply_to: Some(request_id.to_owned()),
|
reply_to: Some(request_id.to_owned()),
|
||||||
@ -6462,6 +6625,8 @@ impl ServerFrame {
|
|||||||
"engineSessionId": engine_session_id,
|
"engineSessionId": engine_session_id,
|
||||||
"scrollback": scrollback,
|
"scrollback": scrollback,
|
||||||
"transcriptUnavailableReason": transcript_unavailable_reason,
|
"transcriptUnavailableReason": transcript_unavailable_reason,
|
||||||
|
"history": history,
|
||||||
|
"resumed": resumed,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7946,6 +8111,10 @@ mod tests {
|
|||||||
assigned_conversation_id: None,
|
assigned_conversation_id: None,
|
||||||
engine_session_id: None,
|
engine_session_id: None,
|
||||||
cell_kind: backend::dto::CellKind::Pty,
|
cell_kind: backend::dto::CellKind::Pty,
|
||||||
|
history: Vec::new(),
|
||||||
|
history_has_more: false,
|
||||||
|
history_next_anchor: None,
|
||||||
|
resumed: false,
|
||||||
},
|
},
|
||||||
vec![4, 5, 6],
|
vec![4, 5, 6],
|
||||||
1,
|
1,
|
||||||
|
|||||||
@ -369,6 +369,12 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conversation-1",
|
assignedConversationId: "conversation-1",
|
||||||
transcriptUnavailableReason: "missingTranscript",
|
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", {
|
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
|
||||||
@ -397,6 +403,32 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
assignedConversationId: "conversation-1",
|
assignedConversationId: "conversation-1",
|
||||||
transcriptUnavailableReason: "missingTranscript",
|
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 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import type {
|
|||||||
ChatDraftScope,
|
ChatDraftScope,
|
||||||
ChatDraftSnapshot,
|
ChatDraftSnapshot,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
|
CustomAgentConversationSummary,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
LiveAgent,
|
LiveAgent,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
@ -55,6 +56,10 @@ interface LaunchAgentResponse {
|
|||||||
cellKind?: "chat" | "pty";
|
cellKind?: "chat" | "pty";
|
||||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||||
assignedConversationId?: string;
|
assignedConversationId?: string;
|
||||||
|
history?: import("@/domain").TurnView[];
|
||||||
|
historyHasMore?: boolean;
|
||||||
|
historyNextAnchor?: string;
|
||||||
|
resumed?: boolean;
|
||||||
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
|
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,12 +311,32 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
...(res.assignedConversationId
|
...(res.assignedConversationId
|
||||||
? { assignedConversationId: 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
|
...(res.transcriptUnavailableReason
|
||||||
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
|
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCustomAgentConversations(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<CustomAgentConversationSummary[]> {
|
||||||
|
const response = await invoke<{ conversations: CustomAgentConversationSummary[] }>(
|
||||||
|
"list_custom_agent_conversations",
|
||||||
|
{ request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) } },
|
||||||
|
);
|
||||||
|
return response.conversations;
|
||||||
|
}
|
||||||
|
|
||||||
async reattachAgentChat(
|
async reattachAgentChat(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
* survives JSON.
|
* survives JSON.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReplyChunk } from "@/domain";
|
import type { ReplyChunk, TurnView } from "@/domain";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Base64 helpers (binary PTY bytes over JSON frames)
|
// Base64 helpers (binary PTY bytes over JSON frames)
|
||||||
@ -156,4 +156,8 @@ export interface ChatAttachedPayload {
|
|||||||
engineSessionId?: string;
|
engineSessionId?: string;
|
||||||
scrollback?: ReplyChunk[];
|
scrollback?: ReplyChunk[];
|
||||||
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
|
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
|
||||||
|
history?: TurnView[];
|
||||||
|
historyHasMore?: boolean;
|
||||||
|
historyNextAnchor?: string;
|
||||||
|
resumed?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import type {
|
|||||||
ChatDraftScope,
|
ChatDraftScope,
|
||||||
ChatDraftSnapshot,
|
ChatDraftSnapshot,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
|
CustomAgentConversationSummary,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
CreateTicketInput,
|
CreateTicketInput,
|
||||||
LiveAgent,
|
LiveAgent,
|
||||||
@ -389,9 +390,30 @@ export class HttpAgentGateway implements AgentGateway {
|
|||||||
...(payload.transcriptUnavailableReason
|
...(payload.transcriptUnavailableReason
|
||||||
? { transcriptUnavailableReason: 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<CustomAgentConversationSummary[]> {
|
||||||
|
const response = await this.http.invoke<{
|
||||||
|
conversations: CustomAgentConversationSummary[];
|
||||||
|
}>("list_custom_agent_conversations", {
|
||||||
|
request: { projectId, agentId, ...(limit === undefined ? {} : { limit }) },
|
||||||
|
});
|
||||||
|
return response.conversations;
|
||||||
|
}
|
||||||
|
|
||||||
async reattachAgentChat(
|
async reattachAgentChat(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
|||||||
@ -118,6 +118,7 @@ import type {
|
|||||||
ConversationGateway,
|
ConversationGateway,
|
||||||
ConversationPageRequest,
|
ConversationPageRequest,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
|
CustomAgentConversationSummary,
|
||||||
CloneProfileFromSeedInput,
|
CloneProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
@ -961,6 +962,14 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listCustomAgentConversations(
|
||||||
|
_projectId: string,
|
||||||
|
_agentId: string,
|
||||||
|
_limit?: number,
|
||||||
|
): Promise<CustomAgentConversationSummary[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async reattachAgentChat(
|
async reattachAgentChat(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
_onChunk: (chunk: ReplyChunk) => void,
|
_onChunk: (chunk: ReplyChunk) => void,
|
||||||
@ -1057,6 +1066,7 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
kind: "cleanConversation",
|
kind: "cleanConversation",
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
clearedChunks: 0,
|
clearedChunks: 0,
|
||||||
|
conversationId: `mock-conversation-${options.sessionId}-${Date.now()}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1922,7 +1922,12 @@ export interface SlashCommand {
|
|||||||
|
|
||||||
export type SlashCommandEffect =
|
export type SlashCommandEffect =
|
||||||
| { kind: "help"; commands: SlashCommand[] }
|
| { kind: "help"; commands: SlashCommand[] }
|
||||||
| { kind: "cleanConversation"; sessionId: string; clearedChunks: number }
|
| {
|
||||||
|
kind: "cleanConversation";
|
||||||
|
sessionId: string;
|
||||||
|
clearedChunks: number;
|
||||||
|
conversationId?: string;
|
||||||
|
}
|
||||||
| { kind: "profileSwitch"; sessionId: string }
|
| { kind: "profileSwitch"; sessionId: string }
|
||||||
| {
|
| {
|
||||||
kind: "pluginCallback";
|
kind: "pluginCallback";
|
||||||
|
|||||||
@ -5585,7 +5585,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(shell.className).toContain("h-full");
|
expect(shell.className).toContain("h-full");
|
||||||
expect(shell.className).toContain("min-h-0");
|
expect(shell.className).toContain("min-h-0");
|
||||||
expect(shell.className).toContain("overflow-hidden");
|
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(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||||
expect(scroll.className).toContain("flex-1");
|
expect(scroll.className).toContain("flex-1");
|
||||||
expect(scroll.className).toContain("basis-0");
|
expect(scroll.className).toContain("basis-0");
|
||||||
@ -6322,4 +6322,120 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
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(
|
||||||
|
<DIProvider gateways={{
|
||||||
|
agent,
|
||||||
|
conversation: { readPage: vi.fn() },
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}>
|
||||||
|
<CustomAgentChatView
|
||||||
|
freshOnOpen
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId={null}
|
||||||
|
conversationId="stale-layout-conversation"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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<void>((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(
|
||||||
|
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||||
|
<CustomAgentChatView
|
||||||
|
freshOnOpen projectId="project-1" agentId="agent-1" agentName="Worker"
|
||||||
|
profile={profile} cwd="/repo" nodeId="node-1" sessionId={null}
|
||||||
|
conversationId={null} onSessionId={vi.fn()} onConversationId={onConversationId}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
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?.();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import type {
|
|||||||
ReplyProgressSource,
|
ReplyProgressSource,
|
||||||
ReplyProgressStage,
|
ReplyProgressStage,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
|
TurnView,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
@ -56,6 +57,7 @@ import type {
|
|||||||
ChatAttachmentInput,
|
ChatAttachmentInput,
|
||||||
ChatDraftScope,
|
ChatDraftScope,
|
||||||
ConversationTranscriptUnavailableReason,
|
ConversationTranscriptUnavailableReason,
|
||||||
|
CustomAgentConversationSummary,
|
||||||
ProjectIconRef,
|
ProjectIconRef,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
|
||||||
@ -69,6 +71,8 @@ export interface CustomAgentChatViewProps {
|
|||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
|
/** Start blank when there is no live session; durable threads reopen via the menu. */
|
||||||
|
freshOnOpen?: boolean;
|
||||||
composerHeightPx?: number;
|
composerHeightPx?: number;
|
||||||
composerDefaultHeightPx?: number;
|
composerDefaultHeightPx?: number;
|
||||||
onComposerHeightPxChange?: (heightPx: number) => void;
|
onComposerHeightPxChange?: (heightPx: number) => void;
|
||||||
@ -87,6 +91,7 @@ type ChatTurn =
|
|||||||
| { role: "final"; text: string; receivedAtMs?: number | null }
|
| { role: "final"; text: string; receivedAtMs?: number | null }
|
||||||
| { role: "error"; 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: "restart"; key: string; text: string; message?: string; failed?: boolean; receivedAtMs?: number | null }
|
||||||
|
| { role: "separator"; text: string }
|
||||||
| { role: "unknown"; text: string; receivedAtMs?: number | null };
|
| { role: "unknown"; text: string; receivedAtMs?: number | null };
|
||||||
|
|
||||||
interface CachedChatView {
|
interface CachedChatView {
|
||||||
@ -488,6 +493,25 @@ function emptyChatViewState(): ChatViewState {
|
|||||||
return { turns: [], progressIndex: new Map(), progressScope: 0 };
|
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(
|
function conversationCacheKey(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@ -999,6 +1023,7 @@ export function CustomAgentChatView({
|
|||||||
nodeId,
|
nodeId,
|
||||||
sessionId,
|
sessionId,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
freshOnOpen = false,
|
||||||
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
onComposerHeightPxChange,
|
onComposerHeightPxChange,
|
||||||
@ -1008,13 +1033,13 @@ export function CustomAgentChatView({
|
|||||||
onSessionId,
|
onSessionId,
|
||||||
onConversationId,
|
onConversationId,
|
||||||
}: CustomAgentChatViewProps) {
|
}: CustomAgentChatViewProps) {
|
||||||
const { agent, profile: profileGateway, project, system, workState } = useGateways();
|
const { agent, conversation, profile: profileGateway, project, system, workState } = useGateways();
|
||||||
const { t, tr } = useI18n();
|
const { t, tr } = useI18n();
|
||||||
const pluginRuntime = usePluginRuntime();
|
const pluginRuntime = usePluginRuntime();
|
||||||
const initialConversationCacheKey = conversationCacheKey(
|
const initialConversationCacheKey = conversationCacheKey(
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
conversationId,
|
sessionId || !freshOnOpen ? conversationId : null,
|
||||||
);
|
);
|
||||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||||
() =>
|
() =>
|
||||||
@ -1044,6 +1069,14 @@ export function CustomAgentChatView({
|
|||||||
() => new Set(),
|
() => new Set(),
|
||||||
);
|
);
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
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<string | null>(
|
||||||
|
sessionId || !freshOnOpen ? conversationId : null,
|
||||||
|
);
|
||||||
|
const activeConversationIdRef = useRef(activeConversationId);
|
||||||
|
activeConversationIdRef.current = activeConversationId;
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const initialDraftScope = explicitDraftScope ?? (
|
const initialDraftScope = explicitDraftScope ?? (
|
||||||
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
|
layoutId && nodeId ? { projectId, layoutId, nodeId } : null
|
||||||
@ -1077,7 +1110,9 @@ export function CustomAgentChatView({
|
|||||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||||
const [profileDialog, setProfileDialog] =
|
const [profileDialog, setProfileDialog] =
|
||||||
useState<ProfileCommandDialogState | null>(null);
|
useState<ProfileCommandDialogState | null>(null);
|
||||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
const [opening, setOpening] = useState(
|
||||||
|
Boolean(sessionId || (!freshOnOpen && conversationId)),
|
||||||
|
);
|
||||||
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
||||||
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
||||||
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||||
@ -1088,6 +1123,16 @@ export function CustomAgentChatView({
|
|||||||
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
|
const [stopTerminalOverride, setStopTerminalOverride] = useState(false);
|
||||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||||
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
|
const [projectIcon, setProjectIcon] = useState<ProjectIconRef | null>(null);
|
||||||
|
const [conversationMenuOpen, setConversationMenuOpen] = useState(false);
|
||||||
|
const [conversationList, setConversationList] = useState<CustomAgentConversationSummary[]>([]);
|
||||||
|
const [conversationListLoading, setConversationListLoading] = useState(false);
|
||||||
|
const [conversationListError, setConversationListError] = useState<string | null>(null);
|
||||||
|
const [conversationNotice, setConversationNotice] = useState<string | null>(null);
|
||||||
|
const [resumedConversation, setResumedConversation] = useState(false);
|
||||||
|
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||||
|
const [historyNextAnchor, setHistoryNextAnchor] = useState<string | undefined>();
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const turnsRef = useRef(turns);
|
const turnsRef = useRef(turns);
|
||||||
@ -1253,8 +1298,8 @@ export function CustomAgentChatView({
|
|||||||
[agentId, layoutId, nodeId, projectId],
|
[agentId, layoutId, nodeId, projectId],
|
||||||
);
|
);
|
||||||
const currentConversationCacheKey = useMemo(
|
const currentConversationCacheKey = useMemo(
|
||||||
() => conversationCacheKey(projectId, agentId, conversationId),
|
() => conversationCacheKey(projectId, agentId, activeConversationId),
|
||||||
[agentId, conversationId, projectId],
|
[activeConversationId, agentId, projectId],
|
||||||
);
|
);
|
||||||
const composerHistoryKey = useMemo(
|
const composerHistoryKey = useMemo(
|
||||||
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
|
() => composerHistoryStorageKey(projectId, agentId, layoutId, nodeId),
|
||||||
@ -1817,6 +1862,12 @@ export function CustomAgentChatView({
|
|||||||
onSessionIdRef.current(nextSessionId);
|
onSessionIdRef.current(nextSessionId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const publishConversationId = useCallback((nextConversationId: string | null) => {
|
||||||
|
activeConversationIdRef.current = nextConversationId;
|
||||||
|
setActiveConversationId(nextConversationId);
|
||||||
|
onConversationIdRef.current(nextConversationId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reattachStructuredSession = useCallback(
|
const reattachStructuredSession = useCallback(
|
||||||
async (
|
async (
|
||||||
sid: string,
|
sid: string,
|
||||||
@ -1857,7 +1908,7 @@ export function CustomAgentChatView({
|
|||||||
publishSessionId(reattached.sessionId);
|
publishSessionId(reattached.sessionId);
|
||||||
}
|
}
|
||||||
if (reattached.conversationId) {
|
if (reattached.conversationId) {
|
||||||
onConversationIdRef.current(reattached.conversationId);
|
publishConversationId(reattached.conversationId);
|
||||||
}
|
}
|
||||||
if (options.applyScrollback) {
|
if (options.applyScrollback) {
|
||||||
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
||||||
@ -1881,7 +1932,7 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
return reattached.sessionId;
|
return reattached.sessionId;
|
||||||
},
|
},
|
||||||
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
[agent, agentId, applyChatViewState, projectId, publishConversationId, publishSessionId, receive],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1926,7 +1977,7 @@ export function CustomAgentChatView({
|
|||||||
console.debug("[ticket149] recoverStructuredSession:start", {
|
console.debug("[ticket149] recoverStructuredSession:start", {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
currentSession: sessionRef.current,
|
currentSession: sessionRef.current,
|
||||||
conversationId,
|
conversationId: activeConversationIdRef.current,
|
||||||
options,
|
options,
|
||||||
});
|
});
|
||||||
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
|
let retryAttachNotFound = Boolean(options.retryAttachNotFound);
|
||||||
@ -1937,7 +1988,7 @@ export function CustomAgentChatView({
|
|||||||
cols: 80,
|
cols: 80,
|
||||||
conversationId:
|
conversationId:
|
||||||
options.conversationId === undefined
|
options.conversationId === undefined
|
||||||
? conversationId ?? undefined
|
? activeConversationIdRef.current ?? undefined
|
||||||
: options.conversationId ?? undefined,
|
: options.conversationId ?? undefined,
|
||||||
...(nodeId ? { nodeId } : {}),
|
...(nodeId ? { nodeId } : {}),
|
||||||
};
|
};
|
||||||
@ -1973,14 +2024,26 @@ export function CustomAgentChatView({
|
|||||||
setCurrentSession(launched.sessionId);
|
setCurrentSession(launched.sessionId);
|
||||||
publishSessionId(launched.sessionId);
|
publishSessionId(launched.sessionId);
|
||||||
if (launched.assignedConversationId) {
|
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) {
|
if (launched.transcriptUnavailableReason) {
|
||||||
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
|
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
||||||
applyScrollback: options.applyScrollback,
|
applyScrollback: launched.resumed ? false : options.applyScrollback,
|
||||||
});
|
});
|
||||||
console.debug("[ticket149] recoverStructuredSession:success", {
|
console.debug("[ticket149] recoverStructuredSession:success", {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -2012,8 +2075,9 @@ export function CustomAgentChatView({
|
|||||||
agentId,
|
agentId,
|
||||||
cwd,
|
cwd,
|
||||||
nodeId,
|
nodeId,
|
||||||
conversationId,
|
|
||||||
reattachStructuredSession,
|
reattachStructuredSession,
|
||||||
|
applyChatViewState,
|
||||||
|
publishConversationId,
|
||||||
publishSessionId,
|
publishSessionId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -2139,8 +2203,13 @@ export function CustomAgentChatView({
|
|||||||
error: instrumentationError(e),
|
error: instrumentationError(e),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (currentConversationCacheKey) {
|
} else {
|
||||||
const cached = chatConversationViewCache.get(currentConversationCacheKey);
|
const cacheKey = conversationCacheKey(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
activeConversationIdRef.current,
|
||||||
|
);
|
||||||
|
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||||
applyChatViewState({
|
applyChatViewState({
|
||||||
@ -2165,7 +2234,7 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await recoverStructuredSession({
|
await recoverStructuredSession({
|
||||||
applyScrollback: Boolean(conversationId),
|
applyScrollback: Boolean(activeConversationIdRef.current),
|
||||||
retryAttachNotFound: true,
|
retryAttachNotFound: true,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -2181,7 +2250,7 @@ export function CustomAgentChatView({
|
|||||||
sessionId: externalSessionId,
|
sessionId: externalSessionId,
|
||||||
error: instrumentationError(e),
|
error: instrumentationError(e),
|
||||||
});
|
});
|
||||||
if (!cancelled && conversationId && isNotFound(e)) {
|
if (!cancelled && activeConversationIdRef.current && isNotFound(e)) {
|
||||||
setTranscriptUnavailableReason("conversationNotFound");
|
setTranscriptUnavailableReason("conversationNotFound");
|
||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
publishSessionId(null);
|
publishSessionId(null);
|
||||||
@ -2204,8 +2273,6 @@ export function CustomAgentChatView({
|
|||||||
recoverStructuredSession,
|
recoverStructuredSession,
|
||||||
publishSessionId,
|
publishSessionId,
|
||||||
applyChatViewState,
|
applyChatViewState,
|
||||||
currentConversationCacheKey,
|
|
||||||
conversationId,
|
|
||||||
openRetryNonce,
|
openRetryNonce,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -2242,6 +2309,85 @@ export function CustomAgentChatView({
|
|||||||
setOpenRetryNonce((value) => value + 1);
|
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() {
|
async function openProfileDialog() {
|
||||||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||||
Boolean(candidate.structuredAdapter),
|
Boolean(candidate.structuredAdapter),
|
||||||
@ -2363,6 +2509,17 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
if (result.effect.kind === "cleanConversation") {
|
if (result.effect.kind === "cleanConversation") {
|
||||||
applyChatViewState(emptyChatViewState());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (result.effect.kind === "profileSwitch") {
|
if (result.effect.kind === "profileSwitch") {
|
||||||
@ -2549,10 +2706,18 @@ export function CustomAgentChatView({
|
|||||||
threadScroll.followLatest();
|
threadScroll.followLatest();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const continuingResumedConversation = resumedConversation;
|
||||||
|
if (continuingResumedConversation) {
|
||||||
|
setResumedConversation(false);
|
||||||
|
setConversationNotice(null);
|
||||||
|
}
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
turns: [
|
turns: [
|
||||||
...prev.turns,
|
...prev.turns,
|
||||||
|
...(continuingResumedConversation
|
||||||
|
? [{ role: "separator" as const, text: tr("Conversation poursuivie", "Conversation continued") }]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
text: displayText,
|
text: displayText,
|
||||||
@ -2801,7 +2966,7 @@ export function CustomAgentChatView({
|
|||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t("custom.actions")}
|
aria-label={t("custom.actions")}
|
||||||
className="relative z-20 flex shrink-0 items-center gap-2 overflow-hidden border-b border-border px-3 py-2"
|
className="relative z-20 flex shrink-0 items-center gap-2 border-b border-border px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="truncate text-sm font-medium">{agentName}</div>
|
<div className="truncate text-sm font-medium">{agentName}</div>
|
||||||
@ -2809,6 +2974,92 @@ export function CustomAgentChatView({
|
|||||||
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
|
{tr("CLI personnalisée", "Custom CLI")} · {profile.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-expanded={conversationMenuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => setConversationMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{tr("Conversations", "Conversations")}
|
||||||
|
</Button>
|
||||||
|
{conversationMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={tr("Conversations", "Conversations")}
|
||||||
|
className="absolute right-0 top-full z-50 mt-1 w-[min(22rem,calc(100vw-1rem))] overflow-hidden rounded-md border border-border bg-raised shadow-xl"
|
||||||
|
>
|
||||||
|
{effectiveBusy && (
|
||||||
|
<p className="border-b border-border bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
|
{tr(
|
||||||
|
"Arrêtez la réponse avant de changer de conversation.",
|
||||||
|
"Stop the response before changing conversations.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={effectiveBusy || opening}
|
||||||
|
className="flex min-h-11 w-full items-center px-3 text-left text-sm font-semibold text-content hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onClick={() => void startNewConversation()}
|
||||||
|
>
|
||||||
|
{tr("Nouvelle conversation", "New conversation")}
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<div className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{conversationListLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted" role="status">
|
||||||
|
<Spinner size={14} />
|
||||||
|
{tr("Chargement des conversations…", "Loading conversations…")}
|
||||||
|
</div>
|
||||||
|
) : conversationListError ? (
|
||||||
|
<div className="flex flex-col gap-2 px-3 py-3 text-xs text-danger" role="alert">
|
||||||
|
<span>{tr("Impossible de charger les conversations.", "Unable to load conversations.")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="self-start font-semibold text-content"
|
||||||
|
onClick={() => void refreshConversationList()}
|
||||||
|
>
|
||||||
|
{t("common.retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : conversationList.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-xs text-muted">
|
||||||
|
{tr("Aucune conversation passée.", "No past conversations.")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
conversationList.map((item) => {
|
||||||
|
const isOpen = item.conversationId === activeConversationId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.conversationId}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={effectiveBusy || opening || isOpen}
|
||||||
|
className="flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onClick={() => void reopenConversation(item.conversationId)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm text-content">{item.title}</span>
|
||||||
|
<span className="block text-[11px] text-muted">
|
||||||
|
{conversationActivityLabel(item.updatedAtMs)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isOpen && (
|
||||||
|
<span className="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
|
{tr("Ouverte", "Open")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasPreviousFinal && (
|
{hasPreviousFinal && (
|
||||||
<button
|
<button
|
||||||
@ -2994,6 +3245,39 @@ export function CustomAgentChatView({
|
|||||||
fallbackText={delegationFallbackText}
|
fallbackText={delegationFallbackText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{conversationNotice && (
|
||||||
|
<p role="status" className="shrink-0 rounded-md bg-success/10 px-3 py-2 text-xs text-success">
|
||||||
|
{conversationNotice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{resumedConversation && (
|
||||||
|
<div className="shrink-0 rounded-md border border-border bg-raised/75 px-3 py-2 text-xs text-muted">
|
||||||
|
<strong className="block text-content">
|
||||||
|
{tr("Conversation reprise", "Conversation resumed")}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{tr(
|
||||||
|
"Historique sauvegardé de cet agent. Vous pouvez poursuivre ci-dessous.",
|
||||||
|
"Saved history for this agent. You can continue below.",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resumedConversation && historyHasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="self-center rounded-md px-3 py-1 text-xs font-semibold text-primary hover:bg-raised disabled:opacity-50"
|
||||||
|
disabled={historyLoading}
|
||||||
|
onClick={() => void loadOlderHistory()}
|
||||||
|
>
|
||||||
|
{historyLoading
|
||||||
|
? tr("Chargement…", "Loading…")
|
||||||
|
: tr("Charger les messages précédents", "Load earlier messages")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{historyError && (
|
||||||
|
<p role="alert" className="text-center text-xs text-danger">{historyError}</p>
|
||||||
|
)}
|
||||||
{opening && displayTurns.length === 0 ? (
|
{opening && displayTurns.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted">
|
<div className="flex items-center gap-2 text-sm text-muted">
|
||||||
<Spinner size={14} />
|
<Spinner size={14} />
|
||||||
@ -3026,9 +3310,20 @@ export function CustomAgentChatView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : displayTurns.length === 0 ? (
|
) : displayTurns.length === 0 ? (
|
||||||
<p className="project-cell-empty-label text-xs text-muted">
|
<div className="project-cell-empty-label m-auto max-w-sm text-center text-xs text-muted">
|
||||||
{t("custom.empty")}
|
<strong className="block text-sm text-content">
|
||||||
</p>
|
{tr("Nouvelle conversation", "New conversation")}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{t("custom.empty")}
|
||||||
|
</span>{" "}
|
||||||
|
<span>
|
||||||
|
{tr(
|
||||||
|
"Vous pouvez aussi ouvrir une conversation passée depuis le menu Conversations.",
|
||||||
|
"You can also open a past conversation from the Conversations menu.",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayTurns.map((turn, index) => (
|
displayTurns.map((turn, index) => (
|
||||||
<ChatBubble
|
<ChatBubble
|
||||||
@ -3368,6 +3663,15 @@ function ChatBubble({
|
|||||||
onToggleProgressExpanded: (key: string) => void;
|
onToggleProgressExpanded: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { tr } = useI18n();
|
const { tr } = useI18n();
|
||||||
|
if (turn.role === "separator") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1 text-[11px] text-muted" role="separator">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
<span>{turn.text}</span>
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (turn.role === "restart") {
|
if (turn.role === "restart") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -481,7 +481,7 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
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 layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
const profileGateway = new MockProfileGateway();
|
const profileGateway = new MockProfileGateway();
|
||||||
@ -506,15 +506,13 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
});
|
});
|
||||||
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||||
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
|
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
|
||||||
sessionId: "chat-resumed",
|
sessionId: "chat-fresh",
|
||||||
cellKind: "chat" as const,
|
cellKind: "chat" as const,
|
||||||
|
assignedConversationId: "conversation-fresh",
|
||||||
});
|
});
|
||||||
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
|
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
|
||||||
sessionId: "chat-resumed",
|
sessionId: "chat-fresh",
|
||||||
scrollback: [
|
scrollback: [],
|
||||||
{ kind: "userPrompt", text: "known prompt" },
|
|
||||||
{ kind: "final", content: "known answer" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
renderGrid({
|
renderGrid({
|
||||||
@ -525,25 +523,25 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
system,
|
system,
|
||||||
} as unknown as Gateways);
|
} as unknown as Gateways);
|
||||||
|
|
||||||
expect(await screen.findByText("known answer")).toBeTruthy();
|
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
|
||||||
expect(screen.getByText("known prompt")).toBeTruthy();
|
expect(screen.queryByText("known prompt")).toBeNull();
|
||||||
expect(launchChat).toHaveBeenCalledWith(
|
expect(launchChat).toHaveBeenCalledWith(
|
||||||
"p1",
|
"p1",
|
||||||
created.id,
|
created.id,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
nodeId: leafId,
|
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 () => {
|
await waitFor(async () => {
|
||||||
const updated = await layout.loadLayout("p1");
|
const updated = await layout.loadLayout("p1");
|
||||||
const leaf = leaves(updated)[0];
|
const leaf = leaves(updated)[0];
|
||||||
expect(leaf.conversationId).toBe("conversation-known");
|
expect(leaf.conversationId).toBe("conversation-fresh");
|
||||||
expect(leaf.session).toBe("chat-resumed");
|
expect(leaf.session).toBe("chat-fresh");
|
||||||
});
|
});
|
||||||
expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée."))
|
expect(screen.getByText("Envoyez un message pour démarrer la conversation structurée."))
|
||||||
.toBeNull();
|
.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||||
|
|||||||
@ -1695,6 +1695,7 @@ function LeafView({
|
|||||||
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
||||||
<CustomAgentChatView
|
<CustomAgentChatView
|
||||||
key={`${id}-${agentId}-custom`}
|
key={`${id}-${agentId}-custom`}
|
||||||
|
freshOnOpen
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
agentName={customCliAgent.name}
|
agentName={customCliAgent.name}
|
||||||
|
|||||||
@ -200,6 +200,7 @@ export function WebAgentCell({
|
|||||||
)}
|
)}
|
||||||
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
|
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
|
||||||
<CustomAgentChatView
|
<CustomAgentChatView
|
||||||
|
freshOnOpen
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
agentName={resolvedAgent.name}
|
agentName={resolvedAgent.name}
|
||||||
|
|||||||
@ -107,6 +107,7 @@ import type {
|
|||||||
TicketPriority,
|
TicketPriority,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
TurnPage,
|
TurnPage,
|
||||||
|
TurnView,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
|
||||||
@ -207,10 +208,23 @@ export interface AgentChatHandle {
|
|||||||
readonly cellKind: "chat";
|
readonly cellKind: "chat";
|
||||||
/** Conversation id assigned by launch when the backend minted one. */
|
/** Conversation id assigned by launch when the backend minted one. */
|
||||||
readonly assignedConversationId?: string;
|
readonly assignedConversationId?: string;
|
||||||
|
/** Durable Prompt/Final history returned only for an explicit reopen. */
|
||||||
|
readonly history?: TurnView[];
|
||||||
|
readonly historyHasMore?: boolean;
|
||||||
|
readonly historyNextAnchor?: string;
|
||||||
|
readonly resumed?: boolean;
|
||||||
/** Why a successful structured open has no replayable transcript. */
|
/** Why a successful structured open has no replayable transcript. */
|
||||||
readonly transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
|
readonly transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One non-empty durable custom-CLI conversation in the agent menu. */
|
||||||
|
export interface CustomAgentConversationSummary {
|
||||||
|
conversationId: string;
|
||||||
|
title: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ConversationTranscriptUnavailableReason =
|
export type ConversationTranscriptUnavailableReason =
|
||||||
| "missingTranscript"
|
| "missingTranscript"
|
||||||
| "emptyConversation";
|
| "emptyConversation";
|
||||||
@ -364,6 +378,12 @@ export interface AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
options: OpenTerminalOptions,
|
options: OpenTerminalOptions,
|
||||||
): Promise<AgentChatHandle>;
|
): Promise<AgentChatHandle>;
|
||||||
|
/** Lists the latest durable custom-CLI conversations for one agent. */
|
||||||
|
listCustomAgentConversations?(
|
||||||
|
projectId: string,
|
||||||
|
agentId: string,
|
||||||
|
limit?: number,
|
||||||
|
): Promise<CustomAgentConversationSummary[]>;
|
||||||
/**
|
/**
|
||||||
* Re-attaches a custom chat view to a still-live structured session, replaying
|
* Re-attaches a custom chat view to a still-live structured session, replaying
|
||||||
* retained reply chunks before subsequent live chunks arrive.
|
* retained reply chunks before subsequent live chunks arrive.
|
||||||
|
|||||||
Reference in New Issue
Block a user