fix(tui): surface concrete run errors instead of a bare error header

StreamEvent::Error is encoded by the agent loop as an assistant message with
stop_reason=error and error_message, but the UI only rendered the header and
the (empty) content, hiding the actual message (e.g. a provider HTTP 400
detail). Now:

- UiBlock::Error renders error_message in red, for both committed and live
  assistant messages
- on job Done, the error is also surfaced into the status bar and a note,
  even when the job itself reported no error (it was encoded as a message)
- regression test: an error message renders its concrete text
This commit is contained in:
DaiChaoXiong 2026-08-09 21:48:29 +08:00
parent 4ffd82b096
commit a9de6fe24e
4 changed files with 66 additions and 3 deletions

View File

@ -438,8 +438,22 @@ impl App {
self.persist_entries(&to_persist); self.persist_entries(&to_persist);
self.transcript = messages; self.transcript = messages;
if let Some(err) = error { // 错误优先取 job 报告的 error若错误被编码成消息如 provider 的
self.status = format!("{}", err); // HTTP 400 → StreamEvent::Error → assistant error 消息),
// 从最后一条 assistant 消息的 error_message 提取。
// Prefer the job's reported error; when the error is encoded as
// a message (provider HTTP 400 → StreamEvent::Error → an
// assistant error message), extract it from the last assistant.
let surfaced = error.or_else(|| {
self.transcript.iter().rev().find_map(|m| match m {
Message::Assistant(a) if a.stop_reason == StopReason::Error => {
a.error_message.clone()
}
_ => None,
})
});
if let Some(err) = surfaced {
self.status = format!("{}", crate::format::truncate(&err, 80));
self.push_note(format!("运行出错:{}", err)); self.push_note(format!("运行出错:{}", err));
} else { } else {
self.status = "✓ 完成".into(); self.status = "✓ 完成".into();

View File

@ -77,6 +77,9 @@ pub enum UiBlock {
is_error: bool, is_error: bool,
expanded: bool, expanded: bool,
}, },
/// 运行错误assistant 消息的 error_message红色显示
/// A run error (an assistant message's error_message, shown in red).
Error { id: BlockId, text: String },
/// 工具执行过程(运行中 / 完成,含耗时与输出)。 /// 工具执行过程(运行中 / 完成,含耗时与输出)。
/// A tool execution (running / done, with duration and output). /// A tool execution (running / done, with duration and output).
ToolExec { ToolExec {
@ -382,6 +385,14 @@ pub fn build_blocks(
expanded, expanded,
); );
} }
// 具体错误文本(如 provider 的 HTTP 400 详情)。
// The concrete error text (e.g. a provider HTTP 400 detail).
if let Some(err) = &a.error_message {
out.push(UiBlock::Error {
id: BlockId(format!("m{}-err", mi)),
text: err.clone(),
});
}
} }
Message::ToolResult(t) => { Message::ToolResult(t) => {
out.push(UiBlock::ToolResult { out.push(UiBlock::ToolResult {
@ -414,6 +425,12 @@ pub fn build_blocks(
expanded, expanded,
); );
} }
if let Some(err) = &partial.error_message {
out.push(UiBlock::Error {
id: BlockId("live-err".into()),
text: err.clone(),
});
}
} }
for (ei, exec) in rs.tool_execs.iter().enumerate() { for (ei, exec) in rs.tool_execs.iter().enumerate() {
out.push(UiBlock::ToolExec { out.push(UiBlock::ToolExec {
@ -485,7 +502,8 @@ impl UiBlock {
| UiBlock::Thinking { id, .. } | UiBlock::Thinking { id, .. }
| UiBlock::ToolCall { id, .. } | UiBlock::ToolCall { id, .. }
| UiBlock::ToolResult { id, .. } | UiBlock::ToolResult { id, .. }
| UiBlock::ToolExec { id, .. } => Some(id), | UiBlock::ToolExec { id, .. }
| UiBlock::Error { id, .. } => Some(id),
UiBlock::Note { .. } => None, UiBlock::Note { .. } => None,
} }
} }

View File

@ -271,6 +271,14 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
))); )));
} }
} }
UiBlock::Error { text, .. } => {
for line in wrap_text(text, width) {
out.push(Line::from(Span::styled(
line,
Style::new().fg(Color::Red).bold(),
)));
}
}
UiBlock::ToolCall { UiBlock::ToolCall {
name, name,
summary, summary,

View File

@ -279,3 +279,26 @@ fn working_phase_detection() {
}); });
assert_eq!(rs.working_phase(), "streaming…"); assert_eq!(rs.working_phase(), "streaming…");
} }
/// 错误消息必须渲染出具体的错误文本(而不是只有消息头)。
/// An error message must render its concrete error text (not just a header).
#[test]
fn error_message_renders_as_error_block() {
let transcript = vec![Message::Assistant(AssistantMessage {
content: Vec::new(),
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::Error,
error_message: Some("http error 400: bad request detail".into()),
timestamp: 0,
})];
let blocks = build_blocks(&transcript, &HashMap::new(), None, &HashSet::new());
let err = blocks
.iter()
.find_map(|b| match b {
UiBlock::Error { text, .. } => Some(text.clone()),
_ => None,
})
.expect("error block");
assert_eq!(err, "http error 400: bad request detail");
}