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:
2026-08-11 18:49:12 +02:00
parent bc9a034249
commit ee0ffd4e2d
16 changed files with 654 additions and 61 deletions

View File

@ -34,7 +34,7 @@ use domain::ports::{
ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource, ReplyProgressStage,
};
use crate::dto::ReplyChunk;
use crate::dto::{ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressStageDto};
use crate::stream::TauriChannelSink;
/// Maximum number of recent chat chunks retained for transport reattach replay.
@ -110,6 +110,13 @@ impl ChatBridge {
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
/// generation, leaving the scrollback intact. A pump calls this when its turn
/// stream ends: if the session was re-attached meanwhile (newer generation),
@ -136,6 +143,22 @@ impl ChatBridge {
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).
#[must_use]
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 {
match chunk {
ReplyChunk::UserPrompt { text, attachments } => {

View File

@ -2379,6 +2379,7 @@ pub async fn agent_send(
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
let tap_sid = sid;
let tap_pump = std::thread::spawn(move || {
let mut settler = crate::chat::ProgressSettler::default();
for event in tap_rx {
let Some(chunk) = crate::chat::chunk_from_event(event) else {
continue;
@ -2386,7 +2387,10 @@ pub async fn agent_send(
if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
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);
std::thread::spawn(move || {
let mut terminal_visible = false;
let mut settler = crate::chat::ProgressSettler::default();
for event in stream {
// 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
@ -2447,11 +2452,12 @@ pub async fn agent_send(
// boolean only reflects live delivery. If the view navigated away
// (no channel at this generation) we keep draining so the turn still
// 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 {
let _ = bridge.send_output(
let _ = bridge.send_output_settled(
&sid,
&mut settler,
ReplyChunk::Error {
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
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
///

View File

@ -354,6 +354,7 @@ pub fn run() {
commands::delegation_delivered,
commands::set_front_attached,
commands::reattach_agent_chat,
commands::detach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,
commands::inspect_conversation,

View File

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

View File

@ -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) {
if let Ok(mut map) = self.entries.lock() {
if let Some(entry) = map.get_mut(key) {

View File

@ -688,7 +688,7 @@ impl ReplyProgress {
/// Ajoute un extrait textuel.
#[must_use]
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
}
@ -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).
///
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté

View File

@ -153,15 +153,17 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
.and_then(Value::as_str)
.unwrap_or("outil")
.to_owned();
events.push(ReplyEvent::Progress {
progress: provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.clone(),
"tool_use",
)
.with_tool_name(label.clone()),
});
let mut progress = provider_progress(
ReplyProgressKind::Tool,
ReplyProgressStage::Started,
label.clone(),
"tool_use",
)
.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 });
}
_ => {}
@ -170,6 +172,22 @@ fn assistant_events(value: &Value) -> Vec<ReplyEvent> {
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(
kind: ReplyProgressKind,
stage: ReplyProgressStage,

View File

@ -156,6 +156,7 @@ pub fn parse_event(line: &str) -> Result<ParsedLine, AgentSessionError> {
let label = item_label(item).unwrap_or_else(|| kind.to_owned());
events.push(ReplyEvent::Progress {
progress: item_progress(
item,
&label,
ReplyProgressStage::Completed,
value
@ -190,11 +191,12 @@ fn provider_progress(
}
fn item_progress(
item: &Value,
label: &str,
stage: ReplyProgressStage,
native_event: impl Into<String>,
) -> ReplyProgress {
if label.starts_with("idea_") {
let progress = if label.starts_with("idea_") {
ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
@ -211,6 +213,11 @@ fn item_progress(
native_event,
)
.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);
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"]))
}
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> {
let mut cursor = root;
for key in path {

View File

@ -466,6 +466,7 @@ impl OpenAiCompatibleSession {
ReplyProgressStage::Started,
format!("appel MCP {}", call.name),
&call.name,
Some(call.arguments.as_str()),
);
send_tap(tap, &started);
events.push(started);
@ -485,6 +486,7 @@ impl OpenAiCompatibleSession {
ReplyProgressStage::Completed,
format!("MCP {} terminé", call.name),
&call.name,
Some(result.as_str()),
);
send_tap(tap, &completed);
events.push(completed);
@ -658,16 +660,19 @@ fn local_tool_progress(
stage: ReplyProgressStage,
label: impl Into<String>,
tool_name: &str,
preview: Option<&str>,
) -> ReplyEvent {
ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
stage,
label,
)
.with_tool_name(tool_name.to_owned()),
let mut progress = ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
stage,
label,
)
.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 {

View File

@ -31,7 +31,12 @@ pub enum ParsedEvent {
/// Fragment texte assistant.
Text(String),
/// 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.
StepFinish,
/// Erreur terminale remontée par OpenCode.
@ -110,7 +115,10 @@ fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
.and_then(Value::as_str)
.map(|text| ParsedEvent::Text(text.to_owned()))
.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)),
_ => ParsedEvent::Ignored,
};
@ -168,6 +176,39 @@ fn tool_label(value: &Value) -> String {
.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.
///
/// 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);
}
ParsedEvent::ToolActivity(label) => {
events.push(ReplyEvent::Progress {
progress: tool_progress(&label),
});
ParsedEvent::ToolActivity { label, preview } => {
let mut progress = tool_progress(&label);
if let Some(preview) = preview {
progress = progress.with_text(preview);
}
events.push(ReplyEvent::Progress { progress });
events.push(ReplyEvent::ToolActivity { label });
}
ParsedEvent::Text(text) => {
@ -396,7 +439,7 @@ fn current_turn_records(
matches!(
record.event,
ParsedEvent::Text(_)
| ParsedEvent::ToolActivity(_)
| ParsedEvent::ToolActivity { .. }
| ParsedEvent::StepStart
| ParsedEvent::StepFinish
| ParsedEvent::Error(_)
@ -871,7 +914,13 @@ mod tests {
#[test]
fn parse_jsonl_event_accepts_tool_use() {
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]