mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
1446bd2b23
commit
5d2702f6b8
@@ -1064,11 +1064,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
|
||||
@@ -3169,11 +3169,23 @@
|
||||
]
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -4685,6 +4697,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -4695,6 +4708,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
|
||||
@@ -3948,11 +3948,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -6686,6 +6698,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -6696,6 +6709,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
|
||||
@@ -6064,11 +6064,23 @@
|
||||
]
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -9146,6 +9158,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -9156,6 +9169,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
@@ -12178,11 +12202,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
|
||||
@@ -3169,11 +3169,23 @@
|
||||
]
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -4685,6 +4697,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -4695,6 +4708,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
|
||||
@@ -271,11 +271,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NewConversationParams": {
|
||||
"properties": {
|
||||
|
||||
@@ -3169,11 +3169,23 @@
|
||||
]
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -4685,6 +4697,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -4695,6 +4708,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
|
||||
@@ -3169,11 +3169,23 @@
|
||||
]
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ModeKind": {
|
||||
"description": "Initial collaboration mode to use when the TUI starts.",
|
||||
@@ -4685,6 +4697,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Assistant-authored message payload used in turn-item streams.\n\n`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`.",
|
||||
"properties": {
|
||||
"content": {
|
||||
"items": {
|
||||
@@ -4695,6 +4708,17 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"phase": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/MessagePhase"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter."
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"AgentMessage"
|
||||
|
||||
+17
-5
@@ -238,11 +238,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ReasoningItemContent": {
|
||||
"oneOf": [
|
||||
|
||||
@@ -247,11 +247,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"MessagePhase": {
|
||||
"enum": [
|
||||
"commentary",
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
"description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.",
|
||||
"enum": [
|
||||
"commentary"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The assistant's terminal answer text for the current turn.",
|
||||
"enum": [
|
||||
"final_answer"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Personality": {
|
||||
"enum": [
|
||||
|
||||
@@ -2,5 +2,20 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgentMessageContent } from "./AgentMessageContent";
|
||||
import type { MessagePhase } from "./MessagePhase";
|
||||
|
||||
export type AgentMessageItem = { id: string, content: Array<AgentMessageContent>, };
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export type AgentMessageItem = { id: string, content: Array<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.
|
||||
*/
|
||||
phase?: MessagePhase, };
|
||||
|
||||
@@ -2,4 +2,10 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type MessagePhase = "commentary" | "final_answer";
|
||||
|
||||
@@ -3249,6 +3249,7 @@ mod tests {
|
||||
text: "world".to_string(),
|
||||
},
|
||||
],
|
||||
phase: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -4483,6 +4483,7 @@ async fn emit_agent_message_in_plan_mode(
|
||||
TurnItem::AgentMessage(codex_protocol::items::AgentMessageItem {
|
||||
id: agent_message_id.clone(),
|
||||
content: Vec::new(),
|
||||
phase: None,
|
||||
})
|
||||
});
|
||||
sess.emit_turn_item_started(turn_context, &start_item).await;
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -69,7 +70,11 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
Some(UserMessageItem::new(&content))
|
||||
}
|
||||
|
||||
fn parse_agent_message(id: Option<&String>, message: &[ContentItem]) -> AgentMessageItem {
|
||||
fn parse_agent_message(
|
||||
id: Option<&String>,
|
||||
message: &[ContentItem],
|
||||
phase: Option<MessagePhase>,
|
||||
) -> AgentMessageItem {
|
||||
let mut content: Vec<AgentMessageContent> = Vec::new();
|
||||
for content_item in message.iter() {
|
||||
match content_item {
|
||||
@@ -85,18 +90,23 @@ fn parse_agent_message(id: Option<&String>, message: &[ContentItem]) -> AgentMes
|
||||
}
|
||||
}
|
||||
let id = id.cloned().unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
AgentMessageItem { id, content }
|
||||
AgentMessageItem { id, content, phase }
|
||||
}
|
||||
|
||||
pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
|
||||
match item {
|
||||
ResponseItem::Message {
|
||||
role, content, id, ..
|
||||
role,
|
||||
content,
|
||||
id,
|
||||
phase,
|
||||
..
|
||||
} => match role.as_str() {
|
||||
"user" => parse_user_message(content).map(TurnItem::UserMessage),
|
||||
"assistant" => Some(TurnItem::AgentMessage(parse_agent_message(
|
||||
id.as_ref(),
|
||||
content,
|
||||
phase.clone(),
|
||||
))),
|
||||
"system" => None,
|
||||
_ => None,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked
|
||||
//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via
|
||||
//! `update_task_running_state`.
|
||||
//!
|
||||
//! For preamble-capable models, assistant output may include commentary before
|
||||
//! the final answer. During streaming we hide the status row to avoid duplicate
|
||||
//! progress indicators; once commentary completes and stream queues drain, we
|
||||
//! re-show it so users still see turn-in-progress state between output bursts.
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
@@ -116,6 +121,8 @@ use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::Settings;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
@@ -542,6 +549,8 @@ pub(crate) struct ChatWidget {
|
||||
current_status_header: String,
|
||||
// Previous status header to restore after a transient stream retry.
|
||||
retry_status_header: Option<String>,
|
||||
// Set when commentary output completes; once stream queues go idle we restore the status row.
|
||||
pending_status_indicator_restore: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
@@ -808,6 +817,37 @@ impl ChatWidget {
|
||||
self.adaptive_chunking.reset();
|
||||
}
|
||||
|
||||
fn stream_controllers_idle(&self) -> bool {
|
||||
self.stream_controller
|
||||
.as_ref()
|
||||
.map(|controller| controller.queued_lines() == 0)
|
||||
.unwrap_or(true)
|
||||
&& self
|
||||
.plan_stream_controller
|
||||
.as_ref()
|
||||
.map(|controller| controller.queued_lines() == 0)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Restore the status indicator only after commentary completion is pending,
|
||||
/// the turn is still running, and all stream queues have drained.
|
||||
///
|
||||
/// This gate prevents flicker while normal output is still actively
|
||||
/// streaming, but still restores a visible "working" affordance when a
|
||||
/// commentary block ends before the turn itself has completed.
|
||||
fn maybe_restore_status_indicator_after_stream_idle(&mut self) {
|
||||
if !self.pending_status_indicator_restore
|
||||
|| !self.bottom_pane.is_task_running()
|
||||
|| !self.stream_controllers_idle()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.set_status_header(self.current_status_header.clone());
|
||||
self.pending_status_indicator_restore = false;
|
||||
}
|
||||
|
||||
/// Update the status indicator header and details.
|
||||
///
|
||||
/// Passing `None` clears any existing details.
|
||||
@@ -1181,21 +1221,29 @@ impl ChatWidget {
|
||||
} else {
|
||||
text
|
||||
};
|
||||
// Plan commit ticks can hide the status row; remember whether we streamed plan output so
|
||||
// completion can restore it once stream queues are idle.
|
||||
let should_restore_after_stream = self.plan_stream_controller.is_some();
|
||||
self.plan_delta_buffer.clear();
|
||||
self.plan_item_active = false;
|
||||
self.saw_plan_item_this_turn = true;
|
||||
if let Some(mut controller) = self.plan_stream_controller.take()
|
||||
&& let Some(cell) = controller.finalize()
|
||||
{
|
||||
let finalized_streamed_cell =
|
||||
if let Some(mut controller) = self.plan_stream_controller.take() {
|
||||
controller.finalize()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(cell) = finalized_streamed_cell {
|
||||
self.add_boxed_history(cell);
|
||||
// TODO: Replace streamed output with the final plan item text if plan streaming is
|
||||
// removed or if we need to reconcile mismatches between streamed and final content.
|
||||
return;
|
||||
} else if !plan_text.is_empty() {
|
||||
self.add_to_history(history_cell::new_proposed_plan(plan_text));
|
||||
}
|
||||
if plan_text.is_empty() {
|
||||
return;
|
||||
if should_restore_after_stream {
|
||||
self.pending_status_indicator_restore = true;
|
||||
self.maybe_restore_status_indicator_after_stream_idle();
|
||||
}
|
||||
self.add_to_history(history_cell::new_proposed_plan(plan_text));
|
||||
}
|
||||
|
||||
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
||||
@@ -1256,6 +1304,7 @@ impl ChatWidget {
|
||||
self.quit_shortcut_key = None;
|
||||
self.update_task_running_state();
|
||||
self.retry_status_header = None;
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.full_reasoning_buffer.clear();
|
||||
@@ -1297,6 +1346,7 @@ impl ChatWidget {
|
||||
self.request_status_line_branch_refresh();
|
||||
}
|
||||
// Mark task stopped and request redraw now that all content is in history.
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.agent_turn_running = false;
|
||||
self.update_task_running_state();
|
||||
self.running_commands.clear();
|
||||
@@ -1528,6 +1578,7 @@ impl ChatWidget {
|
||||
self.adaptive_chunking.reset();
|
||||
self.stream_controller = None;
|
||||
self.plan_stream_controller = None;
|
||||
self.pending_status_indicator_restore = false;
|
||||
self.request_status_line_branch_refresh();
|
||||
self.maybe_show_pending_rate_limit_prompt();
|
||||
}
|
||||
@@ -2087,9 +2138,24 @@ impl ChatWidget {
|
||||
if self.retry_status_header.is_none() {
|
||||
self.retry_status_header = Some(self.current_status_header.clone());
|
||||
}
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.set_status(message, additional_details);
|
||||
}
|
||||
|
||||
/// Handle completion of an `AgentMessage` turn item.
|
||||
///
|
||||
/// Commentary completion sets a deferred restore flag so the status row
|
||||
/// returns once stream queues are idle. Final-answer completion (or absent
|
||||
/// phase for legacy models) clears the flag to preserve historical behavior.
|
||||
fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) {
|
||||
self.pending_status_indicator_restore = match item.phase {
|
||||
// Models that don't support preambles only output AgentMessageItems on turn completion.
|
||||
Some(MessagePhase::FinalAnswer) | None => false,
|
||||
Some(MessagePhase::Commentary) => true,
|
||||
};
|
||||
self.maybe_restore_status_indicator_after_stream_idle();
|
||||
}
|
||||
|
||||
/// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while
|
||||
/// catch-up mode drains larger batches to reduce queue lag.
|
||||
pub(crate) fn on_commit_tick(&mut self) {
|
||||
@@ -2110,9 +2176,8 @@ impl ChatWidget {
|
||||
///
|
||||
/// `scope` controls whether this call may commit in smooth mode or only when catch-up
|
||||
/// is currently active. While lines are actively streaming we hide the status row to avoid
|
||||
/// duplicate "in progress" affordances, but once all stream controllers go idle for this
|
||||
/// turn we restore the status row if the task is still running so users keep a live
|
||||
/// spinner/shimmer signal between preamble output and subsequent tool activity.
|
||||
/// duplicate "in progress" affordances. Restoration is gated separately so we only re-show
|
||||
/// the row after commentary completion once stream queues are idle.
|
||||
fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) {
|
||||
let now = Instant::now();
|
||||
let outcome = run_commit_tick(
|
||||
@@ -2128,10 +2193,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
if outcome.has_controller && outcome.all_idle {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.set_status_header(self.current_status_header.clone());
|
||||
}
|
||||
self.maybe_restore_status_indicator_after_stream_idle();
|
||||
self.app_event_tx.send(AppEvent::StopCommitAnimation);
|
||||
}
|
||||
|
||||
@@ -2562,6 +2624,7 @@ impl ChatWidget {
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
@@ -2724,6 +2787,7 @@ impl ChatWidget {
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
@@ -2875,6 +2939,7 @@ impl ChatWidget {
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
@@ -3967,8 +4032,12 @@ impl ChatWidget {
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::DynamicToolCallRequest(_) => {}
|
||||
EventMsg::ItemCompleted(event) => {
|
||||
if let codex_protocol::items::TurnItem::Plan(plan_item) = event.item {
|
||||
self.on_plan_item_completed(plan_item.text);
|
||||
let item = event.item;
|
||||
if let codex_protocol::items::TurnItem::Plan(plan_item) = &item {
|
||||
self.on_plan_item_completed(plan_item.text.clone());
|
||||
}
|
||||
if let codex_protocol::items::TurnItem::AgentMessage(item) = item {
|
||||
self.on_agent_message_item_completed(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::ExecPolicyAmendment;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::ItemCompletedEvent;
|
||||
use codex_core::protocol::McpStartupCompleteEvent;
|
||||
use codex_core::protocol::McpStartupStatus;
|
||||
use codex_core::protocol::McpStartupUpdateEvent;
|
||||
@@ -71,6 +72,10 @@ use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
@@ -1074,6 +1079,7 @@ async fn make_chatwidget_manual(
|
||||
full_reasoning_buffer: String::new(),
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
pending_status_indicator_restore: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
forked_from: None,
|
||||
@@ -1958,6 +1964,28 @@ fn terminal_interaction(chat: &mut ChatWidget, call_id: &str, process_id: &str,
|
||||
});
|
||||
}
|
||||
|
||||
fn complete_assistant_message(
|
||||
chat: &mut ChatWidget,
|
||||
item_id: &str,
|
||||
text: &str,
|
||||
phase: Option<MessagePhase>,
|
||||
) {
|
||||
chat.handle_codex_event(Event {
|
||||
id: format!("raw-{item_id}"),
|
||||
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item: TurnItem::AgentMessage(AgentMessageItem {
|
||||
id: item_id.to_string(),
|
||||
content: vec![AgentMessageContent::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
phase,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)
|
||||
}
|
||||
@@ -2103,15 +2131,16 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_final_answer_keeps_task_running_state() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
assert!(chat.bottom_pane.is_task_running());
|
||||
assert!(chat.bottom_pane.status_widget().is_some());
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
|
||||
@@ -2133,7 +2162,26 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preamble_keeps_status_indicator_visible_until_exec_begin() {
|
||||
async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_task_started();
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
||||
|
||||
chat.on_agent_message_delta("Final answer line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
|
||||
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
||||
|
||||
// A second idle tick should not toggle the row back on and cause jitter.
|
||||
chat.on_commit_tick();
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn commentary_completion_restores_status_indicator_before_exec_begin() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.on_task_started();
|
||||
@@ -2143,12 +2191,45 @@ async fn preamble_keeps_status_indicator_visible_until_exec_begin() {
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
|
||||
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-commentary",
|
||||
"Preamble line\n",
|
||||
Some(MessagePhase::Commentary),
|
||||
);
|
||||
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
||||
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
||||
|
||||
begin_exec(&mut chat, "call-1", "echo hi");
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_completion_restores_status_indicator_after_streaming_plan_output() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
let plan_mask =
|
||||
collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
|
||||
chat.on_task_started();
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
||||
|
||||
chat.on_plan_delta("- Step 1\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
|
||||
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
||||
|
||||
chat.on_plan_item_completed("- Step 1\n".to_string());
|
||||
|
||||
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
||||
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2157,11 +2238,17 @@ async fn preamble_keeps_working_status_snapshot() {
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
// Regression sequence: a preamble line is committed to history before any exec/tool event.
|
||||
// The status row must remain visible so the spinner/shimmer still communicates "working".
|
||||
// After commentary completes, the status row should be restored before subsequent work.
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("Preamble line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-commentary-snapshot",
|
||||
"Preamble line\n",
|
||||
Some(MessagePhase::Commentary),
|
||||
);
|
||||
|
||||
let height = chat.desired_height(80);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
@@ -5176,6 +5263,34 @@ async fn stream_error_updates_status_indicator() {
|
||||
assert_eq!(status.details(), Some(details));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_error_restores_hidden_status_indicator() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("Preamble line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
|
||||
let msg = "Reconnecting... 2/5";
|
||||
let details = "Idle timeout waiting for SSE";
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::StreamError(StreamErrorEvent {
|
||||
message: msg.to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
additional_details: Some(details.to_string()),
|
||||
}),
|
||||
});
|
||||
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be visible");
|
||||
assert_eq!(status.header(), msg);
|
||||
assert_eq!(status.details(), Some(details));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warning_event_adds_warning_history_cell() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
Reference in New Issue
Block a user