[codex] Support assistant realtime append text (#28836)

## Why

Frontend realtime voice continuity needs to replay a tiny
previous-session overlap as actual conversation items, including
assistant text. The app-server `thread/realtime/appendText` API already
carries a role through to the Rust realtime websocket layer, but the
shared role enum only accepted `user` and `developer`.

## What Changed

- Added `assistant` to `ConversationTextRole` and regenerated the
app-server schema/type fixtures.
- Added `output_text` as a realtime conversation content type.
- Updated realtime websocket item creation so assistant appendText emits
`content: [{ type: "output_text", text }]`, while user and developer
continue to emit `input_text`.
- Updated app-server docs and tests to cover assistant appendText
alongside the existing developer role behavior.

## Validation

- `just write-app-server-schema`
- `just fmt` (first sandboxed attempt failed because `uv` could not
access `~/.cache/uv`; reran with filesystem access and passed)
- `just test -p codex-api` passed: 126/126
- `just test -p codex-app-server-protocol` passed: 239/239, including
generated JSON/TypeScript fixture checks
- `just test -p codex-app-server` was started locally but stopped per
request after unrelated local sandbox/Seatbelt failures (`sandbox-exec:
sandbox_apply: Operation not permitted`) and one missing local `codex`
binary failure; CI should be faster and more authoritative for the full
suite.
This commit is contained in:
guinness-oai
2026-06-17 20:57:13 -07:00
committed by GitHub
Unverified
parent 683bd170dc
commit e922f46a0f
11 changed files with 123 additions and 18 deletions
@@ -1637,10 +1637,29 @@ mod tests {
.into_text()
.expect("text");
let fourth_json: Value = serde_json::from_str(&fourth).expect("json");
assert_eq!(fourth_json["type"], "conversation.handoff.append");
assert_eq!(fourth_json["handoff_id"], "handoff_1");
assert_eq!(fourth_json["type"], "conversation.item.create");
assert_eq!(fourth_json["item"]["role"], "assistant");
assert_eq!(
fourth_json["output_text"],
fourth_json["item"]["content"][0]["type"],
Value::String("output_text".to_string())
);
assert_eq!(
fourth_json["item"]["content"][0]["text"],
Value::String("assistant context".to_string())
);
let fifth = ws
.next()
.await
.expect("fifth msg")
.expect("fifth msg ok")
.into_text()
.expect("text");
let fifth_json: Value = serde_json::from_str(&fifth).expect("json");
assert_eq!(fifth_json["type"], "conversation.handoff.append");
assert_eq!(fifth_json["handoff_id"], "handoff_1");
assert_eq!(
fifth_json["output_text"],
"\"Agent Final Message\":\n\nhello from background agent"
);
@@ -1766,6 +1785,13 @@ mod tests {
)
.await
.expect("send item");
connection
.send_conversation_item_create(
"assistant context".to_string(),
ConversationTextRole::Assistant,
)
.await
.expect("send assistant item");
connection
.send_conversation_function_call_output(
"handoff_1".to_string(),
@@ -1988,16 +2014,35 @@ mod tests {
.expect("text");
let third_json: Value = serde_json::from_str(&third).expect("json");
assert_eq!(third_json["type"], "conversation.item.create");
assert_eq!(third_json["item"]["role"], "assistant");
assert_eq!(
third_json["item"]["type"],
third_json["item"]["content"][0]["type"],
Value::String("output_text".to_string())
);
assert_eq!(
third_json["item"]["content"][0]["text"],
Value::String("assistant context".to_string())
);
let fourth = ws
.next()
.await
.expect("fourth msg")
.expect("fourth msg ok")
.into_text()
.expect("text");
let fourth_json: Value = serde_json::from_str(&fourth).expect("json");
assert_eq!(fourth_json["type"], "conversation.item.create");
assert_eq!(
fourth_json["item"]["type"],
Value::String("function_call_output".to_string())
);
assert_eq!(
third_json["item"]["call_id"],
fourth_json["item"]["call_id"],
Value::String("call_1".to_string())
);
assert_eq!(
third_json["item"]["output"],
fourth_json["item"]["output"],
Value::String("delegated result".to_string())
);
});
@@ -2054,6 +2099,13 @@ mod tests {
)
.await
.expect("send text item");
connection
.send_conversation_item_create(
"assistant context".to_string(),
ConversationTextRole::Assistant,
)
.await
.expect("send assistant item");
connection
.send_conversation_function_call_output(
"call_1".to_string(),
@@ -19,12 +19,19 @@ pub(super) fn conversation_item_create_message(
text: String,
role: ConversationTextRole,
) -> RealtimeOutboundMessage {
let content_type = match role {
ConversationTextRole::Assistant => ConversationContentType::OutputText,
ConversationTextRole::User | ConversationTextRole::Developer => {
ConversationContentType::InputText
}
};
RealtimeOutboundMessage::ConversationItemCreate {
item: ConversationItemPayload::Message(ConversationMessageItem {
r#type: ConversationItemType::Message,
role,
content: vec![ConversationItemContent {
r#type: ConversationContentType::InputText,
r#type: content_type,
text,
}],
}),
@@ -40,12 +40,19 @@ pub(super) fn conversation_item_create_message(
text: String,
role: ConversationTextRole,
) -> RealtimeOutboundMessage {
let content_type = match role {
ConversationTextRole::Assistant => ConversationContentType::OutputText,
ConversationTextRole::User | ConversationTextRole::Developer => {
ConversationContentType::InputText
}
};
RealtimeOutboundMessage::ConversationItemCreate {
item: ConversationItemPayload::Message(ConversationMessageItem {
r#type: ConversationItemType::Message,
role,
content: vec![ConversationItemContent {
r#type: ConversationContentType::InputText,
r#type: content_type,
text,
}],
}),
@@ -195,6 +195,7 @@ pub(super) struct ConversationItemContent {
#[serde(rename_all = "snake_case")]
pub(super) enum ConversationContentType {
InputText,
OutputText,
}
#[derive(Debug, Clone, Serialize)]