fix(chat): réattache persistante CLI custom et rendu tools/MCP/progress enrichi (#188)
Corrige la perte de session à la navigation: le démontage du CustomAgentChatView appelait close_agent_session (destructeur) au lieu de simplement détacher le transport. Désormais: - nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le canal sans tuer la session ni le scrollback retenu; distinct de close_agent_session qui reste le close destructeur explicite; - le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un cache de session (turns + chunkCount) à la réattache; - message « Reprise effectuée: N événements rattrapés… » quand la réattache rejoue plus de chunks que le cache local n'en avait conservé. Enrichit le rendu tools/MCP/progress: - ProgressSettler (chat bridge) émet un progress completed synthétique quand un spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini les spinners fantômes; - les adapters claude/codex/opencode/openai_compat extraient un aperçu du payload (arguments/input/command/message/params) attaché au progress; - bounded_reply_progress_preview (domain) garde les 4 premières lignes non vides, normalisation CR/LF; - previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent affichés sous le titre; suppression du flag settled et du Spinner inline devenus redondants (l'état est porté par le stage + le settler). Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage reply_progress, item_preview/tool_preview par adapter, réattache avec cache et notice de rattrapage.
This commit is contained in:
@ -14,9 +14,13 @@ use std::sync::{Arc, Mutex};
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
|
||||
use app_tauri_lib::chat::{
|
||||
chunk_from_event, ChatBridge, MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS,
|
||||
chunk_from_event, ChatBridge, ProgressSettler, MAX_CHAT_SCROLLBACK_BYTES,
|
||||
MAX_CHAT_SCROLLBACK_CHUNKS,
|
||||
};
|
||||
use app_tauri_lib::dto::{
|
||||
ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto,
|
||||
ReplyProgressStageDto,
|
||||
};
|
||||
use app_tauri_lib::dto::ReplyChunk;
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::{
|
||||
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
|
||||
@ -59,6 +63,25 @@ fn error_chunk(s: &str) -> ReplyChunk {
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_chunk(
|
||||
kind: ReplyProgressKindDto,
|
||||
stage: ReplyProgressStageDto,
|
||||
label: &str,
|
||||
) -> ReplyChunk {
|
||||
ReplyChunk::Progress {
|
||||
progress: ReplyProgressDto {
|
||||
source: ReplyProgressSourceDto::ProviderNative,
|
||||
kind,
|
||||
stage,
|
||||
label: label.to_owned(),
|
||||
text: None,
|
||||
provider: None,
|
||||
native_event: None,
|
||||
tool_name: Some(label.to_owned()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||
match chunk {
|
||||
ReplyChunk::UserPrompt { text, attachments } => {
|
||||
@ -458,6 +481,47 @@ fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_view_keeps_session_scrollback_and_later_reattach_replays_absent_chunks() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let first_sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let _gen = bridge.register(session, capturing_channel(Arc::clone(&first_sink)));
|
||||
|
||||
assert!(bridge.send_output(&session, delta("before")));
|
||||
bridge.detach(&session);
|
||||
|
||||
assert!(
|
||||
!bridge.send_output(&session, delta("during")),
|
||||
"detached view receives nothing live"
|
||||
);
|
||||
assert_eq!(
|
||||
bridge.active_sessions(),
|
||||
1,
|
||||
"detach keeps session registered"
|
||||
);
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
vec![delta("before"), delta("during")],
|
||||
"chunks produced while detached are retained for reattach"
|
||||
);
|
||||
assert_eq!(first_sink.lock().unwrap().as_slice(), &[delta("before")]);
|
||||
|
||||
let reattach_sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&reattach_sink)));
|
||||
assert!(bridge.send_output(&session, final_chunk("done")));
|
||||
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session),
|
||||
vec![delta("before"), delta("during"), final_chunk("done")]
|
||||
);
|
||||
assert_eq!(
|
||||
reattach_sink.lock().unwrap().as_slice(),
|
||||
&[final_chunk("done")],
|
||||
"reattach receives only new live chunks; returned scrollback replays history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_of_unknown_session_is_empty() {
|
||||
let bridge = ChatBridge::new();
|
||||
@ -625,6 +689,71 @@ fn unregister_purges_channel_and_scrollback() {
|
||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_output_settled_completes_stale_tool_progress_before_next_activity() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
let mut settler = ProgressSettler::default();
|
||||
|
||||
let started = progress_chunk(
|
||||
ReplyProgressKindDto::Tool,
|
||||
ReplyProgressStageDto::Started,
|
||||
"read_file",
|
||||
);
|
||||
let next = progress_chunk(
|
||||
ReplyProgressKindDto::Mcp,
|
||||
ReplyProgressStageDto::Started,
|
||||
"idea_ask_agent",
|
||||
);
|
||||
|
||||
assert!(bridge.send_output_settled(&session, &mut settler, started.clone()));
|
||||
assert!(bridge.send_output_settled(&session, &mut settler, next.clone()));
|
||||
assert!(bridge.send_output_settled(&session, &mut settler, delta("answer")));
|
||||
|
||||
let received = sink.lock().unwrap().clone();
|
||||
assert_eq!(received.len(), 5);
|
||||
assert_eq!(received[0], started);
|
||||
assert!(matches!(
|
||||
&received[1],
|
||||
ReplyChunk::Progress { progress }
|
||||
if progress.label == "read_file"
|
||||
&& progress.stage == ReplyProgressStageDto::Completed
|
||||
));
|
||||
assert_eq!(received[2], next);
|
||||
assert!(matches!(
|
||||
&received[3],
|
||||
ReplyChunk::Progress { progress }
|
||||
if progress.label == "idea_ask_agent"
|
||||
&& progress.stage == ReplyProgressStageDto::Completed
|
||||
));
|
||||
assert_eq!(received[4], delta("answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_settler_finish_completes_active_progress_at_stream_end() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let sink = Arc::new(Mutex::new(Vec::new()));
|
||||
bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
||||
let mut settler = ProgressSettler::default();
|
||||
let started = progress_chunk(
|
||||
ReplyProgressKindDto::Tool,
|
||||
ReplyProgressStageDto::Started,
|
||||
"write_file",
|
||||
);
|
||||
|
||||
assert!(bridge.send_output_settled(&session, &mut settler, started));
|
||||
|
||||
let Some(ReplyChunk::Progress { progress: done }) = settler.finish() else {
|
||||
panic!("finish must complete the active progress")
|
||||
};
|
||||
assert_eq!(done.label, "write_file");
|
||||
assert_eq!(done.stage, ReplyProgressStageDto::Completed);
|
||||
assert!(settler.finish().is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry basics, mirrored from pty_bridge.rs for parity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user