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:
@ -34,7 +34,7 @@ use domain::ports::{
|
|||||||
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
|
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::dto::ReplyChunk;
|
use crate::dto::{ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressStageDto};
|
||||||
use crate::stream::TauriChannelSink;
|
use crate::stream::TauriChannelSink;
|
||||||
|
|
||||||
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
||||||
@ -110,6 +110,13 @@ impl ChatBridge {
|
|||||||
self.inner.unregister(session);
|
self.inner.unregister(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detaches the current output channel while preserving the live session's
|
||||||
|
/// retained scrollback. This is the view-unmount path; it is intentionally
|
||||||
|
/// distinct from [`unregister`](Self::unregister), which is the real close.
|
||||||
|
pub fn detach(&self, session: &SessionId) {
|
||||||
|
self.inner.detach(session);
|
||||||
|
}
|
||||||
|
|
||||||
/// Detaches a session's channel **only if** `gen` is still the current
|
/// Detaches a session's channel **only if** `gen` is still the current
|
||||||
/// generation, leaving the scrollback intact. A pump calls this when its turn
|
/// generation, leaving the scrollback intact. A pump calls this when its turn
|
||||||
/// stream ends: if the session was re-attached meanwhile (newer generation),
|
/// stream ends: if the session was re-attached meanwhile (newer generation),
|
||||||
@ -136,6 +143,22 @@ impl ChatBridge {
|
|||||||
self.inner.send_output(session, chunk)
|
self.inner.send_output(session, chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends `chunk`, first completing any previous running progress entity when
|
||||||
|
/// the next chunk proves that another process/tool/MCP has taken over.
|
||||||
|
pub fn send_output_settled(
|
||||||
|
&self,
|
||||||
|
session: &SessionId,
|
||||||
|
settler: &mut ProgressSettler,
|
||||||
|
chunk: ReplyChunk,
|
||||||
|
) -> bool {
|
||||||
|
let mut delivered = true;
|
||||||
|
if let Some(completed) = settler.before(&chunk) {
|
||||||
|
delivered &= self.send_output(session, completed);
|
||||||
|
}
|
||||||
|
let current_delivered = self.send_output(session, chunk);
|
||||||
|
delivered && current_delivered
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn active_sessions(&self) -> usize {
|
pub fn active_sessions(&self) -> usize {
|
||||||
@ -143,6 +166,86 @@ impl ChatBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tracks one running progress entity for a chat stream and emits a synthetic
|
||||||
|
/// `completed` update when subsequent activity makes the previous spinner stale.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ProgressSettler {
|
||||||
|
active: Option<ReplyProgressDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressSettler {
|
||||||
|
/// Completes the currently active progress entity, if any.
|
||||||
|
pub fn finish(&mut self) -> Option<ReplyChunk> {
|
||||||
|
self.complete_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn before(&mut self, chunk: &ReplyChunk) -> Option<ReplyChunk> {
|
||||||
|
match chunk {
|
||||||
|
ReplyChunk::Progress { progress } => self.before_progress(progress),
|
||||||
|
ReplyChunk::TextDelta { .. }
|
||||||
|
| ReplyChunk::ToolActivity { .. }
|
||||||
|
| ReplyChunk::Final { .. }
|
||||||
|
| ReplyChunk::Error { .. }
|
||||||
|
| ReplyChunk::UserPrompt { .. } => self.complete_active(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn before_progress(&mut self, progress: &ReplyProgressDto) -> Option<ReplyChunk> {
|
||||||
|
if !is_settleable_progress(progress) {
|
||||||
|
return self.complete_active();
|
||||||
|
}
|
||||||
|
match progress.stage {
|
||||||
|
ReplyProgressStageDto::Started | ReplyProgressStageDto::Delta => {
|
||||||
|
let next_key = progress_entity_key(progress);
|
||||||
|
let previous = self.active.as_ref().and_then(progress_entity_key);
|
||||||
|
if previous.as_deref() == next_key.as_deref() {
|
||||||
|
self.active = Some(progress.clone());
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let completed = self.complete_active();
|
||||||
|
self.active = Some(progress.clone());
|
||||||
|
completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReplyProgressStageDto::Completed => {
|
||||||
|
let next_key = progress_entity_key(progress);
|
||||||
|
let previous = self.active.as_ref().and_then(progress_entity_key);
|
||||||
|
if previous.as_deref() == next_key.as_deref() {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ReplyProgressStageDto::Info => self.complete_active(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_active(&mut self) -> Option<ReplyChunk> {
|
||||||
|
let mut progress = self.active.take()?;
|
||||||
|
progress.stage = ReplyProgressStageDto::Completed;
|
||||||
|
Some(ReplyChunk::Progress { progress })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_settleable_progress(progress: &ReplyProgressDto) -> bool {
|
||||||
|
matches!(
|
||||||
|
progress.kind,
|
||||||
|
ReplyProgressKindDto::Tool | ReplyProgressKindDto::Mcp | ReplyProgressKindDto::Message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progress_entity_key(progress: &ReplyProgressDto) -> Option<String> {
|
||||||
|
if !is_settleable_progress(progress) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = progress
|
||||||
|
.tool_name
|
||||||
|
.as_ref()
|
||||||
|
.filter(|name| !name.trim().is_empty())
|
||||||
|
.unwrap_or(&progress.label);
|
||||||
|
let name = name.trim().to_ascii_lowercase();
|
||||||
|
(!name.is_empty()).then(|| format!("{:?}:{name}", progress.kind))
|
||||||
|
}
|
||||||
|
|
||||||
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||||
match chunk {
|
match chunk {
|
||||||
ReplyChunk::UserPrompt { text, attachments } => {
|
ReplyChunk::UserPrompt { text, attachments } => {
|
||||||
|
|||||||
@ -2379,6 +2379,7 @@ pub async fn agent_send(
|
|||||||
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
|
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
|
||||||
let tap_sid = sid;
|
let tap_sid = sid;
|
||||||
let tap_pump = std::thread::spawn(move || {
|
let tap_pump = std::thread::spawn(move || {
|
||||||
|
let mut settler = crate::chat::ProgressSettler::default();
|
||||||
for event in tap_rx {
|
for event in tap_rx {
|
||||||
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
let Some(chunk) = crate::chat::chunk_from_event(event) else {
|
||||||
continue;
|
continue;
|
||||||
@ -2386,7 +2387,10 @@ pub async fn agent_send(
|
|||||||
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let _ = tap_bridge.send_output(&tap_sid, chunk);
|
let _ = tap_bridge.send_output_settled(&tap_sid, &mut settler, chunk);
|
||||||
|
}
|
||||||
|
if let Some(completed) = settler.finish() {
|
||||||
|
let _ = tap_bridge.send_output(&tap_sid, completed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2411,6 +2415,7 @@ pub async fn agent_send(
|
|||||||
let meta = state.structured_sessions.meta_for_session(&sid);
|
let meta = state.structured_sessions.meta_for_session(&sid);
|
||||||
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();
|
||||||
for event in stream {
|
for event in stream {
|
||||||
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
// Non-terminal, sans contenu chat : un signal de limite alimente le service
|
||||||
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
|
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
|
||||||
@ -2447,11 +2452,12 @@ pub async fn agent_send(
|
|||||||
// boolean only reflects live delivery. If the view navigated away
|
// boolean only reflects live delivery. If the view navigated away
|
||||||
// (no channel at this generation) we keep draining so the turn still
|
// (no channel at this generation) we keep draining so the turn still
|
||||||
// completes and the scrollback stays whole for the next re-attach.
|
// completes and the scrollback stays whole for the next re-attach.
|
||||||
let _ = bridge.send_output(&sid, chunk);
|
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
|
||||||
}
|
}
|
||||||
if !terminal_visible {
|
if !terminal_visible {
|
||||||
let _ = bridge.send_output(
|
let _ = bridge.send_output_settled(
|
||||||
&sid,
|
&sid,
|
||||||
|
&mut settler,
|
||||||
ReplyChunk::Error {
|
ReplyChunk::Error {
|
||||||
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
|
||||||
},
|
},
|
||||||
@ -2967,6 +2973,28 @@ pub fn reattach_agent_chat(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `detach_agent_chat` — detach only the custom chat view transport from a live
|
||||||
|
/// structured session, preserving the agent process and retained scrollback.
|
||||||
|
///
|
||||||
|
/// This is the backend distinction between a disappearing view
|
||||||
|
/// (layout/tab/project switch, cell unmount) and [`close_agent_session`], which is
|
||||||
|
/// an explicit destructive close.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
|
||||||
|
/// structured session owns the id).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn detach_agent_chat(session_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
|
||||||
|
let sid = parse_session_id(&session_id)?;
|
||||||
|
if state.structured_sessions.session(&sid).is_none() {
|
||||||
|
return Err(ErrorDto::from(AppError::NotFound(format!(
|
||||||
|
"structured session {sid}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
state.chat_bridge.detach(&sid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// `close_agent_session` — shut a live structured session down and tear its
|
/// `close_agent_session` — shut a live structured session down and tear its
|
||||||
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
|
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -354,6 +354,7 @@ pub fn run() {
|
|||||||
commands::delegation_delivered,
|
commands::delegation_delivered,
|
||||||
commands::set_front_attached,
|
commands::set_front_attached,
|
||||||
commands::reattach_agent_chat,
|
commands::reattach_agent_chat,
|
||||||
|
commands::detach_agent_chat,
|
||||||
commands::close_agent_session,
|
commands::close_agent_session,
|
||||||
commands::list_resumable_agents,
|
commands::list_resumable_agents,
|
||||||
commands::inspect_conversation,
|
commands::inspect_conversation,
|
||||||
|
|||||||
@ -14,9 +14,13 @@ use std::sync::{Arc, Mutex};
|
|||||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||||
|
|
||||||
use app_tauri_lib::chat::{
|
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::ids::SessionId;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
|
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 {
|
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||||
match chunk {
|
match chunk {
|
||||||
ReplyChunk::UserPrompt { text, attachments } => {
|
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]
|
#[test]
|
||||||
fn scrollback_of_unknown_session_is_empty() {
|
fn scrollback_of_unknown_session_is_empty() {
|
||||||
let bridge = ChatBridge::new();
|
let bridge = ChatBridge::new();
|
||||||
@ -625,6 +689,71 @@ fn unregister_purges_channel_and_scrollback() {
|
|||||||
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
|
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
|
// Registry basics, mirrored from pty_bridge.rs for parity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -176,6 +176,15 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn detach(&self, key: &K) {
|
||||||
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
|
if let Some(entry) = map.get_mut(key) {
|
||||||
|
entry.sink = None;
|
||||||
|
entry.generation = entry.generation.wrapping_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn detach_if(&self, key: &K, gen: u64) {
|
pub fn detach_if(&self, key: &K, gen: u64) {
|
||||||
if let Ok(mut map) = self.entries.lock() {
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
if let Some(entry) = map.get_mut(key) {
|
if let Some(entry) = map.get_mut(key) {
|
||||||
|
|||||||
@ -688,7 +688,7 @@ impl ReplyProgress {
|
|||||||
/// Ajoute un extrait textuel.
|
/// Ajoute un extrait textuel.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
pub fn with_text(mut self, text: impl Into<String>) -> Self {
|
||||||
self.text = Some(text.into());
|
self.text = bounded_reply_progress_preview(text.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -712,6 +712,57 @@ impl ReplyProgress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bounded_reply_progress_preview(text: String) -> Option<String> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||||
|
for line in normalized.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(trimmed.to_owned());
|
||||||
|
if lines.len() == 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(lines.join("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reply_progress_tests {
|
||||||
|
use super::{ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_text_keeps_first_four_non_blank_lines() {
|
||||||
|
let progress = ReplyProgress::new(
|
||||||
|
ReplyProgressSource::ProviderNative,
|
||||||
|
ReplyProgressKind::Tool,
|
||||||
|
ReplyProgressStage::Started,
|
||||||
|
"tool",
|
||||||
|
)
|
||||||
|
.with_text(" one \n\n two\r\n three\r four\n five ");
|
||||||
|
|
||||||
|
assert_eq!(progress.text.as_deref(), Some("one\ntwo\nthree\nfour"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_text_drops_blank_preview() {
|
||||||
|
let progress = ReplyProgress::new(
|
||||||
|
ReplyProgressSource::ProviderNative,
|
||||||
|
ReplyProgressKind::Tool,
|
||||||
|
ReplyProgressStage::Started,
|
||||||
|
"tool",
|
||||||
|
)
|
||||||
|
.with_text(" \n\t\r\n ");
|
||||||
|
|
||||||
|
assert_eq!(progress.text, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
|
/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
|
||||||
///
|
///
|
||||||
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
|
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
|
||||||
|
|||||||
@ -153,15 +153,17 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("outil")
|
.unwrap_or("outil")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
events.push(ReplyEvent::Progress {
|
let mut progress = provider_progress(
|
||||||
progress: provider_progress(
|
|
||||||
ReplyProgressKind::Tool,
|
ReplyProgressKind::Tool,
|
||||||
ReplyProgressStage::Started,
|
ReplyProgressStage::Started,
|
||||||
label.clone(),
|
label.clone(),
|
||||||
"tool_use",
|
"tool_use",
|
||||||
)
|
)
|
||||||
.with_tool_name(label.clone()),
|
.with_tool_name(label.clone());
|
||||||
});
|
if let Some(preview) = tool_use_preview(block) {
|
||||||
|
progress = progress.with_text(preview);
|
||||||
|
}
|
||||||
|
events.push(ReplyEvent::Progress { progress });
|
||||||
events.push(ReplyEvent::ToolActivity { label });
|
events.push(ReplyEvent::ToolActivity { label });
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@ -170,6 +172,22 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
|
|||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_use_preview(block: &Value) -> Option<String> {
|
||||||
|
block
|
||||||
|
.get("input")
|
||||||
|
.or_else(|| block.get("arguments"))
|
||||||
|
.or_else(|| block.get("parameters"))
|
||||||
|
.map(preview_value)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_value(value: &Value) -> String {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_progress(
|
fn provider_progress(
|
||||||
kind: ReplyProgressKind,
|
kind: ReplyProgressKind,
|
||||||
stage: ReplyProgressStage,
|
stage: ReplyProgressStage,
|
||||||
|
|||||||
@ -156,6 +156,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
|
|||||||
let label = item_label(item).unwrap_or_else(|| kind.to_owned());
|
let label = item_label(item).unwrap_or_else(|| kind.to_owned());
|
||||||
events.push(ReplyEvent::Progress {
|
events.push(ReplyEvent::Progress {
|
||||||
progress: item_progress(
|
progress: item_progress(
|
||||||
|
item,
|
||||||
&label,
|
&label,
|
||||||
ReplyProgressStage::Completed,
|
ReplyProgressStage::Completed,
|
||||||
value
|
value
|
||||||
@ -190,11 +191,12 @@ fn provider_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn item_progress(
|
fn item_progress(
|
||||||
|
item: &Value,
|
||||||
label: &str,
|
label: &str,
|
||||||
stage: ReplyProgressStage,
|
stage: ReplyProgressStage,
|
||||||
native_event: impl Into<String>,
|
native_event: impl Into<String>,
|
||||||
) -> ReplyProgress {
|
) -> ReplyProgress {
|
||||||
if label.starts_with("idea_") {
|
let progress = if label.starts_with("idea_") {
|
||||||
ReplyProgress::new(
|
ReplyProgress::new(
|
||||||
ReplyProgressSource::IdeaLocal,
|
ReplyProgressSource::IdeaLocal,
|
||||||
ReplyProgressKind::Mcp,
|
ReplyProgressKind::Mcp,
|
||||||
@ -211,6 +213,11 @@ fn item_progress(
|
|||||||
native_event,
|
native_event,
|
||||||
)
|
)
|
||||||
.with_tool_name(label.to_owned())
|
.with_tool_name(label.to_owned())
|
||||||
|
};
|
||||||
|
if let Some(preview) = item_preview(item) {
|
||||||
|
progress.with_text(preview)
|
||||||
|
} else {
|
||||||
|
progress
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,7 +235,7 @@ fn append_item_progress(
|
|||||||
}
|
}
|
||||||
let label = item_label(item).unwrap_or(kind);
|
let label = item_label(item).unwrap_or(kind);
|
||||||
events.push(ReplyEvent::Progress {
|
events.push(ReplyEvent::Progress {
|
||||||
progress: item_progress(&label, stage, native_event),
|
progress: item_progress(item, &label, stage, native_event),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +262,26 @@ fn item_text(item: &Value) -> Option<String> {
|
|||||||
.or_else(|| nested_non_blank(item, &["content", "text"]))
|
.or_else(|| nested_non_blank(item, &["content", "text"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn item_preview(item: &Value) -> Option<String> {
|
||||||
|
non_blank_string(item.get("arguments"))
|
||||||
|
.or_else(|| non_blank_string(item.get("input")))
|
||||||
|
.or_else(|| non_blank_string(item.get("command")))
|
||||||
|
.or_else(|| non_blank_string(item.get("text")))
|
||||||
|
.or_else(|| non_blank_string(item.get("message")))
|
||||||
|
.or_else(|| nested_non_blank(item, &["function", "arguments"]))
|
||||||
|
.or_else(|| nested_non_blank(item, &["function_call", "arguments"]))
|
||||||
|
.or_else(|| nested_non_blank(item, &["call", "arguments"]))
|
||||||
|
.or_else(|| value_preview(item.get("arguments")))
|
||||||
|
.or_else(|| value_preview(item.get("input")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_preview(value: Option<&Value>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.filter(|value| value.is_object() || value.is_array())
|
||||||
|
.map(Value::to_string)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn nested_non_blank(root: &Value, path: &[&str]) -> Option<String> {
|
fn nested_non_blank(root: &Value, path: &[&str]) -> Option<String> {
|
||||||
let mut cursor = root;
|
let mut cursor = root;
|
||||||
for key in path {
|
for key in path {
|
||||||
|
|||||||
@ -466,6 +466,7 @@ impl OpenAiCompatibleSession {
|
|||||||
ReplyProgressStage::Started,
|
ReplyProgressStage::Started,
|
||||||
format!("appel MCP {}", call.name),
|
format!("appel MCP {}", call.name),
|
||||||
&call.name,
|
&call.name,
|
||||||
|
Some(call.arguments.as_str()),
|
||||||
);
|
);
|
||||||
send_tap(tap, &started);
|
send_tap(tap, &started);
|
||||||
events.push(started);
|
events.push(started);
|
||||||
@ -485,6 +486,7 @@ impl OpenAiCompatibleSession {
|
|||||||
ReplyProgressStage::Completed,
|
ReplyProgressStage::Completed,
|
||||||
format!("MCP {} terminé", call.name),
|
format!("MCP {} terminé", call.name),
|
||||||
&call.name,
|
&call.name,
|
||||||
|
Some(result.as_str()),
|
||||||
);
|
);
|
||||||
send_tap(tap, &completed);
|
send_tap(tap, &completed);
|
||||||
events.push(completed);
|
events.push(completed);
|
||||||
@ -658,16 +660,19 @@ fn local_tool_progress(
|
|||||||
stage: ReplyProgressStage,
|
stage: ReplyProgressStage,
|
||||||
label: impl Into<String>,
|
label: impl Into<String>,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
|
preview: Option<&str>,
|
||||||
) -> ReplyEvent {
|
) -> ReplyEvent {
|
||||||
ReplyEvent::Progress {
|
let mut progress = ReplyProgress::new(
|
||||||
progress: ReplyProgress::new(
|
|
||||||
ReplyProgressSource::IdeaLocal,
|
ReplyProgressSource::IdeaLocal,
|
||||||
ReplyProgressKind::Mcp,
|
ReplyProgressKind::Mcp,
|
||||||
stage,
|
stage,
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
.with_tool_name(tool_name.to_owned()),
|
.with_tool_name(tool_name.to_owned());
|
||||||
|
if let Some(preview) = preview {
|
||||||
|
progress = progress.with_text(preview);
|
||||||
}
|
}
|
||||||
|
ReplyEvent::Progress { progress }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool {
|
fn should_retry_without_tools(err: &AgentSessionError, using_tools: bool) -> bool {
|
||||||
|
|||||||
@ -31,7 +31,12 @@ pub enum ParsedEvent {
|
|||||||
/// Fragment texte assistant.
|
/// Fragment texte assistant.
|
||||||
Text(String),
|
Text(String),
|
||||||
/// Activité outil OpenCode.
|
/// Activité outil OpenCode.
|
||||||
ToolActivity(String),
|
ToolActivity {
|
||||||
|
/// Nom d'outil affichable.
|
||||||
|
label: String,
|
||||||
|
/// Aperçu borné du payload d'appel outil, quand OpenCode l'expose.
|
||||||
|
preview: Option<String>,
|
||||||
|
},
|
||||||
/// Fin d'étape OpenCode.
|
/// Fin d'étape OpenCode.
|
||||||
StepFinish,
|
StepFinish,
|
||||||
/// Erreur terminale remontée par OpenCode.
|
/// Erreur terminale remontée par OpenCode.
|
||||||
@ -110,7 +115,10 @@ fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(|text| ParsedEvent::Text(text.to_owned()))
|
.map(|text| ParsedEvent::Text(text.to_owned()))
|
||||||
.unwrap_or(ParsedEvent::Ignored),
|
.unwrap_or(ParsedEvent::Ignored),
|
||||||
Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity(tool_label(&value)),
|
Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity {
|
||||||
|
label: tool_label(&value),
|
||||||
|
preview: tool_preview(&value),
|
||||||
|
},
|
||||||
Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)),
|
Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)),
|
||||||
_ => ParsedEvent::Ignored,
|
_ => ParsedEvent::Ignored,
|
||||||
};
|
};
|
||||||
@ -168,6 +176,39 @@ fn tool_label(value: &Value) -> String {
|
|||||||
.unwrap_or_else(|| "outil OpenCode".to_owned())
|
.unwrap_or_else(|| "outil OpenCode".to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_preview(value: &Value) -> Option<String> {
|
||||||
|
let part = value.get("part").unwrap_or(value);
|
||||||
|
non_blank_string(part.get("arguments"))
|
||||||
|
.or_else(|| non_blank_string(part.get("input")))
|
||||||
|
.or_else(|| non_blank_string(part.get("params")))
|
||||||
|
.or_else(|| non_blank_string(part.get("command")))
|
||||||
|
.or_else(|| non_blank_string(value.get("arguments")))
|
||||||
|
.or_else(|| non_blank_string(value.get("input")))
|
||||||
|
.or_else(|| non_blank_string(value.get("params")))
|
||||||
|
.or_else(|| non_blank_string(value.get("command")))
|
||||||
|
.or_else(|| value_preview(part.get("arguments")))
|
||||||
|
.or_else(|| value_preview(part.get("input")))
|
||||||
|
.or_else(|| value_preview(part.get("params")))
|
||||||
|
.or_else(|| value_preview(value.get("arguments")))
|
||||||
|
.or_else(|| value_preview(value.get("input")))
|
||||||
|
.or_else(|| value_preview(value.get("params")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank_string(value: Option<&Value>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_preview(value: Option<&Value>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.filter(|value| value.is_object() || value.is_array())
|
||||||
|
.map(Value::to_string)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Convertit des lignes JSONL OpenCode en événements domaine.
|
/// Convertit des lignes JSONL OpenCode en événements domaine.
|
||||||
///
|
///
|
||||||
/// Le `Final` est la concaténation ordonnée des événements `text` du tour courant.
|
/// Le `Final` est la concaténation ordonnée des événements `text` du tour courant.
|
||||||
@ -226,10 +267,12 @@ fn append_record_events(
|
|||||||
});
|
});
|
||||||
events.push(ReplyEvent::Heartbeat);
|
events.push(ReplyEvent::Heartbeat);
|
||||||
}
|
}
|
||||||
ParsedEvent::ToolActivity(label) => {
|
ParsedEvent::ToolActivity { label, preview } => {
|
||||||
events.push(ReplyEvent::Progress {
|
let mut progress = tool_progress(&label);
|
||||||
progress: tool_progress(&label),
|
if let Some(preview) = preview {
|
||||||
});
|
progress = progress.with_text(preview);
|
||||||
|
}
|
||||||
|
events.push(ReplyEvent::Progress { progress });
|
||||||
events.push(ReplyEvent::ToolActivity { label });
|
events.push(ReplyEvent::ToolActivity { label });
|
||||||
}
|
}
|
||||||
ParsedEvent::Text(text) => {
|
ParsedEvent::Text(text) => {
|
||||||
@ -396,7 +439,7 @@ fn current_turn_records(
|
|||||||
matches!(
|
matches!(
|
||||||
record.event,
|
record.event,
|
||||||
ParsedEvent::Text(_)
|
ParsedEvent::Text(_)
|
||||||
| ParsedEvent::ToolActivity(_)
|
| ParsedEvent::ToolActivity { .. }
|
||||||
| ParsedEvent::StepStart
|
| ParsedEvent::StepStart
|
||||||
| ParsedEvent::StepFinish
|
| ParsedEvent::StepFinish
|
||||||
| ParsedEvent::Error(_)
|
| ParsedEvent::Error(_)
|
||||||
@ -871,7 +914,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_jsonl_event_accepts_tool_use() {
|
fn parse_jsonl_event_accepts_tool_use() {
|
||||||
let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap();
|
let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap();
|
||||||
assert_eq!(event, ParsedEvent::ToolActivity("bash".to_owned()));
|
assert_eq!(
|
||||||
|
event,
|
||||||
|
ParsedEvent::ToolActivity {
|
||||||
|
label: "bash".to_owned(),
|
||||||
|
preview: None,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -171,6 +171,15 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detachAgentChat invokes detach_agent_chat without closing the session", async () => {
|
||||||
|
await new TauriAgentGateway().detachAgentChat("chat-session-1");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("detach_agent_chat", {
|
||||||
|
sessionId: "chat-session-1",
|
||||||
|
});
|
||||||
|
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
|
it("sendAgentChat forwards structured clipboard attachments to agent_send", async () => {
|
||||||
await new TauriAgentGateway().sendAgentChat(
|
await new TauriAgentGateway().sendAgentChat(
|
||||||
"chat-session-1",
|
"chat-session-1",
|
||||||
|
|||||||
@ -262,6 +262,10 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async detachAgentChat(sessionId: string): Promise<void> {
|
||||||
|
await invoke("detach_agent_chat", { sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
async sendAgentChat(
|
async sendAgentChat(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
|||||||
@ -1026,6 +1026,15 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async detachAgentChat(sessionId: string): Promise<void> {
|
||||||
|
if (!this.chatScrollback.has(sessionId)) {
|
||||||
|
throw {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent chat session ${sessionId} not found`,
|
||||||
|
} as GatewayError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async cancelAgentChat(sessionId: string): Promise<void> {
|
async cancelAgentChat(sessionId: string): Promise<void> {
|
||||||
if (!this.chatScrollback.has(sessionId)) {
|
if (!this.chatScrollback.has(sessionId)) {
|
||||||
throw {
|
throw {
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import type { AgentProfile } from "@/domain";
|
import type { AgentProfile, ReplyChunk } from "@/domain";
|
||||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
PluginCommandRegistry,
|
PluginCommandRegistry,
|
||||||
@ -12,7 +12,10 @@ import {
|
|||||||
PluginRuntimeRegistry,
|
PluginRuntimeRegistry,
|
||||||
} from "@/plugins/runtime";
|
} from "@/plugins/runtime";
|
||||||
import type { ChatDraftScope, Gateways } from "@/ports";
|
import type { ChatDraftScope, Gateways } from "@/ports";
|
||||||
import { CustomAgentChatView } from "./CustomAgentChatView";
|
import {
|
||||||
|
CustomAgentChatView,
|
||||||
|
__clearCustomAgentChatViewCacheForTests,
|
||||||
|
} from "./CustomAgentChatView";
|
||||||
|
|
||||||
const profile: AgentProfile = {
|
const profile: AgentProfile = {
|
||||||
id: "structured",
|
id: "structured",
|
||||||
@ -57,6 +60,11 @@ function addPluginCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("CustomAgentChatView", () => {
|
describe("CustomAgentChatView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
__clearCustomAgentChatViewCacheForTests();
|
||||||
|
});
|
||||||
|
|
||||||
it("repaints attachment labels from user prompt scrollback", async () => {
|
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
@ -101,6 +109,75 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
|
||||||
|
let scrollback: ReplyChunk[] = [
|
||||||
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
||||||
|
{ kind: "textDelta" as const, text: "Initial reply" },
|
||||||
|
];
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback,
|
||||||
|
})),
|
||||||
|
detachAgentChat: vi.fn(async () => {}),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const props = {
|
||||||
|
projectId: "project-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "Worker",
|
||||||
|
profile,
|
||||||
|
cwd: "/repo",
|
||||||
|
nodeId: "node-1",
|
||||||
|
sessionId: "chat-session-1",
|
||||||
|
conversationId: "conversation-1",
|
||||||
|
onSessionId: vi.fn(),
|
||||||
|
onConversationId: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView {...props} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Initial reply")).toBeTruthy();
|
||||||
|
first.unmount();
|
||||||
|
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
|
||||||
|
|
||||||
|
scrollback = [
|
||||||
|
...scrollback,
|
||||||
|
{ kind: "toolActivity" as const, label: "Background tool" },
|
||||||
|
{ kind: "final" as const, content: "Completed away" },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView {...props} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Initial reply")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("Background tool")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Completed away")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
||||||
let resolveStop: (value: {
|
let resolveStop: (value: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@ -614,7 +691,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Worker → QA")).toBeTruthy();
|
expect(screen.getByText("Worker → QA")).toBeTruthy();
|
||||||
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
|
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
|
||||||
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
|
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitChunk?.(progress("providerNative", "completed"));
|
emitChunk?.(progress("providerNative", "completed"));
|
||||||
@ -638,7 +715,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops animating running progress rows once the turn reaches a final chunk", async () => {
|
it("renders running progress without spinner and marks it done on final", async () => {
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
@ -707,7 +784,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
|
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
||||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
|
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@ -53,6 +53,17 @@ type ChatTurn =
|
|||||||
| { role: "error"; text: string }
|
| { role: "error"; text: string }
|
||||||
| { role: "unknown"; text: string };
|
| { role: "unknown"; text: string };
|
||||||
|
|
||||||
|
interface CachedChatView {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
chunkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||||
|
|
||||||
|
export function __clearCustomAgentChatViewCacheForTests() {
|
||||||
|
chatSessionViewCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
interface AttachmentDraft {
|
interface AttachmentDraft {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -288,6 +299,17 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[]
|
|||||||
return [...turns, { role: "progress", progress }];
|
return [...turns, { role: "progress", progress }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reattachNotice(missedChunks: number): ChatTurn | null {
|
||||||
|
if (missedChunks <= 0) return null;
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
label:
|
||||||
|
missedChunks === 1
|
||||||
|
? "Reprise effectuée: 1 événement rattrapé pendant l'absence."
|
||||||
|
: `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mergeCompletedProgress(
|
function mergeCompletedProgress(
|
||||||
running: ReplyProgress,
|
running: ReplyProgress,
|
||||||
completed: ReplyProgress,
|
completed: ReplyProgress,
|
||||||
@ -522,7 +544,9 @@ export function CustomAgentChatView({
|
|||||||
}: CustomAgentChatViewProps) {
|
}: CustomAgentChatViewProps) {
|
||||||
const { agent, profile: profileGateway, system } = useGateways();
|
const { agent, profile: profileGateway, system } = useGateways();
|
||||||
const pluginRuntime = usePluginRuntime();
|
const pluginRuntime = usePluginRuntime();
|
||||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||||
|
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
||||||
|
);
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@ -540,6 +564,11 @@ export function CustomAgentChatView({
|
|||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const sessionRef = useRef<string | null>(sessionId);
|
const sessionRef = useRef<string | null>(sessionId);
|
||||||
sessionRef.current = currentSession;
|
sessionRef.current = currentSession;
|
||||||
|
const scrollbackChunkCountRef = useRef(
|
||||||
|
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
|
||||||
|
);
|
||||||
|
const detachAgentChatRef = useRef(agent.detachAgentChat);
|
||||||
|
detachAgentChatRef.current = agent.detachAgentChat;
|
||||||
const slashQuerySeqRef = useRef(0);
|
const slashQuerySeqRef = useRef(0);
|
||||||
const draftSaveSeqRef = useRef(0);
|
const draftSaveSeqRef = useRef(0);
|
||||||
const draftHydratedRef = useRef(false);
|
const draftHydratedRef = useRef(false);
|
||||||
@ -593,14 +622,30 @@ export function CustomAgentChatView({
|
|||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
const sid = sessionRef.current;
|
||||||
|
if (!sid) return;
|
||||||
|
void detachAgentChatRef.current?.(sid).catch((e) => {
|
||||||
|
if (!isNotFound(e)) {
|
||||||
|
console.warn("[ticket188] detachAgentChat failed", instrumentationError(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const receive = useCallback((chunk: ReplyChunk) => {
|
const receive = useCallback((chunk: ReplyChunk) => {
|
||||||
|
scrollbackChunkCountRef.current += 1;
|
||||||
setTurns((prev) => foldChunk(prev, chunk));
|
setTurns((prev) => foldChunk(prev, chunk));
|
||||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession) return;
|
||||||
|
chatSessionViewCache.set(currentSession, {
|
||||||
|
turns,
|
||||||
|
chunkCount: scrollbackChunkCountRef.current,
|
||||||
|
});
|
||||||
|
}, [currentSession, turns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draftSaveSeqRef.current += 1;
|
draftSaveSeqRef.current += 1;
|
||||||
draftHydratedRef.current = false;
|
draftHydratedRef.current = false;
|
||||||
@ -748,7 +793,14 @@ export function CustomAgentChatView({
|
|||||||
if (!mountedRef.current) return reattached.sessionId;
|
if (!mountedRef.current) return reattached.sessionId;
|
||||||
setCurrentSession(reattached.sessionId);
|
setCurrentSession(reattached.sessionId);
|
||||||
if (options.applyScrollback) {
|
if (options.applyScrollback) {
|
||||||
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
|
const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0;
|
||||||
|
scrollbackChunkCountRef.current = Math.max(
|
||||||
|
scrollbackChunkCountRef.current,
|
||||||
|
reattached.scrollback.length,
|
||||||
|
);
|
||||||
|
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
|
||||||
|
const notice = reattachNotice(reattached.scrollback.length - cachedCount);
|
||||||
|
setTurns(notice ? [...replayed, notice] : replayed);
|
||||||
}
|
}
|
||||||
return reattached.sessionId;
|
return reattached.sessionId;
|
||||||
},
|
},
|
||||||
@ -810,6 +862,7 @@ export function CustomAgentChatView({
|
|||||||
launched,
|
launched,
|
||||||
});
|
});
|
||||||
if (!mountedRef.current) return launched.sessionId;
|
if (!mountedRef.current) return launched.sessionId;
|
||||||
|
scrollbackChunkCountRef.current = 0;
|
||||||
setCurrentSession(launched.sessionId);
|
setCurrentSession(launched.sessionId);
|
||||||
publishSessionId(launched.sessionId);
|
publishSessionId(launched.sessionId);
|
||||||
if (launched.assignedConversationId) {
|
if (launched.assignedConversationId) {
|
||||||
@ -888,6 +941,11 @@ export function CustomAgentChatView({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (externalSessionId) {
|
if (externalSessionId) {
|
||||||
|
const cached = chatSessionViewCache.get(externalSessionId);
|
||||||
|
if (cached) {
|
||||||
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||||
|
setTurns(cached.turns);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
|
await reattachStructuredSession(externalSessionId, { applyScrollback: true });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -1385,7 +1443,9 @@ export function CustomAgentChatView({
|
|||||||
{opening && turns.length === 0 ? (
|
{opening && turns.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} />
|
||||||
<span>Ouverture de la session structurée…</span>
|
<span>
|
||||||
|
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : turns.length === 0 ? (
|
) : turns.length === 0 ? (
|
||||||
<p className="m-auto text-xs text-muted">
|
<p className="m-auto text-xs text-muted">
|
||||||
@ -1572,7 +1632,6 @@ function ChatBubble({
|
|||||||
<ProgressBubble
|
<ProgressBubble
|
||||||
progress={turn.progress}
|
progress={turn.progress}
|
||||||
requesterName={requesterName}
|
requesterName={requesterName}
|
||||||
settled={turn.settled}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1683,9 +1742,20 @@ function compactToolName(progress: ReplyProgress): string {
|
|||||||
return progress.toolName ?? progress.label;
|
return progress.toolName ?? progress.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateText(text: string, max = 140): string {
|
function previewText(text: string, maxLineLength = 160): string {
|
||||||
const flat = text.replace(/\s+/g, " ").trim();
|
return text
|
||||||
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((line) =>
|
||||||
|
line.length > maxLineLength
|
||||||
|
? `${line.slice(0, Math.max(0, maxLineLength - 3))}...`
|
||||||
|
: line,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseKeyValue(text: string, key: string): string | null {
|
function parseKeyValue(text: string, key: string): string | null {
|
||||||
@ -1710,9 +1780,9 @@ function delegationSummary(progress: ReplyProgress): {
|
|||||||
requester,
|
requester,
|
||||||
target,
|
target,
|
||||||
excerpt: task
|
excerpt: task
|
||||||
? truncateText(task, 92)
|
? previewText(task, 92)
|
||||||
: fallback
|
: fallback
|
||||||
? truncateText(fallback, 92)
|
? previewText(fallback, 92)
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1743,16 +1813,13 @@ function TurnMarker({ progress }: { progress: ReplyProgress }) {
|
|||||||
function ToolProgressCard({
|
function ToolProgressCard({
|
||||||
progress,
|
progress,
|
||||||
requesterName,
|
requesterName,
|
||||||
settled,
|
|
||||||
}: {
|
}: {
|
||||||
progress: ReplyProgress;
|
progress: ReplyProgress;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
settled: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
|
|
||||||
const stage = progressStageLabel(progress.stage);
|
const stage = progressStageLabel(progress.stage);
|
||||||
const detail =
|
const detail =
|
||||||
progress.text && !isDelegationProgress(progress) ? truncateText(progress.text) : null;
|
progress.text && !isDelegationProgress(progress) ? previewText(progress.text) : null;
|
||||||
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
const delegation = isDelegationProgress(progress) ? delegationSummary(progress) : null;
|
||||||
const title = delegation
|
const title = delegation
|
||||||
? delegation.target
|
? delegation.target
|
||||||
@ -1770,7 +1837,6 @@ function ToolProgressCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center justify-center gap-1.5">
|
<div className="flex min-w-0 items-center justify-center gap-1.5">
|
||||||
{running && <Spinner size={12} />}
|
|
||||||
<span className="shrink-0 font-semibold">{title}</span>
|
<span className="shrink-0 font-semibold">{title}</span>
|
||||||
{!delegation && (
|
{!delegation && (
|
||||||
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
|
<span className="min-w-0 truncate text-muted">{compactToolName(progress)}</span>
|
||||||
@ -1787,8 +1853,19 @@ function ToolProgressCard({
|
|||||||
{compactToolName(progress)}
|
{compactToolName(progress)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{(progress.source || progress.nativeEvent || progress.provider) && (
|
||||||
|
<div className="mt-0.5 flex min-w-0 flex-wrap justify-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||||
|
<span>{progressSourceLabel(progress)}</span>
|
||||||
|
{progress.nativeEvent && (
|
||||||
|
<span className="truncate">event: {progress.nativeEvent}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{detail && (
|
{detail && (
|
||||||
<p className="mt-1 text-center text-[11px] text-content" style={fourLineClamp}>
|
<p
|
||||||
|
className="mt-1 overflow-hidden whitespace-pre-wrap break-words text-center text-[11px] text-content"
|
||||||
|
style={fourLineClamp}
|
||||||
|
>
|
||||||
{detail}
|
{detail}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -1800,11 +1877,9 @@ function ToolProgressCard({
|
|||||||
function ProgressBubble({
|
function ProgressBubble({
|
||||||
progress,
|
progress,
|
||||||
requesterName,
|
requesterName,
|
||||||
settled = false,
|
|
||||||
}: {
|
}: {
|
||||||
progress: ReplyProgress;
|
progress: ReplyProgress;
|
||||||
requesterName: string;
|
requesterName: string;
|
||||||
settled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
if (isTimelineProgress(progress)) return <TurnMarker progress={progress} />;
|
||||||
if (isCenteredToolProgress(progress)) {
|
if (isCenteredToolProgress(progress)) {
|
||||||
@ -1812,15 +1887,13 @@ function ProgressBubble({
|
|||||||
<ToolProgressCard
|
<ToolProgressCard
|
||||||
progress={progress}
|
progress={progress}
|
||||||
requesterName={requesterName}
|
requesterName={requesterName}
|
||||||
settled={settled}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const title = progressTitle(progress);
|
const title = progressTitle(progress);
|
||||||
const stage = progressStageLabel(progress.stage);
|
const stage = progressStageLabel(progress.stage);
|
||||||
const source = progressSourceLabel(progress);
|
const source = progressSourceLabel(progress);
|
||||||
const detail = progress.text ? truncateText(progress.text, 260) : null;
|
const detail = progress.text ? previewText(progress.text, 180) : null;
|
||||||
const running = !settled && (progress.stage === "started" || progress.stage === "delta");
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
<div data-testid="chat-progress-status" className="flex min-w-0 justify-end">
|
||||||
<div
|
<div
|
||||||
@ -1830,7 +1903,6 @@ function ProgressBubble({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
{running && <Spinner size={12} />}
|
|
||||||
<span className="shrink-0 font-semibold">{title}</span>
|
<span className="shrink-0 font-semibold">{title}</span>
|
||||||
<span className="min-w-0 truncate text-muted">{progress.label}</span>
|
<span className="min-w-0 truncate text-muted">{progress.label}</span>
|
||||||
<span className="shrink-0 text-muted">· {stage}</span>
|
<span className="shrink-0 text-muted">· {stage}</span>
|
||||||
|
|||||||
@ -311,6 +311,8 @@ export interface AgentGateway {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
onChunk: (chunk: ReplyChunk) => void,
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
): Promise<ReattachAgentChatResult>;
|
): Promise<ReattachAgentChatResult>;
|
||||||
|
/** Detaches the current custom chat view transport while keeping the session alive. */
|
||||||
|
detachAgentChat?(sessionId: string): Promise<void>;
|
||||||
/** Sends one user prompt to a live structured session. */
|
/** Sends one user prompt to a live structured session. */
|
||||||
sendAgentChat?(
|
sendAgentChat?(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user