fix(tui): conditionally restore status indicator using message phase (#10947)

TLDR: use new message phase field emitted by preamble-supported models
to determine whether an AgentMessage is mid-turn commentary. if so,
restore the status indicator afterwards to indicate the turn has not
completed.

### Problem
`commit_tick` hides the status indicator while streaming assistant text.
For preamble-capable models, that text can be commentary mid-turn, so
hiding was correct during streaming but restore timing mattered:
- restoring too aggressively caused jitter/flashing
- not restoring caused indicator to stay hidden before subsequent work
(tool calls, web search, etc.)

### Fix
- Add optional `phase` to `AgentMessageItem` and propagate it from
`ResponseItem::Message`
- Keep indicator hidden during streamed commit ticks, restore only when:
  - assistant item completes as `phase=commentary`, and
  - stream queues are idle + task is still running.
- Treat `phase=None` as final-answer behavior (no restore) to keep
existing behavior for non-preamble models

### Tests
Add/update tests for:
- no idle-tick restore without commentary completion
- commentary completion restoring status before tool begin
- snapshot coverage for preamble/status behavior

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
sayan-oai
2026-02-06 18:39:52 -08:00
committed by GitHub
parent 1446bd2b23
commit 5d2702f6b8
19 changed files with 527 additions and 80 deletions

View File

@@ -1,3 +1,4 @@
use crate::models::MessagePhase;
use crate::models::WebSearchAction;
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningEvent;
@@ -40,9 +41,21 @@ pub enum AgentMessageContent {
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
/// Assistant-authored message payload used in turn-item streams.
///
/// `phase` is optional because not all providers/models emit it. Consumers
/// should use it when present, but retain legacy completion semantics when it
/// is `None`.
pub struct AgentMessageItem {
pub id: String,
pub content: Vec<AgentMessageContent>,
/// Optional phase metadata carried through from `ResponseItem::Message`.
///
/// This is currently used by TUI rendering to distinguish mid-turn
/// commentary from a final answer and avoid status-indicator jitter.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub phase: Option<MessagePhase>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
@@ -172,10 +185,13 @@ impl AgentMessageItem {
Self {
id: uuid::Uuid::new_v4().to_string(),
content: content.to_vec(),
phase: None,
}
}
pub fn as_legacy_events(&self) -> Vec<EventMsg> {
// Legacy events only preserve visible assistant text; `phase` has no
// representation in the v1 event stream.
self.content
.iter()
.map(|c| match c {

View File

@@ -74,8 +74,17 @@ pub enum ContentItem {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
/// Classifies an assistant message as interim commentary or final answer text.
///
/// Providers do not emit this consistently, so callers must treat `None` as
/// "phase unknown" and keep compatibility behavior for legacy models.
pub enum MessagePhase {
/// Mid-turn assistant text (for example preamble/progress narration).
///
/// Additional tool calls or assistant output may follow before turn
/// completion.
Commentary,
/// The assistant's terminal answer text for the current turn.
FinalAnswer,
}
@@ -93,7 +102,8 @@ pub enum ResponseItem {
#[ts(optional)]
end_turn: Option<bool>,
// Optional output-message phase (for example: "commentary", "final_answer").
// Do not use directly; availability can vary by provider and model.
// Availability varies by provider/model, so downstream consumers must
// preserve fallback behavior when this is absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
phase: Option<MessagePhase>,