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:
parent
71d83d23ed
commit
ea22f3d9ea
|
|
@ -6,6 +6,7 @@ use crate::config::{ProviderKind, TuiConfig};
|
||||||
use crate::format::display_width;
|
use crate::format::display_width;
|
||||||
use crate::job::{JobEvent, RunJob};
|
use crate::job::{JobEvent, RunJob};
|
||||||
use crate::state::*;
|
use crate::state::*;
|
||||||
|
use focus_core::event::AgentEvent;
|
||||||
use focus_core::model::*;
|
use focus_core::model::*;
|
||||||
use focus_harness::estimate_messages;
|
use focus_harness::estimate_messages;
|
||||||
use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate};
|
use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate};
|
||||||
|
|
@ -389,6 +390,38 @@ impl App {
|
||||||
self.run_state = Some(RunState::new(real_clock()));
|
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.
|
/// Drain background job events.
|
||||||
pub fn drain_job_events(&mut self) {
|
pub fn drain_job_events(&mut self) {
|
||||||
|
|
@ -415,11 +448,7 @@ impl App {
|
||||||
/// Handle one background event.
|
/// Handle one background event.
|
||||||
fn on_job_event(&mut self, ev: JobEvent) {
|
fn on_job_event(&mut self, ev: JobEvent) {
|
||||||
match ev {
|
match ev {
|
||||||
JobEvent::Agent(ev) => {
|
JobEvent::Agent(ev) => self.on_agent_event(ev),
|
||||||
if let Some(rs) = &mut self.run_state {
|
|
||||||
rs.on_agent_event(&ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JobEvent::Note(n) => self.status = n,
|
JobEvent::Note(n) => self.status = n,
|
||||||
JobEvent::Done {
|
JobEvent::Done {
|
||||||
messages,
|
messages,
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,28 @@ pub fn build_blocks(
|
||||||
results.entry(t.tool_call_id.as_str()).or_insert((t, mi));
|
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() {
|
for (mi, msg) in transcript.iter().enumerate() {
|
||||||
match msg {
|
match msg {
|
||||||
Message::User(u) => {
|
Message::User(u) => {
|
||||||
|
|
@ -401,6 +423,11 @@ pub fn build_blocks(
|
||||||
// The tool call merges with its result.
|
// The tool call merges with its result.
|
||||||
let id = BlockId(format!("m{}-{}", mi, bi));
|
let id = BlockId(format!("m{}-{}", mi, bi));
|
||||||
let result = results.get(tc.id.as_str()).copied();
|
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 {
|
let (status, duration, output, is_error) = match result {
|
||||||
Some((r, r_idx)) => (
|
Some((r, r_idx)) => (
|
||||||
ExecStatus::Done,
|
ExecStatus::Done,
|
||||||
|
|
@ -408,7 +435,12 @@ pub fn build_blocks(
|
||||||
join_text(&r.content),
|
join_text(&r.content),
|
||||||
r.is_error,
|
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 {
|
out.push(UiBlock::Tool {
|
||||||
id,
|
id,
|
||||||
|
|
@ -485,6 +517,7 @@ pub fn build_blocks(
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, e)| e.status == ExecStatus::Running)
|
.filter(|(_, e)| e.status == ExecStatus::Running)
|
||||||
|
.filter(|(_, e)| !committed_calls.contains(e.tool_call_id.as_str()))
|
||||||
{
|
{
|
||||||
let id = BlockId(format!("exec-{}", ei));
|
let id = BlockId(format!("exec-{}", ei));
|
||||||
out.push(UiBlock::Tool {
|
out.push(UiBlock::Tool {
|
||||||
|
|
|
||||||
|
|
@ -134,3 +134,43 @@ fn scroll_event(kind: MouseEventKind) -> MouseEvent {
|
||||||
modifiers: KeyModifiers::NONE,
|
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(_)));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -554,3 +554,52 @@ fn live_partial_renders_pending_tool_calls() {
|
||||||
assert_eq!(tool.0, ExecStatus::Pending);
|
assert_eq!(tool.0, ExecStatus::Pending);
|
||||||
assert_eq!(tool.1, "read src/main.rs");
|
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…");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue