fix(tui): restore real-time transcript growth lost in the tool-merge refactor

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).
This commit is contained in:
DaiChaoXiong 2026-08-09 23:05:06 +08:00
parent 71d83d23ed
commit ea22f3d9ea
4 changed files with 157 additions and 6 deletions

View File

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

View File

@ -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());
}
}
}
}
// 运行中的 execcall_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 {

View File

@ -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(_)));
}

View File

@ -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…");
}