use super::support::*;
use super::*;
use crate::provider::retry::{ProviderFailureKind, RetryDisposition};
use crate::provider::transports::OpenAiResponsesTransportContract;
use crate::tool::{apply_patch::ApplyPatchSurface, tools::apply_patch_tool, ToolSpec};
use serde_json::{json, Value};
#[test]
fn relaxed_emitted_schema_disables_additional_properties_recursively() {
let schema = json!({
"type": "object",
"properties": {
"payload": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
});
let relaxed = emitted_tool_json_schema(&schema, ToolSchemaContract::Relaxed).unwrap();
assert_eq!(relaxed["additionalProperties"], Value::Bool(false));
assert_eq!(
relaxed["properties"]["payload"]["additionalProperties"],
Value::Bool(false)
);
validate_emitted_tool_schema(&relaxed, ToolSchemaContract::Relaxed).unwrap();
}
#[test]
fn emitted_spawn_agent_schema_strips_openai_incompatible_top_level_composition() {
let tools = trusted_tool_specs();
let spawn_agent = tools
.into_iter()
.find(|spec| spec.name == "SpawnAgent")
.expect("SpawnAgent should be present");
let relaxed =
emitted_tool_json_schema(&spawn_agent.input_schema, ToolSchemaContract::Relaxed).unwrap();
assert_eq!(relaxed["type"], "object");
for forbidden in ["allOf", "anyOf", "oneOf", "enum", "not"] {
assert!(
relaxed.get(forbidden).is_none(),
"SpawnAgent emitted schema should not contain top-level {forbidden}: {relaxed}"
);
}
validate_emitted_tool_schema(&relaxed, ToolSchemaContract::Relaxed).unwrap();
}
#[test]
fn openai_input_preserves_tool_results() {
let input = build_openai_input(&[
ConversationMessage::UserText("inspect".into()),
ConversationMessage::AssistantBlocks(vec![ModelBlock::ToolUse {
id: "call_1".into(),
name: "ExecCommand".into(),
input: json!({"cmd": "sed -n '1,40p' src/main.rs", "workdir": "."}),
}]),
ConversationMessage::UserToolResults(vec![ToolResultBlock {
tool_use_id: "call_1".into(),
content: "file contents".into(),
is_error: false,
error: None,
}]),
])
.unwrap();
assert_eq!(input[1]["type"], "function_call");
assert_eq!(input[2]["type"], "function_call_output");
}
#[test]
fn openai_input_preserves_apply_patch_custom_tool_results() {
let input = build_openai_input(&[
ConversationMessage::UserText("inspect".into()),
ConversationMessage::AssistantBlocks(vec![ModelBlock::ToolUse {
id: "call_1".into(),
name: "ApplyPatch".into(),
input: json!("--- /dev/null\n+++ b/note.txt\n@@ -0,0 +1,1 @@\n+hi\n"),
}]),
ConversationMessage::UserToolResults(vec![ToolResultBlock {
tool_use_id: "call_1".into(),
content: "Success. Updated the following files:\nA note.txt".into(),
is_error: false,
error: None,
}]),
])
.unwrap();
assert_eq!(input[1]["type"], "custom_tool_call");
assert_eq!(input[2]["type"], "custom_tool_call_output");
}
#[test]
fn parse_openai_response_handles_text_and_function_calls() {
let response = json!({
"status": "completed",
"usage": {
"input_tokens": 11,
"output_tokens": 7,
"input_tokens_details": {
"cached_tokens": 5
}
},
"output": [
{
"type": "message",
"content": [
{ "type": "output_text", "text": "done" }
]
},
{
"type": "function_call",
"call_id": "call_1",
"name": "ExecCommand",
"arguments": "{\"cmd\":\"sed -n '1,40p' src/main.rs\",\"workdir\":\".\"}"
}
]
});
let parsed = parse_openai_response(response).unwrap();
assert_eq!(parsed.blocks.len(), 2);
assert_eq!(parsed.input_tokens, 11);
assert_eq!(parsed.output_tokens, 7);
assert_eq!(
parsed
.cache_usage
.as_ref()
.map(|usage| usage.read_input_tokens),
Some(5)
);
}
#[test]
fn parse_openai_response_handles_custom_tool_calls() {
let response = json!({
"status": "completed",
"output": [
{
"type": "custom_tool_call",
"call_id": "call_patch",
"name": "ApplyPatch",
"input": "--- /dev/null\n+++ b/note.txt\n@@ -0,0 +1,1 @@\n+hi\n"
}
]
});
let parsed = parse_openai_response(response).unwrap();
assert_eq!(parsed.blocks.len(), 1);
match &parsed.blocks[0] {
ModelBlock::ToolUse { id, name, input } => {
assert_eq!(id, "call_patch");
assert_eq!(name, "ApplyPatch");
assert_eq!(
input.as_str(),
Some("--- /dev/null\n+++ b/note.txt\n@@ -0,0 +1,1 @@\n+hi\n")
);
}
_ => panic!("expected tool use block"),
}
}
#[test]
fn parse_openai_response_falls_back_to_prompt_token_details_for_cached_tokens() {
let response = json!({
"status": "completed",
"usage": {
"input_tokens": 11,
"output_tokens": 7,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"output": [
{
"type": "message",
"content": [
{ "type": "output_text", "text": "done" }
]
}
]
});
let parsed = parse_openai_response(response).unwrap();
assert_eq!(
parsed
.cache_usage
.as_ref()
.map(|usage| usage.read_input_tokens),
Some(3)
);
}
#[test]
fn parse_openai_response_classifies_shape_errors_as_invalid_response() {
let error = parse_openai_response(json!({
"status": "completed",
"output": [
{
"type": "function_call",
"name": "ExecCommand",
"arguments": "{}"
}
]
}))
.err()
.expect("missing call_id should fail");
let classification = super::super::retry::classify_provider_error(&error);
assert_eq!(classification.kind, ProviderFailureKind::InvalidResponse);
assert_eq!(classification.disposition, RetryDisposition::FailFast);
}
#[test]
fn openai_responses_request_sets_store_false() {
let request = ProviderTurnRequest::plain(
"system",
vec![ConversationMessage::UserText("hello".into())],
vec![ToolSpec {
name: "ExecCommand".into(),
description: "Run a shell command".into(),
input_schema: json!({
"type": "object",
"properties": {
"cmd": { "type": "string" }
},
"required": ["cmd"]
}),
freeform_grammar: None,
}],
);
let body = build_openai_responses_request(
"gpt-5.3-codex-spark",
2048,
&request,
OpenAiResponsesTransportContract::StandardJson,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["store"], Value::Bool(false));
}
#[test]
fn openai_responses_request_lowers_prompt_frame_to_full_request_with_cache_key() {
let request = provider_turn_request_with_prompt_frame();
let body = build_openai_responses_request(
"gpt-5.4",
2048,
&request,
OpenAiResponsesTransportContract::StandardJson,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["instructions"], json!("rendered system"));
assert_eq!(body["prompt_cache_key"], json!("cache-key"));
assert_eq!(
body["input"][0]["content"][0]["text"],
json!("agent context")
);
}
#[test]
fn build_openai_standard_request_includes_max_output_tokens() {
let request = provider_turn_request();
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::StandardJson,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["max_output_tokens"], Value::from(256));
assert!(body.get("stream").is_none());
}
#[test]
fn build_openai_codex_streaming_request_omits_max_output_tokens() {
let request = provider_turn_request();
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::CodexStreaming,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["stream"], Value::Bool(true));
assert!(body.get("max_output_tokens").is_none());
assert!(body.get("reasoning").is_some());
assert_eq!(body["reasoning"], Value::Null);
assert_eq!(body["include"], json!([]));
}
#[test]
fn build_openai_codex_streaming_request_sends_supported_reasoning_effort() {
let request = provider_turn_request();
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::CodexStreaming,
ToolSchemaContract::Relaxed,
Some("low"),
)
.unwrap();
assert_eq!(body["stream"], Value::Bool(true));
assert!(body.get("max_output_tokens").is_none());
assert_eq!(body["reasoning"], json!({ "effort": "low" }));
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
}
#[test]
fn openai_request_uses_function_tool_shape_for_generic_apply_patch() {
let apply_patch = tool_spec_named("ApplyPatch");
let request = provider_turn_request_with_tools(vec![apply_patch]);
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::StandardJson,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["tools"][0]["type"], "function");
assert_eq!(body["tools"][0]["name"], "ApplyPatch");
assert_eq!(
body["tools"][0]["parameters"]["properties"]["patch"]["type"],
"string"
);
assert!(body["tools"][0].get("format").is_none());
}
#[test]
fn openai_codex_request_uses_custom_codex_dsl_tool_shape_for_apply_patch() {
let apply_patch = apply_patch_tool::definition_for_surface(ApplyPatchSurface::CodexDslFreeform)
.unwrap()
.spec;
let request = provider_turn_request_with_tools(vec![apply_patch]);
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::CodexStreaming,
ToolSchemaContract::Relaxed,
None,
)
.unwrap();
assert_eq!(body["tools"][0]["type"], "custom");
assert_eq!(body["tools"][0]["name"], "ApplyPatch");
assert_eq!(body["tools"][0]["format"]["type"], "grammar");
assert_eq!(body["tools"][0]["format"]["syntax"], "lark");
let grammar = body["tools"][0]["format"]["definition"]
.as_str()
.expect("grammar definition should be a string");
assert!(grammar.contains("*** Begin Patch"));
assert!(!grammar.contains("old_file: \"--- \" file_path LF"));
}
#[test]
fn built_in_tool_source_schemas_remain_valid() {
for spec in trusted_tool_specs() {
crate::tool::schema::validate_source_tool_schema(&spec.input_schema)
.unwrap_or_else(|error| panic!("{} source schema invalid: {error}", spec.name));
}
}
#[test]
fn openai_request_payload_validates_full_tool_matrix_in_strict_mode() {
let tools = trusted_tool_specs();
let request = provider_turn_request_with_tools(tools.clone());
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::StandardJson,
ToolSchemaContract::Strict,
None,
)
.unwrap();
assert_eq!(body["max_output_tokens"], Value::from(256));
let emitted_tools = body["tools"].as_array().expect("tools should be an array");
assert_eq!(emitted_tools.len(), tools.len());
for retired in ["Read", "Glob", "Grep"] {
assert!(emitted_tools.iter().all(|tool| tool["name"] != retired));
}
for tool in emitted_tools {
if tool["type"] == "custom" {
assert_eq!(tool["name"], "ApplyPatch");
assert_eq!(tool["format"]["type"], "grammar");
assert_eq!(tool["format"]["syntax"], "lark");
} else {
assert_eq!(tool["strict"], Value::Bool(true));
validate_emitted_tool_schema(&tool["parameters"], ToolSchemaContract::Strict).unwrap();
}
}
let enqueue = emitted_tools
.iter()
.find(|tool| tool["name"] == "Enqueue")
.expect("Enqueue tool should be present");
let enqueue_required = enqueue["parameters"]["required"]
.as_array()
.expect("required should be an array");
assert_eq!(enqueue_required.len(), 2);
assert!(enqueue_required.iter().any(|value| value == "text"));
assert!(enqueue_required.iter().any(|value| value == "priority"));
let priority_types = enqueue["parameters"]["properties"]["priority"]["type"]
.as_array()
.expect("priority type should be an array")
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(priority_types.contains(&"string"));
assert!(priority_types.contains(&"null"));
assert!(enqueue["parameters"]["properties"]["priority"]["enum"]
.as_array()
.expect("priority enum should be an array")
.iter()
.any(Value::is_null));
let sleep = emitted_tools
.iter()
.find(|tool| tool["name"] == "Sleep")
.expect("Sleep tool should be present");
let duration_types = sleep["parameters"]["properties"]["duration_ms"]["type"]
.as_array()
.expect("duration_ms type should be an array")
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>();
assert!(duration_types.contains(&"integer"));
assert!(duration_types.contains(&"null"));
assert_eq!(
sleep["parameters"]["properties"]["duration_ms"]["minimum"],
Value::from(1.0)
);
let memory_get = emitted_tools
.iter()
.find(|tool| tool["name"] == "MemoryGet")
.expect("MemoryGet tool should be present");
assert_eq!(
memory_get["parameters"]["properties"]["max_chars"]["minimum"],
Value::from(1.0)
);
assert_eq!(
memory_get["parameters"]["properties"]["max_chars"]["maximum"],
Value::from(50000.0)
);
}
#[test]
fn openai_codex_request_payload_validates_full_tool_matrix_in_strict_mode() {
let tools = trusted_tool_specs();
let request = provider_turn_request_with_tools(tools.clone());
let body = build_openai_responses_request(
"gpt-5.4",
256,
&request,
OpenAiResponsesTransportContract::CodexStreaming,
ToolSchemaContract::Strict,
Some("low"),
)
.unwrap();
assert_eq!(body["stream"], Value::Bool(true));
assert!(body.get("max_output_tokens").is_none());
let emitted_tools = body["tools"].as_array().expect("tools should be an array");
assert_eq!(emitted_tools.len(), tools.len());
for retired in ["Read", "Glob", "Grep"] {
assert!(emitted_tools.iter().all(|tool| tool["name"] != retired));
}
for tool in emitted_tools {
if tool["type"] == "custom" {
assert_eq!(tool["name"], "ApplyPatch");
assert_eq!(tool["format"]["type"], "grammar");
assert_eq!(tool["format"]["syntax"], "lark");
} else {
assert_eq!(tool["strict"], Value::Bool(true));
validate_emitted_tool_schema(&tool["parameters"], ToolSchemaContract::Strict).unwrap();
}
}
assert!(emitted_tools
.iter()
.all(|tool| tool["name"] != "CreateExternalTrigger"));
assert!(emitted_tools
.iter()
.all(|tool| tool["name"] != "CancelExternalTrigger"));
}