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:
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]
|
||||
|
||||
Reference in New Issue
Block a user