Add a partial answer message phase (#51241)

## Why

Assistant answer text may be followed by more output or tools. A distinct `partial_answer` phase lets callers represent this separately from commentary and a terminal `final_answer`.

## What changed

- Add `MessagePhase::PartialAnswer` and expose `partial_answer` in the app-server schemas and generated TypeScript and Python types.
- Keep the TUI working indicator visible after partial answers and exclude them from the thread history's final-answer summary field.
- Route partial answers to the speakable channel in realtime V3 BEM-tag mode.

## Testing

Add coverage for phase preservation through serialization, input-message conversion, and SSE item start/completion events. Extend TUI snapshot coverage to verify that partial answers keep the working indicator visible while final answers hide it.

GitOrigin-RevId: 1ae3ad9264b6e36ae1cbe6b93df3d345ab4460e6
This commit is contained in:
rb-oai
2026-10-06 02:57:15 +00:00
committed by copyberry
parent 2f412e60d7
commit 8b6bb1c77d
35 changed files with 346 additions and 100 deletions
+9 -2
View File
@@ -2213,7 +2213,7 @@
"type": "string"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -2223,7 +2223,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -3015,7 +3015,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -3025,7 +3025,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -15361,7 +15361,7 @@
"type": "string"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -15371,7 +15371,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -11373,7 +11373,7 @@
"type": "string"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -11383,7 +11383,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -650,7 +650,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -660,7 +660,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -650,7 +650,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -660,7 +660,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -389,7 +389,7 @@
"type": "string"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -399,7 +399,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -798,7 +798,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -808,7 +808,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -902,7 +902,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -912,7 +912,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -650,7 +650,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -660,7 +660,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -451,7 +451,7 @@
"type": "string"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -461,7 +461,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -918,7 +918,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -928,7 +928,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -902,7 +902,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -912,7 +912,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -798,7 +798,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -808,7 +808,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -824,7 +824,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -834,7 +834,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -798,7 +798,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -808,7 +808,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -798,7 +798,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -808,7 +808,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -798,7 +798,7 @@
"type": "object"
},
"MessagePhase": {
"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.",
"description": "Classifies assistant text as commentary, a partial answer, or a terminal answer.\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.",
@@ -808,7 +808,14 @@
"type": "string"
},
{
"description": "The assistant's terminal answer text for the current turn.",
"description": "Stable answer text that may be followed by more assistant output or tools.",
"enum": [
"partial_answer"
],
"type": "string"
},
{
"description": "The assistant's declared terminal answer text for the current turn.",
"enum": [
"final_answer"
],
@@ -3,9 +3,9 @@
// 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.
* Classifies assistant text as commentary, a partial answer, or a terminal answer.
*
* 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";
export type MessagePhase = "commentary" | "partial_answer" | "final_answer";
+40
View File
@@ -734,6 +734,46 @@ mod tests {
Duration::from_millis(1000)
}
#[tokio::test]
async fn partial_answer_phase_survives_item_start_and_completion() {
let item = json!({
"id": "msg_partial",
"type": "message",
"role": "assistant",
"content": [],
"phase": "partial_answer"
});
let added = json!({"type": "response.output_item.added", "item": item});
let delta = json!({"type": "response.output_text.delta", "delta": "First result."});
let mut done_item = item;
done_item["content"] = json!([{"type": "output_text", "text": "First result."}]);
let done = json!({"type": "response.output_item.done", "item": done_item});
let completed = json!({
"type": "response.completed",
"response": {"id": "resp1", "end_turn": false}
});
let stream = [added, delta, done, completed]
.into_iter()
.map(|event| format!("data: {event}\n\n"))
.collect::<String>();
let events = collect_events(&[stream.as_bytes()]).await;
assert_matches!(events.as_slice(), [
Ok(ResponseEvent::OutputItemAdded(ResponseItem::Message {
phase: Some(MessagePhase::PartialAnswer), content: start_content, ..
})),
Ok(ResponseEvent::OutputTextDelta(text)),
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message {
phase: Some(MessagePhase::PartialAnswer), content: done_content, ..
})),
Ok(ResponseEvent::Completed { end_turn: Some(false), .. }),
] if start_content.is_empty()
&& text == "First result."
&& done_content == &vec![codex_protocol::models::ContentItem::OutputText {
text: "First result.".to_string()
}]);
}
#[tokio::test]
async fn parses_items_and_completed() {
let item1 = json!({
+7 -1
View File
@@ -928,6 +928,8 @@ impl RealtimeConversationManager {
if handoff.client_managed_handoffs {
return Ok(());
}
// TODO: Preserve explicit PartialAnswer phases through completed and streamed BEM
// routing, with integration coverage asserting the speakable channel.
let phase = if handoff.routes_handoff_by_bem() {
match bem_message_phase(
&output_text,
@@ -942,6 +944,8 @@ impl RealtimeConversationManager {
} else {
phase
};
// TODO: Treat PartialAnswer as nonterminal for V1 handoffs, omitting the final-message
// prefix for streamed, completed-item, and standalone output.
let is_commentary = matches!(phase, Some(MessagePhase::Commentary));
let active_handoff = handoff.stream.lock().await.active_handoff.clone();
let output = match active_handoff {
@@ -2272,7 +2276,9 @@ fn v3_output_writer(
CodexResponseHandoffMode::Thinking => None,
CodexResponseHandoffMode::Commentary => Some(RealtimeContextAppendChannel::Commentary),
CodexResponseHandoffMode::BemTags => match phase {
Some(MessagePhase::FinalAnswer) => Some(RealtimeContextAppendChannel::Speakable),
Some(MessagePhase::PartialAnswer | MessagePhase::FinalAnswer) => {
Some(RealtimeContextAppendChannel::Speakable)
}
Some(MessagePhase::Commentary) => Some(RealtimeContextAppendChannel::Commentary),
None => Some(RealtimeContextAppendChannel::Speakable),
},
+2
View File
@@ -2750,6 +2750,8 @@ async fn try_run_sampling_request(
cancellation_token: cancellation_token.child_token(),
};
// TODO: Allow mailbox preemption after PartialAnswer and keep delivery open
// in the completed-item deferral predicates.
let preempt_for_mailbox_mail = match &item {
ResponseItem::Message { role, phase, .. } => {
role == "assistant" && matches!(phase, Some(MessagePhase::Commentary))
+31 -18
View File
@@ -937,7 +937,7 @@ pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High;
#[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.
/// Classifies assistant text as commentary, a partial answer, or a terminal answer.
///
/// Providers do not emit this consistently, so callers must treat `None` as
/// "phase unknown" and keep compatibility behavior for legacy models.
@@ -947,7 +947,9 @@ pub enum MessagePhase {
/// Additional tool calls or assistant output may follow before turn
/// completion.
Commentary,
/// The assistant's terminal answer text for the current turn.
/// Stable answer text that may be followed by more assistant output or tools.
PartialAnswer,
/// The assistant's declared terminal answer text for the current turn.
FinalAnswer,
}
@@ -2518,27 +2520,38 @@ mod tests {
}
#[test]
fn response_input_message_conversion_preserves_phase() {
let item = ResponseItem::from(ResponseInputItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "still working".to_string(),
}],
phase: Some(MessagePhase::Commentary),
});
assert_eq!(
item,
ResponseItem::Message {
fn response_input_message_conversion_preserves_phase() -> Result<()> {
for phase in [
None,
Some(MessagePhase::Commentary),
Some(MessagePhase::PartialAnswer),
Some(MessagePhase::FinalAnswer),
] {
let input = ResponseInputItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "answer text".to_string(),
}],
phase: phase.clone(),
};
let wire = serde_json::to_value(input)?;
let item = ResponseItem::from(serde_json::from_value::<ResponseInputItem>(wire)?);
let expected = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "still working".to_string(),
text: "answer text".to_string(),
}],
phase: Some(MessagePhase::Commentary),
phase,
internal_chat_message_metadata_passthrough: None,
}
);
};
assert_eq!(item, expected);
assert_eq!(
serde_json::from_value::<ResponseItem>(serde_json::to_value(item)?)?,
expected
);
}
Ok(())
}
#[test]
@@ -520,7 +520,7 @@ WHERE thread_id = ?
.map_err(thread_history_error)?;
}
ThreadItem::AgentMessage {
phase: Some(MessagePhase::Commentary) | None,
phase: Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) | None,
..
}
| ThreadItem::HookPrompt { .. }
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalize_snapshot_paths(terminal.backend().vt100().screen().contents())
---
• 1
• Working (0s • esc to interrupt)
› Ask Codex to do anything
gpt-5 default · /tmp/project
+1 -1
View File
@@ -482,7 +482,7 @@ impl ChatWidget {
Some(MessagePhase::FinalAnswer) | None => {
!self.input_queue.pending_steers.is_empty()
}
Some(MessagePhase::Commentary) => true,
Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) => true,
};
self.maybe_restore_status_indicator_after_stream_idle();
}
@@ -2205,42 +2205,51 @@ async fn streaming_final_answer_keeps_task_running_state() {
}
#[tokio::test]
async fn single_line_final_answer_hides_working_status_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.thread_id = Some(ThreadId::new());
async fn answer_phase_controls_working_status_snapshot() {
for (phase, working_visible, snapshot) in [
(
MessagePhase::FinalAnswer,
false,
"single_line_final_answer_hides_working_status",
),
(
MessagePhase::PartialAnswer,
true,
"single_line_partial_answer_keeps_working_status",
),
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.thread_id = Some(ThreadId::new());
complete_user_message(&mut chat, "user-1", "count to 1");
chat.on_task_started();
complete_assistant_message(
&mut chat,
"msg-final-single-line",
"1",
Some(MessagePhase::FinalAnswer),
);
complete_user_message(&mut chat, "user-1", "count to 1");
chat.on_task_started();
complete_assistant_message(&mut chat, "msg-final-single-line", "1", Some(phase));
assert!(chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.status_indicator_visible());
assert!(chat.bottom_pane.is_task_running());
assert_eq!(chat.bottom_pane.status_indicator_visible(), working_visible);
let width: u16 = 40;
let vt_height: u16 = 10;
let ui_height = chat.desired_height(width);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(viewport);
let width: u16 = 40;
let vt_height: u16 = 10;
let ui_height = chat.desired_height(width);
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
let mut terminal =
crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
terminal.set_viewport_area(viewport);
for lines in drain_insert_history(&mut rx) {
crate::insert_history::insert_history_lines(&mut terminal, lines)
.expect("insert history lines");
for lines in drain_insert_history(&mut rx) {
crate::insert_history::insert_history_lines(&mut terminal, lines)
.expect("insert history lines");
}
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw final answer");
assert_chatwidget_snapshot!(
snapshot,
normalize_snapshot_paths(terminal.backend().vt100().screen().contents())
);
}
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw final answer");
assert_chatwidget_snapshot!(
"single_line_final_answer_hides_working_status",
normalize_snapshot_paths(terminal.backend().vt100().screen().contents())
);
}
#[tokio::test]
@@ -2750,6 +2750,7 @@ class MergeStrategy(Enum):
class MessagePhase(Enum):
commentary = "commentary"
partial_answer = "partial_answer"
final_answer = "final_answer"