From ea22f3d9ea120fb020ba7240f488633a03361f82 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 23:05:06 +0800 Subject: [PATCH] fix(tui): restore real-time transcript growth lost in the tool-merge refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on_agent_event handler (committing messages at MessageEnd and clearing the live partial) was accidentally dropped during the merged-tool-block refactor, so only the current turn's partial was visible: thinking1 vanished when thinking2 streamed, and everything appeared only at Done. - restore on_agent_event: MessageEnd(assistant/toolResult) commits into the transcript in real time; commit_partial clears the streamed partial - tool-block state machine: committed call + result → done; committed call + running exec → running with live streamed output; otherwise → pending (a committed call no longer shows a bogus ✓ before its result arrives) - live section skips running execs already rendered via committed blocks regressions: transcript_grows_in_real_time (app-level), committed_tool_with_running_exec_shows_live_output (state-level). --- crates/focus-tui/src/app.rs | 39 +++++++++++++--- crates/focus-tui/src/state.rs | 35 ++++++++++++++- crates/focus-tui/tests/app_session_tests.rs | 40 +++++++++++++++++ crates/focus-tui/tests/state_tests.rs | 49 +++++++++++++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index 5a349a6..0d7caf5 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -6,6 +6,7 @@ use crate::config::{ProviderKind, TuiConfig}; use crate::format::display_width; use crate::job::{JobEvent, RunJob}; use crate::state::*; +use focus_core::event::AgentEvent; use focus_core::model::*; use focus_harness::estimate_messages; use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate}; @@ -389,6 +390,38 @@ impl App { self.run_state = Some(RunState::new(real_clock())); } + /// 处理一个 agent 事件:消息在 MessageEnd 时**实时**提交进 transcript, + /// 保证多轮运行中前一轮的内容(含思考、工具)不会从界面消失; + /// 否则只有当前轮的 partial 在显示,之前的回合要等整个对话结束才可见。 + /// Handle one agent event: messages are committed into the transcript in + /// **real time** at MessageEnd, so earlier turns (thinking, tools) never + /// vanish mid-run; otherwise only the current partial would show and + /// previous turns would only appear when the whole conversation ends. + pub fn on_agent_event(&mut self, ev: AgentEvent) { + match &ev { + AgentEvent::MessageEnd { message } => match message { + Message::User(_) => { + // 用户消息在 send 时已加入 transcript。 + // The user message was already added at send time. + } + Message::Assistant(_) => { + self.transcript.push(message.clone()); + if let Some(rs) = &mut self.run_state { + rs.commit_partial(); + } + } + Message::ToolResult(_) => { + self.transcript.push(message.clone()); + } + }, + _ => { + if let Some(rs) = &mut self.run_state { + rs.on_agent_event(&ev); + } + } + } + } + /// 排空后台任务事件。 /// Drain background job events. pub fn drain_job_events(&mut self) { @@ -415,11 +448,7 @@ impl App { /// Handle one background event. fn on_job_event(&mut self, ev: JobEvent) { match ev { - JobEvent::Agent(ev) => { - if let Some(rs) = &mut self.run_state { - rs.on_agent_event(&ev); - } - } + JobEvent::Agent(ev) => self.on_agent_event(ev), JobEvent::Note(n) => self.status = n, JobEvent::Done { messages, diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs index 2f080b0..b462f5a 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -378,6 +378,28 @@ pub fn build_blocks( results.entry(t.tool_call_id.as_str()).or_insert((t, mi)); } } + // 已提交的调用集合(用于判断运行中的 exec 是否已在 transcript 中呈现)。 + // Committed call ids (to know whether a running exec is already rendered + // via the committed block). + let mut committed_calls: HashSet<&str> = HashSet::new(); + for msg in transcript { + if let Message::Assistant(a) = msg { + for c in &a.content { + if let ContentBlock::ToolCall(tc) = c { + committed_calls.insert(tc.id.as_str()); + } + } + } + } + // 运行中的 exec(call_id → exec),供已提交的工具块实时取流式输出。 + // Running executions (call_id → exec), so committed tool blocks can show + // the live streamed output. + let mut running: HashMap<&str, &ToolExecUi> = HashMap::new(); + if let Some(rs) = run_state { + for exec in &rs.tool_execs { + running.entry(exec.tool_call_id.as_str()).or_insert(exec); + } + } for (mi, msg) in transcript.iter().enumerate() { match msg { Message::User(u) => { @@ -401,6 +423,11 @@ pub fn build_blocks( // The tool call merges with its result. let id = BlockId(format!("m{}-{}", mi, bi)); let result = results.get(tc.id.as_str()).copied(); + let exec = running.get(tc.id.as_str()).copied(); + // 状态机:结果已回 → 完成;结果未回但运行中 → 实时输出; + // 否则 → 待执行。 + // State machine: result back → done; not yet but + // running → live output; otherwise → pending. let (status, duration, output, is_error) = match result { Some((r, r_idx)) => ( ExecStatus::Done, @@ -408,7 +435,12 @@ pub fn build_blocks( join_text(&r.content), r.is_error, ), - None => (ExecStatus::Done, None, String::new(), false), + None => match exec { + Some(e) => { + (e.status, e.duration_ms(), e.output.clone(), e.is_error) + } + None => (ExecStatus::Pending, None, String::new(), false), + }, }; out.push(UiBlock::Tool { id, @@ -485,6 +517,7 @@ pub fn build_blocks( .iter() .enumerate() .filter(|(_, e)| e.status == ExecStatus::Running) + .filter(|(_, e)| !committed_calls.contains(e.tool_call_id.as_str())) { let id = BlockId(format!("exec-{}", ei)); out.push(UiBlock::Tool { diff --git a/crates/focus-tui/tests/app_session_tests.rs b/crates/focus-tui/tests/app_session_tests.rs index db8680f..f9536dd 100644 --- a/crates/focus-tui/tests/app_session_tests.rs +++ b/crates/focus-tui/tests/app_session_tests.rs @@ -134,3 +134,43 @@ fn scroll_event(kind: MouseEventKind) -> MouseEvent { modifiers: KeyModifiers::NONE, } } + +/// 消息在 MessageEnd 时实时提交进 transcript(回归:实时提交曾被重构丢失, +/// 导致多轮运行中前一轮的思考/工具不可见,要等整个对话结束)。 +/// Messages are committed into the transcript in real time at MessageEnd +/// (regression: the real-time commit was lost in a refactor, hiding earlier +/// thinking/tools until the whole conversation ended). +#[test] +fn transcript_grows_in_real_time() { + use focus_core::event::AgentEvent; + use focus_core::model::*; + use focus_tui::state::{real_clock, RunState}; + + let mut app = app_on(&temp_data_dir("realtime")); + app.run_state = Some(RunState::new(real_clock())); + let asst = AssistantMessage { + content: vec![ContentBlock::text("hello")], + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 0, + }; + + // 流式中:未提交。 + // Streaming: not committed yet. + app.on_agent_event(AgentEvent::MessageStart { + message: Message::Assistant(asst.clone()), + }); + assert_eq!(app.transcript.len(), 0); + assert!(app.run_state.as_ref().unwrap().partial.is_some()); + + // MessageEnd:提交 + 清 partial。 + // MessageEnd: commit + clear the partial. + app.on_agent_event(AgentEvent::MessageEnd { + message: Message::Assistant(asst.clone()), + }); + assert_eq!(app.transcript.len(), 1); + assert!(app.run_state.as_ref().unwrap().partial.is_none()); + assert!(matches!(&app.transcript[0], Message::Assistant(_))); +} diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index 3d84b18..d5074af 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -554,3 +554,52 @@ fn live_partial_renders_pending_tool_calls() { assert_eq!(tool.0, ExecStatus::Pending); assert_eq!(tool.1, "read src/main.rs"); } + +/// 已提交的工具调用在结果未回但执行中时,显示运行状态与流式输出 +/// (状态机:结果 → 完成;运行中 → 实时输出;否则 → 待执行)。 +/// A committed tool call whose result is not back yet shows the running +/// status and streamed output while executing. +#[test] +fn committed_tool_with_running_exec_shows_live_output() { + let mut args = JsonValue::obj(); + args.insert("command", "cargo build".into()).ok(); + let transcript = vec![Message::Assistant(assistant(vec![ContentBlock::ToolCall( + ToolCall { + id: "c1".into(), + name: "shell".into(), + arguments: args.clone(), + }, + )]))]; + let fc = FakeClock::new(); + let mut rs = RunState::new(fc.clock()); + rs.on_agent_event(&AgentEvent::ToolExecutionStart { + tool_call_id: "c1".into(), + tool_name: "shell".into(), + args, + }); + rs.on_agent_event(&AgentEvent::ToolExecutionUpdate { + tool_call_id: "c1".into(), + tool_name: "shell".into(), + partial_result: ToolUpdate { + content: vec![ContentBlock::text("compiling…")], + details: JsonValue::obj(), + }, + }); + let blocks = build_blocks( + &transcript, + &HashMap::new(), + &HashMap::new(), + Some(&rs), + &HashSet::new(), + &HashSet::new(), + ); + let tool = blocks + .iter() + .find_map(|b| match b { + UiBlock::Tool { status, output, .. } => Some((*status, output.clone())), + _ => None, + }) + .expect("merged tool block"); + assert_eq!(tool.0, ExecStatus::Running); + assert_eq!(tool.1, "compiling…"); +}