Files
codex/codex-rs/ext/image-generation/src/tests.rs
T
pakrym-oaiandGitHub dbd2857f4b [codex] Add optional IDs to response items (#28812)
## Why

`ResponseItem` variants do not have a consistent internal ID shape: some
variants carry required IDs, some carry optional IDs, and some cannot
represent an ID at all. The existing fields also use inconsistent serde,
TypeScript, and JSON-schema annotations. A single enum-level access path
is needed before history recording can assign and retain IDs.

This PR establishes that internal model only. It intentionally does not
generate or serialize IDs; allocation and wire persistence are isolated
in the stacked follow-up.

## What changed

- Give every concrete `ResponseItem` variant an `Option<String>` ID
field.
- Apply the same internal-only annotations to every ID field:
`#[serde(default, skip_serializing)]`, `#[ts(skip)]`, and
`#[schemars(skip)]`.
- Add `ResponseItem::id()` and `ResponseItem::set_id()` as the shared
accessors.
- Preserve IDs when history items are rewritten for truncation.
- Adapt consumers that previously assumed reasoning and image-generation
IDs were required.
- Regenerate app-server schemas so the hidden fields are represented
consistently.

The serde catch-all `ResponseItem::Other` remains ID-less because it
must remain a unit variant.

## Test plan

- `cargo check --tests -p codex-core -p codex-api -p codex-rollout-trace
-p codex-image-generation-extension`
- `just test -p codex-protocol`
- `just test -p codex-app-server-protocol`
- `just test -p codex-api -p codex-rollout-trace -p
codex-image-generation-extension`
- `just test -p codex-core event_mapping`
2026-06-17 18:27:43 -07:00

332 lines
10 KiB
Rust

use codex_api::ImageBackground;
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageQuality;
use codex_api::ImageUrl;
use codex_core::context::extension_image_generation_output_hint;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolPayload;
use codex_extension_api::ToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::ResponsesApiNamespaceTool;
use pretty_assertions::assert_eq;
use super::GeneratedImageOutput;
use super::ImageRequest;
use super::ImagegenArgs;
use super::imagegen_tool_spec;
use super::request_for_call_args;
use crate::IMAGE_GEN_NAMESPACE;
use crate::IMAGEGEN_TOOL_NAME;
const RESULT: &str = "cG5n";
#[test]
fn uses_reserved_image_gen_namespace() {
let ToolSpec::Namespace(spec) = imagegen_tool_spec() else {
panic!("imagegen should advertise a namespace tool");
};
assert_eq!(spec.name, IMAGE_GEN_NAMESPACE);
let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0];
assert_eq!(function.name, IMAGEGEN_TOOL_NAME);
}
#[tokio::test]
async fn omitted_references_generate_with_fixed_defaults() {
assert_eq!(
request_for_call_args(
&ImagegenArgs {
prompt: "paint a moonlit lake".to_string(),
referenced_image_paths: None,
num_last_images_to_include: None,
},
&[],
&[],
)
.await
.expect("generation request should build"),
ImageRequest::Generate(ImageGenerationRequest {
prompt: "paint a moonlit lake".to_string(),
background: Some(ImageBackground::Auto),
model: "gpt-image-2".to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
})
);
}
#[tokio::test]
async fn recent_image_fallback_selects_newest_images_in_chronological_order() {
let history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
input_image("user-1"),
input_image("user-2"),
ContentItem::InputText {
text: "edit these".to_string(),
},
],
phase: None,
metadata: None,
},
ResponseItem::FunctionCall {
id: None,
name: "mcp_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "mcp-call".to_string(),
metadata: None,
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: "mcp-call".to_string(),
output: image_output("mcp"),
metadata: None,
},
ResponseItem::CustomToolCall {
id: None,
status: Some("completed".to_string()),
call_id: "code-mode-call".to_string(),
name: "exec".to_string(),
input: String::new(),
metadata: None,
},
ResponseItem::CustomToolCallOutput {
id: None,
call_id: "code-mode-call".to_string(),
name: Some("exec".to_string()),
output: image_output("code-mode"),
metadata: None,
},
ResponseItem::ImageGenerationCall {
id: Some("generated-call".to_string()),
status: "completed".to_string(),
revised_prompt: None,
result: "generated".to_string(),
metadata: None,
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: "orphan-call".to_string(),
output: image_output("orphan"),
metadata: None,
},
];
assert_eq!(
request_for_call_args(
&ImagegenArgs {
prompt: "change the lighting".to_string(),
referenced_image_paths: None,
num_last_images_to_include: Some(4),
},
&history,
&[],
)
.await
.expect("history-backed edit request should build"),
ImageRequest::Edit(expected_edit_request(
"change the lighting",
&["user-2", "mcp", "code-mode", "generated"],
))
);
}
#[tokio::test]
async fn conflicting_image_selectors_return_tool_error() {
let error = request_for_call_args(
&ImagegenArgs {
prompt: "change the lighting".to_string(),
referenced_image_paths: Some(vec![
"/tmp/image.png"
.try_into()
.expect("test path should be absolute"),
]),
num_last_images_to_include: Some(1),
},
&[],
&[],
)
.await
.expect_err("conflicting selectors should fail");
assert_eq!(
error.to_string(),
"provide only one of `referenced_image_paths` or `num_last_images_to_include`"
);
}
#[tokio::test]
async fn too_many_referenced_image_paths_return_tool_error() {
let error = request_for_call_args(
&ImagegenArgs {
prompt: "change the lighting".to_string(),
referenced_image_paths: Some(
(0..6)
.map(|index| {
format!("/tmp/image-{index}.png")
.try_into()
.expect("test path should be absolute")
})
.collect(),
),
num_last_images_to_include: None,
},
&[],
&[],
)
.await
.expect_err("too many paths should fail before reading files");
assert_eq!(
error.to_string(),
"`referenced_image_paths` must contain at most 5 paths"
);
}
#[tokio::test]
async fn recent_image_fallback_requires_requested_count() {
let error = request_for_call_args(
&ImagegenArgs {
prompt: "change the lighting".to_string(),
referenced_image_paths: None,
num_last_images_to_include: Some(2),
},
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![input_image("only-image")],
phase: None,
metadata: None,
}],
&[],
)
.await
.expect_err("history-backed edit should require the requested image count");
assert_eq!(
error.to_string(),
"requested the last 2 conversation images, but only 1 were available"
);
}
#[test]
fn generated_output_returns_image_input_and_output_hint() {
let output_hint =
extension_image_generation_output_hint("/tmp", "/tmp/call-1.png").expect("hint should fit");
let output = GeneratedImageOutput {
result: RESULT.to_string(),
output_hint: Some(output_hint.clone()),
};
let ResponseInputItem::FunctionCallOutput {
output: response_output,
..
} = output.to_response_item("call-1", &function_payload())
else {
panic!("imagegen should return function tool output");
};
let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else {
panic!("imagegen output should contain generated image bytes");
};
assert_eq!(
content_items,
vec![
FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{RESULT}"),
detail: Some(DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText { text: output_hint },
]
);
}
#[test]
fn generated_output_returns_generated_image_helper_input_in_code_mode() {
let output = GeneratedImageOutput {
result: RESULT.to_string(),
output_hint: Some("generated image save hint".to_string()),
};
assert_eq!(
output.code_mode_result(&function_payload()),
serde_json::json!({
"image_url": format!("data:image/png;base64,{RESULT}"),
"output_hint": "generated image save hint",
})
);
}
#[test]
fn generated_output_omits_oversized_output_hint() {
let long_path = "x".repeat(1024);
let output = GeneratedImageOutput {
result: RESULT.to_string(),
output_hint: extension_image_generation_output_hint("/tmp", long_path),
};
let ResponseInputItem::FunctionCallOutput {
output: response_output,
..
} = output.to_response_item("call-1", &function_payload())
else {
panic!("imagegen should return function tool output");
};
let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else {
panic!("imagegen output should contain generated image bytes");
};
assert_eq!(
content_items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{RESULT}"),
detail: Some(DEFAULT_IMAGE_DETAIL),
}]
);
}
fn input_image(image: &str) -> ContentItem {
ContentItem::InputImage {
image_url: format!("data:image/png;base64,{image}"),
detail: None,
}
}
fn image_output(image: &str) -> FunctionCallOutputPayload {
FunctionCallOutputPayload::from_content_items(vec![FunctionCallOutputContentItem::InputImage {
image_url: format!("data:image/png;base64,{image}"),
detail: None,
}])
}
fn expected_edit_request(prompt: &str, images: &[&str]) -> ImageEditRequest {
ImageEditRequest {
images: images
.iter()
.map(|image| ImageUrl {
image_url: format!("data:image/png;base64,{image}"),
})
.collect(),
prompt: prompt.to_string(),
background: Some(ImageBackground::Auto),
model: "gpt-image-2".to_string(),
n: None,
quality: Some(ImageQuality::Auto),
size: Some("auto".to_string()),
}
}
fn function_payload() -> ToolPayload {
ToolPayload::Function {
arguments: "{}".to_string(),
}
}