Add audio variants to user input protocols (#33923)

## What changed

- Add remote and local audio variants to core and app-server user inputs, including generated JSON and TypeScript schemas.
- Preserve audio fields when converting app-server inputs and response content into thread items.
- Keep unsupported audio out of model requests, TUI rendering, text extraction, and truncated tool output, using explicit text placeholders where audio is submitted as a model input.

## Testing

- Cover app-server media conversion, unsupported-input placeholders, TUI omission, and tool-output truncation.

GitOrigin-RevId: 16152bf3af674575ce569764a8b472e032e7569d
This commit is contained in:
nhamidi-oai
2026-07-17 23:14:37 +00:00
committed by copyberry
parent e98765c212
commit 97dbd01a3d
49 changed files with 1330 additions and 33 deletions
@@ -670,6 +670,26 @@
"title": "InputImageContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioContentItem",
"type": "object"
},
{
"properties": {
"text": {
@@ -1251,6 +1271,26 @@
"title": "InputImageFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioFunctionCallOutputContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
@@ -4864,6 +4904,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -5562,6 +5562,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -9012,6 +9012,26 @@
"title": "InputImageContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioContentItem",
"type": "object"
},
{
"properties": {
"text": {
@@ -10628,6 +10648,26 @@
"title": "InputImageFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioFunctionCallOutputContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
@@ -21234,6 +21274,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -5205,6 +5205,26 @@
"title": "InputImageContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioContentItem",
"type": "object"
},
{
"properties": {
"text": {
@@ -6821,6 +6841,26 @@
"title": "InputImageFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioFunctionCallOutputContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
@@ -19013,6 +19053,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -1378,6 +1378,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -1378,6 +1378,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -97,6 +97,26 @@
"title": "InputImageContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioContentItem",
"type": "object"
},
{
"properties": {
"text": {
@@ -185,6 +205,26 @@
"title": "InputImageFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioFunctionCallOutputContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
@@ -1653,6 +1653,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2259,6 +2259,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -159,6 +159,26 @@
"title": "InputImageContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioContentItem",
"type": "object"
},
{
"properties": {
"text": {
@@ -247,6 +267,26 @@
"title": "InputImageFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"audio_url": {
"type": "string"
},
"type": {
"enum": [
"input_audio"
],
"title": "InputAudioFunctionCallOutputContentItemType",
"type": "string"
}
},
"required": [
"audio_url",
"type"
],
"title": "InputAudioFunctionCallOutputContentItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
@@ -2285,6 +2285,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2259,6 +2259,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -2050,6 +2050,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -1653,6 +1653,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -472,6 +472,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -1653,6 +1653,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -1653,6 +1653,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -166,6 +166,46 @@
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"audio"
],
"title": "AudioUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "AudioUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localAudio"
],
"title": "LocalAudioUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalAudioUserInput",
"type": "object"
},
{
"properties": {
"name": {
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImageDetail } from "./ImageDetail";
export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, };
export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "input_audio", audio_url: string, } | { "type": "output_text", text: string, };
@@ -7,4 +7,4 @@ import type { ImageDetail } from "./ImageDetail";
* Responses API compatible content items that can be returned by a tool call.
* This is a subset of ContentItem with the types we support as function call outputs.
*/
export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "encrypted_content", encrypted_content: string, };
export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "input_audio", audio_url: string, } | { "type": "encrypted_content", encrypted_content: string, };
@@ -8,4 +8,4 @@ export type UserInput = { "type": "text", text: string,
/**
* UI-defined spans within `text` used to render or persist special elements.
*/
text_elements: Array<TextElement>, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, };
text_elements: Array<TextElement>, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "audio", url: string, } | { "type": "localAudio", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, };
@@ -2545,6 +2545,12 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
path: PathBuf::from("local/image.png"),
detail: Some(ImageDetail::Original),
},
CoreUserInput::Audio {
audio_url: "data:audio/wav;base64,AAA".to_string(),
},
CoreUserInput::LocalAudio {
path: PathBuf::from("local/audio.mp3"),
},
CoreUserInput::Skill {
name: "skill-creator".to_string(),
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
@@ -2574,6 +2580,12 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
path: PathBuf::from("local/image.png"),
detail: Some(ImageDetail::Original),
},
UserInput::Audio {
url: "data:audio/wav;base64,AAA".to_string(),
},
UserInput::LocalAudio {
path: PathBuf::from("local/audio.mp3"),
},
UserInput::Skill {
name: "skill-creator".to_string(),
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
@@ -3022,7 +3034,7 @@ fn mcp_tool_call_app_context_serializes_missing_mixed_version_fields_as_null() {
}
#[test]
fn user_input_into_core_preserves_image_detail() {
fn user_input_into_core_preserves_media_fields() {
assert_eq!(
UserInput::Image {
url: "https://example.com/image.png".to_string(),
@@ -3046,6 +3058,26 @@ fn user_input_into_core_preserves_image_detail() {
detail: Some(ImageDetail::Original),
}
);
assert_eq!(
UserInput::Audio {
url: "data:audio/wav;base64,AAA".to_string(),
}
.into_core(),
CoreUserInput::Audio {
audio_url: "data:audio/wav;base64,AAA".to_string(),
}
);
assert_eq!(
UserInput::LocalAudio {
path: PathBuf::from("local/audio.mp3"),
}
.into_core(),
CoreUserInput::LocalAudio {
path: PathBuf::from("local/audio.mp3"),
}
);
}
#[test]
@@ -308,6 +308,12 @@ pub enum UserInput {
detail: Option<ImageDetail>,
path: PathBuf,
},
Audio {
url: String,
},
LocalAudio {
path: PathBuf,
},
Skill {
name: String,
path: PathBuf,
@@ -333,6 +339,8 @@ impl UserInput {
detail,
},
UserInput::LocalImage { path, detail } => CoreUserInput::LocalImage { path, detail },
UserInput::Audio { url } => CoreUserInput::Audio { audio_url: url },
UserInput::LocalAudio { path } => CoreUserInput::LocalAudio { path },
UserInput::Skill { name, path } => CoreUserInput::Skill { name, path },
UserInput::Mention { name, path } => CoreUserInput::Mention { name, path },
}
@@ -354,6 +362,8 @@ impl From<CoreUserInput> for UserInput {
detail,
},
CoreUserInput::LocalImage { path, detail } => UserInput::LocalImage { path, detail },
CoreUserInput::Audio { audio_url } => UserInput::Audio { url: audio_url },
CoreUserInput::LocalAudio { path } => UserInput::LocalAudio { path },
CoreUserInput::Skill { name, path } => UserInput::Skill { name, path },
CoreUserInput::Mention { name, path } => UserInput::Mention { name, path },
_ => unreachable!("unsupported user input variant"),
@@ -367,6 +377,8 @@ impl UserInput {
UserInput::Text { text, .. } => text.chars().count(),
UserInput::Image { .. }
| UserInput::LocalImage { .. }
| UserInput::Audio { .. }
| UserInput::LocalAudio { .. }
| UserInput::Skill { .. }
| UserInput::Mention { .. } => 0,
}
@@ -2078,6 +2078,8 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
UserInput::Text { text, .. } => Some(text.as_str()),
UserInput::Image { .. }
| UserInput::LocalImage { .. }
| UserInput::Audio { .. }
| UserInput::LocalAudio { .. }
| UserInput::Skill { .. }
| UserInput::Mention { .. } => None,
},
+4
View File
@@ -750,6 +750,10 @@ pub(crate) fn render_input_preview(input: &[UserInput]) -> String {
UserInput::LocalImage { path, .. } => {
format!("[local_image:{}]", path.display())
}
UserInput::Audio { .. } => "[audio]".to_string(),
UserInput::LocalAudio { path } => {
format!("[local_audio:{}]", path.display())
}
UserInput::Skill { name, path, .. } => {
format!("[skill:${name}]({})", path.display())
}
+5 -3
View File
@@ -252,7 +252,7 @@ fn has_subagent_notification(history_items: &[ResponseItem]) -> bool {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
SubagentNotification::matches_text(text)
}
ContentItem::InputImage { .. } => false,
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
})
}
@@ -267,7 +267,7 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
text.contains(needle)
}
ContentItem::InputImage { .. } => false,
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
})
}
@@ -290,7 +290,9 @@ fn history_contains_assistant_inter_agent_communication(
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
ContentItem::InputText { .. }
| ContentItem::InputImage { .. }
| ContentItem::InputAudio { .. } => false,
})
})
}
+1 -1
View File
@@ -484,7 +484,7 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
pieces.push(text.as_str());
}
}
ContentItem::InputImage { .. } => {}
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {}
}
}
if pieces.is_empty() {
+4 -2
View File
@@ -516,7 +516,7 @@ fn message_text_token_count(item: &ResponseItem) -> usize {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
approx_token_count(text)
}
ContentItem::InputImage { .. } => 0,
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => 0,
})
.sum()
}
@@ -556,7 +556,9 @@ fn truncate_message_text_to_token_budget(
truncated_content.push(content_item);
}
}
ContentItem::InputImage { .. } => truncated_content.push(content_item),
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => {
truncated_content.push(content_item);
}
}
}
+5
View File
@@ -113,6 +113,11 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
detail: *detail,
});
}
ContentItem::InputAudio { audio_url } => {
content.push(UserInput::Audio {
audio_url: audio_url.clone(),
});
}
ContentItem::OutputText { text } => {
warn!("Output text in user message: {}", text);
}
+1 -1
View File
@@ -345,7 +345,7 @@ fn response_item_contains_message_text(item: &ResponseItem, needle: &str) -> boo
};
content.iter().any(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => text.contains(needle),
ContentItem::InputImage { .. } => false,
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
}
+3 -1
View File
@@ -844,7 +844,9 @@ mod tests {
.iter()
.map(|item| match item {
ContentItem::InputText { text } => text.as_str(),
ContentItem::InputImage { .. } | ContentItem::OutputText { .. } => {
ContentItem::InputImage { .. }
| ContentItem::InputAudio { .. }
| ContentItem::OutputText { .. } => {
panic!("expected input text content, got {item:?}")
}
})
@@ -559,10 +559,14 @@ fn create_collab_input_items_schema() -> JsonSchema {
"image_url".to_string(),
JsonSchema::string(Some("Image URL when type is image.".to_string())),
),
(
"audio_url".to_string(),
JsonSchema::string(Some("Audio URL when type is audio.".to_string())),
),
(
"path".to_string(),
JsonSchema::string(Some(
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
"Path when type is local_image/local_audio/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
)),
),
+4 -1
View File
@@ -363,7 +363,9 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec<ImageUrl> {
ResponseItem::Message { content, .. } => {
image_urls.extend(content.iter().rev().filter_map(|item| match item {
ContentItem::InputImage { image_url, .. } => Some(image_url.clone()),
ContentItem::InputText { .. } | ContentItem::OutputText { .. } => None,
ContentItem::InputText { .. }
| ContentItem::InputAudio { .. }
| ContentItem::OutputText { .. } => None,
}));
}
ResponseItem::FunctionCallOutput {
@@ -417,6 +419,7 @@ fn output_image_urls(output: &FunctionCallOutputPayload) -> impl Iterator<Item =
.filter_map(|item| match item {
FunctionCallOutputContentItem::InputImage { image_url, .. } => Some(image_url.clone()),
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
}
+5 -1
View File
@@ -36,7 +36,11 @@ pub(crate) fn collect_explicit_skill_mentions(
blocked_plain_names.insert(name.clone());
select_by_path(catalog, path, &mut seen, &mut selected);
}
UserInput::Text { .. } | UserInput::Image { .. } | UserInput::LocalImage { .. } => {}
UserInput::Text { .. }
| UserInput::Image { .. }
| UserInput::LocalImage { .. }
| UserInput::Audio { .. }
| UserInput::LocalAudio { .. } => {}
UserInput::Mention { .. } => {}
_ => {}
}
+95 -9
View File
@@ -718,6 +718,9 @@ pub enum ContentItem {
#[ts(optional)]
detail: Option<ImageDetail>,
},
InputAudio {
audio_url: String,
},
OutputText {
text: String,
},
@@ -1334,16 +1337,37 @@ fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>
}
}
fn local_image_error_placeholder(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalMediaKind {
Audio,
Image,
}
impl LocalMediaKind {
fn name(self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
}
}
}
fn local_media_error_placeholder(
path: &std::path::Path,
error: impl std::fmt::Display,
media_kind: LocalMediaKind,
) -> ContentItem {
let media_name = media_kind.name();
let path = path.display();
ContentItem::InputText {
text: format!(
"Codex could not read the local image at `{}`: {}",
path.display(),
error
),
text: format!("Codex could not read the local {media_name} at `{path}`: {error}"),
}
}
fn local_media_kind_unsupported(media_kind: LocalMediaKind) -> ContentItem {
let media_name = media_kind.name();
ContentItem::InputText {
text: format!("Codex does not support local {media_name} input yet."),
}
}
@@ -1431,13 +1455,21 @@ pub fn local_image_content_items_with_label_number(
| ImageProcessingError::Encode { .. }
| ImageProcessingError::InvalidDataUrl { .. }
| ImageProcessingError::ImageTooLarge { .. } => {
vec![local_image_error_placeholder(path, &err)]
vec![local_media_error_placeholder(
path,
&err,
LocalMediaKind::Image,
)]
}
ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
vec![invalid_image_error_placeholder(path, &err)]
}
ImageProcessingError::Decode { .. } => {
vec![local_image_error_placeholder(path, &err)]
vec![local_media_error_placeholder(
path,
&err,
LocalMediaKind::Image,
)]
}
ImageProcessingError::UnsupportedImageFormat { mime } => {
vec![unsupported_image_error_placeholder(path, mime)]
@@ -1647,9 +1679,20 @@ impl ResponseInputItem {
detail,
),
},
Err(err) => vec![local_image_error_placeholder(&path, err)],
Err(err) => vec![local_media_error_placeholder(
&path,
err,
LocalMediaKind::Image,
)],
}
}
UserInput::Audio { .. } => vec![ContentItem::InputText {
text: "Codex does not support audio input yet.".to_string(),
}],
// TODO: Load local audio inputs once they are supported.
UserInput::LocalAudio { .. } => {
vec![local_media_kind_unsupported(LocalMediaKind::Audio)]
}
UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core
})
.collect::<Vec<ContentItem>>(),
@@ -1707,6 +1750,10 @@ pub enum FunctionCallOutputContentItem {
#[ts(optional)]
detail: Option<ImageDetail>,
},
// Do not rename, these are serialized and used directly in the responses API.
InputAudio {
audio_url: String,
},
EncryptedContent {
encrypted_content: String,
},
@@ -1734,6 +1781,7 @@ pub fn function_call_output_content_items_to_text(
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>();
@@ -3192,6 +3240,44 @@ mod tests {
Ok(())
}
#[test]
fn replaces_unsupported_audio_user_input_with_placeholder() {
let item = ResponseInputItem::from(vec![UserInput::Audio {
audio_url: "data:audio/wav;base64,abc".to_string(),
}]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Codex does not support audio input yet.".to_string(),
}],
phase: None,
}
);
}
#[test]
fn replaces_unsupported_local_audio_user_input_with_placeholder() -> Result<()> {
let item = ResponseInputItem::from(vec![UserInput::LocalAudio {
path: "sample.mp3".into(),
}]);
assert_eq!(
item,
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Codex does not support local audio input yet.".to_string(),
}],
phase: None,
}
);
Ok(())
}
#[test]
fn image_user_input_preserves_requested_detail() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();
+4 -1
View File
@@ -29,7 +29,6 @@ pub enum UserInput {
#[ts(optional)]
detail: Option<ImageDetail>,
},
/// Local image path provided by the user. This will be converted to an
/// `Image` variant (base64 data URL) during request serialization.
LocalImage {
@@ -38,6 +37,10 @@ pub enum UserInput {
#[ts(optional)]
detail: Option<ImageDetail>,
},
/// Pre-encoded audio data URI. Audio input is not yet forwarded to the Responses API.
Audio { audio_url: String },
/// Local audio path provided by the user. Local audio is not converted in this layer.
LocalAudio { path: std::path::PathBuf },
/// Skill selected by the user (name + path to SKILL.md).
Skill {
+1 -1
View File
@@ -293,7 +293,7 @@ fn conversation_text_from_item(item: &RolloutItem) -> Option<String> {
fn content_item_text(item: &ContentItem) -> Option<&str> {
match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => Some(text.as_str()),
ContentItem::InputImage { .. } => None,
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => None,
}
}
+9
View File
@@ -202,6 +202,9 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)),
}
}
codex_protocol::models::ContentItem::InputAudio { audio_url } => {
FunctionCallOutputContentItem::InputAudio { audio_url }
}
})
.collect::<Vec<_>>(),
),
@@ -233,8 +236,14 @@ fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) ->
{
Some(image_url.clone())
}
FunctionCallOutputContentItem::InputAudio { audio_url }
if !audio_url.trim().is_empty() =>
{
Some(audio_url.clone())
}
FunctionCallOutputContentItem::InputText { .. }
| FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>()
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/app_server.rs
expression: "lines_to_single_string(&inserted[0])"
---
› Please inspect the attachments.
@@ -538,6 +538,43 @@ async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn live_app_server_user_message_omits_unsupported_media() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: AppServerThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: vec![
AppServerUserInput::Text {
text: "Please inspect the attachments.".to_string(),
text_elements: Vec::new(),
},
AppServerUserInput::Audio {
url: "https://example.com/one.wav".to_string(),
},
AppServerUserInput::LocalAudio {
path: test_path_buf("/tmp/two.wav"),
},
],
},
}),
/*replay_kind*/ None,
);
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert_chatwidget_snapshot!(
"live_app_server_user_message_omits_unsupported_media",
lines_to_single_string(&inserted[0]),
);
}
#[tokio::test]
async fn live_app_server_turn_completed_clears_working_status_after_answer_item() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+19 -3
View File
@@ -589,7 +589,11 @@ pub(crate) fn mention_bindings_from_user_inputs(
path: path.clone(),
})
}
UserInput::Text { .. } | UserInput::Image { .. } | UserInput::LocalImage { .. } => None,
UserInput::Text { .. }
| UserInput::Image { .. }
| UserInput::LocalImage { .. }
| UserInput::Audio { .. }
| UserInput::LocalAudio { .. } => None,
})
.collect();
mention_bindings.sort_by_key(|binding| {
@@ -655,7 +659,10 @@ impl ChatWidget {
match item {
UserInput::Text { text, .. } => message.push_str(text),
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
UserInput::Audio { .. }
| UserInput::LocalAudio { .. }
| UserInput::Skill { .. }
| UserInput::Mention { .. } => {}
}
}
@@ -666,6 +673,12 @@ impl ChatWidget {
}
pub(crate) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay {
if items
.iter()
.any(|item| matches!(item, UserInput::Audio { .. } | UserInput::LocalAudio { .. }))
{
tracing::warn!("audio user inputs are not supported by the TUI and will be omitted");
}
let mut message = String::new();
let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new();
@@ -694,7 +707,10 @@ impl ChatWidget {
),
UserInput::Image { url, .. } => remote_image_urls.push(url.clone()),
UserInput::LocalImage { path, .. } => local_images.push(path.clone()),
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
UserInput::Audio { .. }
| UserInput::LocalAudio { .. }
| UserInput::Skill { .. }
| UserInput::Mention { .. } => {}
}
}
+12
View File
@@ -13,6 +13,7 @@ use crate::history_cell::split_reasoning_summary_parts;
use crate::multi_agents::sub_agent_activity_summary;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
use codex_protocol::items::UserMessageItem;
use ratatui::style::Stylize as _;
@@ -54,6 +55,17 @@ pub(crate) fn thread_to_transcript_cells(
client_id,
content,
} => {
if content.iter().any(|input| {
matches!(
input,
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
)
}) {
tracing::warn!(
user_message_id = id,
"audio user inputs are not supported by the TUI and will be omitted"
);
}
let item = UserMessageItem {
id: id.clone(),
client_id: client_id.clone(),
+12 -2
View File
@@ -38,12 +38,20 @@ pub fn formatted_truncate_text_content_items_with_policy(
.filter_map(|item| match item {
FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()),
FunctionCallOutputContentItem::InputImage { .. }
| FunctionCallOutputContentItem::InputAudio { .. }
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>();
let without_audio = || {
items
.iter()
.filter(|item| !matches!(item, FunctionCallOutputContentItem::InputAudio { .. }))
.cloned()
.collect()
};
if text_segments.is_empty() {
return (items.to_vec(), None);
return (without_audio(), None);
}
let mut combined = String::new();
@@ -55,7 +63,7 @@ pub fn formatted_truncate_text_content_items_with_policy(
}
if combined.len() <= policy.byte_budget() {
return (items.to_vec(), None);
return (without_audio(), None);
}
let original_token_count = approx_token_count(&combined);
@@ -69,6 +77,7 @@ pub fn formatted_truncate_text_content_items_with_policy(
detail: *detail,
})
}
FunctionCallOutputContentItem::InputAudio { .. } => None,
FunctionCallOutputContentItem::EncryptedContent { encrypted_content } => {
Some(FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: encrypted_content.clone(),
@@ -127,6 +136,7 @@ pub fn truncate_function_output_items_with_policy(
detail: *detail,
});
}
FunctionCallOutputContentItem::InputAudio { .. } => {}
FunctionCallOutputContentItem::EncryptedContent { encrypted_content } => {
out.push(FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: encrypted_content.clone(),
@@ -208,7 +208,7 @@ fn formatted_truncate_text_content_items_with_policy_preserves_empty_leading_tex
}
#[test]
fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_images() {
fn formatted_truncate_text_content_items_with_policy_merges_text_and_drops_audio() {
let items = vec![
FunctionCallOutputContentItem::InputText {
text: "abcd".to_string(),
@@ -220,6 +220,9 @@ fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_ima
FunctionCallOutputContentItem::InputText {
text: "efgh".to_string(),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "audio:one".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: "ijkl".to_string(),
},
@@ -280,7 +283,7 @@ fn formatted_truncate_text_content_items_with_policy_preserves_encrypted_content
}
#[test]
fn truncate_function_output_items_with_policy_preserves_encrypted_content() {
fn truncate_function_output_items_with_policy_preserves_encrypted_content_and_drops_audio() {
let items = vec![
FunctionCallOutputContentItem::InputText {
text: "abcdefgh".to_string(),
@@ -288,6 +291,9 @@ fn truncate_function_output_items_with_policy_preserves_encrypted_content() {
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: "enc_opaque".to_string(),
},
FunctionCallOutputContentItem::InputAudio {
audio_url: "audio:one".to_string(),
},
];
let output = truncate_function_output_items_with_policy(&items, TruncationPolicy::Bytes(2));