chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
@@ -0,0 +1,34 @@
[package]
name = "jcode-provider-openai-runtime"
version = "0.1.0"
edition = "2024"
description = "OpenAI provider runtime (Codex OAuth + API key, Responses API over SSE/WebSocket) for jcode, kept downstream of jcode-base so provider edits do not rebuild the app spine"
[lib]
name = "jcode_provider_openai_runtime"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
# default-features = false: the top-level binary decides heavy optional base
# features (embeddings/bedrock). Runtime crates must not re-enable them via
# feature unification, or --no-default-features release targets (e.g. Windows
# ARM64, which cannot build tract-linalg asm) break.
jcode-base = { path = "../jcode-base", default-features = false }
jcode-message-types = { path = "../jcode-message-types" }
jcode-provider-core = { path = "../jcode-provider-core" }
jcode-provider-openai = { path = "../jcode-provider-openai" }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
serde_json = { version = "1", features = ["raw_value"] }
tokio = { version = "1", features = ["sync", "time", "rt", "net", "io-util"] }
tokio-stream = "0.1"
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
dirs = "5"
[dev-dependencies]
# The migrated openai tests use jcode-base's test-env sandbox.
jcode-base = { path = "../jcode-base", features = ["test-support"] }
tempfile = "3"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
pub(super) use jcode_provider_openai::stream::{
OpenAIResponsesStream, parse_openai_response_event,
};
#[cfg(test)]
pub(super) use jcode_provider_openai::stream::{
handle_openai_output_item, parse_text_wrapped_tool_call,
};
@@ -0,0 +1,16 @@
pub(super) use jcode_provider_openai::websocket_health::{
WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE,
WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason,
is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice,
is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success,
summarize_websocket_fallback_reason, websocket_activity_timeout_kind,
websocket_cooldown_remaining, websocket_next_activity_timeout_secs_with_completion,
};
#[cfg(test)]
pub(super) use jcode_provider_openai::websocket_health::{
WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason,
clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown,
websocket_cooldown_for_streak, websocket_next_activity_timeout_secs,
websocket_remaining_timeout_secs,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
#![allow(clippy::collapsible_match)]
use super::*;
use anyhow::Result;
use futures::{SinkExt, StreamExt};
use jcode_base::auth::codex::CodexCredentials;
use jcode_message_types::{ContentBlock, Role};
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::MutexGuard;
use std::time::{Duration, Instant};
const BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/openai/bright_pearl_wrapped_tool_call.txt"
));
struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
jcode_base::env::set_var(key, value);
Self { key, previous }
}
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
let previous = std::env::var_os(key);
jcode_base::env::set_var(key, value);
Self { key, previous }
}
fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
jcode_base::env::remove_var(key);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
jcode_base::env::set_var(self.key, previous);
} else {
jcode_base::env::remove_var(self.key);
}
}
}
async fn test_persistent_ws_state() -> (PersistentWsState, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket listener");
let addr = listener.local_addr().expect("listener local addr");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept websocket client");
let mut ws = tokio_tungstenite::accept_async(stream)
.await
.expect("accept websocket handshake");
while let Some(message) = ws.next().await {
match message {
Ok(WsMessage::Ping(payload)) => {
let _ = ws.send(WsMessage::Pong(payload)).await;
}
Ok(WsMessage::Close(_)) | Err(_) => break,
_ => {}
}
}
});
let (client_ws, _) = connect_async(format!("ws://{}", addr))
.await
.expect("connect websocket client");
(
PersistentWsState {
ws_stream: client_ws,
last_response_id: "resp_test".to_string(),
connected_at: Instant::now(),
last_activity_at: Instant::now(),
message_count: 1,
last_input_item_count: 1,
},
server,
)
}
struct LiveOpenAITestEnv {
_lock: MutexGuard<'static, ()>,
_jcode_home: EnvVarGuard,
_transport: EnvVarGuard,
_temp: tempfile::TempDir,
}
impl LiveOpenAITestEnv {
fn new() -> Result<Option<Self>> {
let lock = jcode_base::storage::lock_test_env();
let Some(source_auth) = real_codex_auth_path() else {
return Ok(None);
};
let temp = tempfile::Builder::new()
.prefix("jcode-openai-live-")
.tempdir()?;
let target_auth = temp
.path()
.join("external")
.join(".codex")
.join("auth.json");
std::fs::create_dir_all(
target_auth
.parent()
.expect("temp auth target should have a parent"),
)?;
std::fs::copy(source_auth, &target_auth)?;
let jcode_home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
let transport = EnvVarGuard::set("JCODE_OPENAI_TRANSPORT", "https");
Ok(Some(Self {
_lock: lock,
_jcode_home: jcode_home,
_transport: transport,
_temp: temp,
}))
}
}
fn real_codex_auth_path() -> Option<PathBuf> {
let home = dirs::home_dir()?;
let path = home.join(".codex").join("auth.json");
path.exists().then_some(path)
}
async fn live_openai_catalog() -> Result<Option<jcode_base::provider::OpenAIModelCatalog>> {
let Some(_env) = LiveOpenAITestEnv::new()? else {
return Ok(None);
};
let creds = jcode_base::auth::codex::load_credentials()?;
if !OpenAIProvider::is_chatgpt_mode(&creds) {
return Ok(None);
}
let token = openai_access_token(&Arc::new(RwLock::new(creds))).await?;
Ok(Some(
jcode_base::provider::fetch_openai_model_catalog(&token).await?,
))
}
async fn live_openai_smoke(model: &str, sentinel: &str) -> Result<Option<String>> {
let Some(_env) = LiveOpenAITestEnv::new()? else {
return Ok(None);
};
let creds = jcode_base::auth::codex::load_credentials()?;
if !OpenAIProvider::is_chatgpt_mode(&creds) {
return Ok(None);
}
let provider = OpenAIProvider::new(creds);
provider.set_model(model)?;
let response = provider
.complete_simple(&format!("Reply with exactly {}.", sentinel), "")
.await?;
Ok(Some(response))
}
include!("openai_tests/models_state.rs");
include!("openai_tests/responses_input.rs");
include!("openai_tests/transport_runtime.rs");
include!("openai_tests/payloads.rs");
include!("openai_tests/parsing_tools.rs");
/// Mirror of the Anthropic round-trip guard: the runtime-provider identity that
/// `set_credential_mode` writes for OpenAI must decode back to the same mode so
/// the model picker / header widget report the auth method that requests will
/// actually use.
#[test]
fn openai_credential_mode_runtime_provider_identity_round_trips() {
let _guard = jcode_base::storage::lock_test_env();
let previous = std::env::var_os("JCODE_RUNTIME_PROVIDER");
jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", "openai");
assert_eq!(
OpenAICredentialMode::from_runtime_env(jcode_provider_core::DualAuthProvider::OpenAI),
OpenAICredentialMode::OAuth,
"OAuth selection must surface as the OAuth runtime identity"
);
jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-api");
assert_eq!(
OpenAICredentialMode::from_runtime_env(jcode_provider_core::DualAuthProvider::OpenAI),
OpenAICredentialMode::ApiKey,
"API-key selection must surface as the API-key runtime identity"
);
match previous {
Some(value) => jcode_base::env::set_var("JCODE_RUNTIME_PROVIDER", value),
None => jcode_base::env::remove_var("JCODE_RUNTIME_PROVIDER"),
}
}
@@ -0,0 +1,351 @@
#[test]
fn test_openai_supports_codex_models() {
let _guard = jcode_base::storage::lock_test_env();
jcode_base::auth::codex::set_active_account_override(Some(
"openai-supports-codex-models".to_string(),
));
jcode_base::provider::populate_account_models(vec![
"gpt-5.1-codex".to_string(),
"gpt-5.1-codex-mini".to_string(),
"gpt-5.2-codex".to_string(),
]);
let creds = CodexCredentials {
access_token: "test".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
};
let provider = OpenAIProvider::new(creds);
assert!(provider.available_models().contains(&"gpt-5.2-codex"));
assert!(provider.available_models().contains(&"gpt-5.1-codex-mini"));
provider.set_model("gpt-5.1-codex").unwrap();
assert_eq!(provider.model(), "gpt-5.1-codex");
provider.set_model("gpt-5.1-codex-mini").unwrap();
assert_eq!(provider.model(), "gpt-5.1-codex-mini");
jcode_base::auth::codex::set_active_account_override(None);
}
#[test]
fn test_openai_switching_models_include_dynamic_catalog_entries() {
let _guard = jcode_base::storage::lock_test_env();
let dynamic_model = "gpt-5.9-switching-test";
jcode_base::auth::codex::set_active_account_override(Some("switching-test".to_string()));
jcode_base::provider::populate_account_models(vec![
"gpt-5.4".to_string(),
dynamic_model.to_string(),
]);
let provider = OpenAIProvider::new(CodexCredentials {
access_token: "test".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
});
let models = provider.available_models_for_switching();
assert!(models.contains(&"gpt-5.4".to_string()));
assert!(models.contains(&dynamic_model.to_string()));
jcode_base::auth::codex::set_active_account_override(None);
}
#[test]
fn test_summarize_ws_input_counts_tool_outputs() {
let items = vec![
serde_json::json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}),
serde_json::json!({
"type": "function_call",
"call_id": "call_1",
"name": "bash",
"arguments": "{}"
}),
serde_json::json!({
"type": "function_call_output",
"call_id": "call_1",
"output": "ok"
}),
serde_json::json!({"type": "unknown"}),
];
assert_eq!(
summarize_ws_input(&items),
WsInputStats {
total_items: 4,
message_items: 1,
function_call_items: 1,
function_call_output_items: 1,
other_items: 1,
}
);
}
#[test]
fn test_persistent_ws_idle_policy_thresholds() {
assert!(!persistent_ws_idle_needs_healthcheck(Duration::from_secs(
5
)));
assert!(persistent_ws_idle_needs_healthcheck(Duration::from_secs(
WEBSOCKET_PERSISTENT_HEALTHCHECK_IDLE_SECS
)));
// Default idle-reconnect window: reuse below threshold, reconnect at/above it.
let default = WEBSOCKET_PERSISTENT_IDLE_RECONNECT_SECS_DEFAULT;
assert!(!idle_requires_reconnect_with(
Some(default),
Duration::from_secs(default - 1)
));
assert!(idle_requires_reconnect_with(
Some(default),
Duration::from_secs(default)
));
// Disabled (None / env=0): never force a reconnect on idle alone.
assert!(!idle_requires_reconnect_with(
None,
Duration::from_secs(u32::MAX as u64)
));
}
#[tokio::test]
#[allow(
clippy::await_holding_lock,
reason = "test intentionally serializes process-wide active OpenAI account model cache across async websocket state setup"
)]
async fn test_set_model_clears_persistent_ws_state() {
let _guard = jcode_base::storage::lock_test_env();
jcode_base::auth::codex::set_active_account_override(Some("openai-set-model-clears-ws".to_string()));
jcode_base::provider::populate_account_models(vec!["gpt-5.3-codex".to_string()]);
let provider = OpenAIProvider::new(CodexCredentials {
access_token: "test".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
});
let (state, server) = test_persistent_ws_state().await;
*provider.persistent_ws.lock().await = Some(state);
provider.set_model("gpt-5.3-codex").expect("set model");
assert!(
provider.persistent_ws.lock().await.is_none(),
"changing models should reset the persistent websocket chain"
);
server.abort();
jcode_base::auth::codex::set_active_account_override(None);
}
#[tokio::test]
async fn test_switching_to_https_clears_persistent_ws_state() {
let provider = OpenAIProvider::new(CodexCredentials {
access_token: "test".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
});
let (state, server) = test_persistent_ws_state().await;
*provider.persistent_ws.lock().await = Some(state);
provider
.set_transport("https")
.expect("switch transport to https");
assert!(
provider.persistent_ws.lock().await.is_none(),
"switching to HTTPS should drop the websocket continuation chain"
);
server.abort();
}
#[test]
fn test_service_tier_can_be_changed_while_a_request_snapshot_is_held() {
let provider = Arc::new(OpenAIProvider::new(CodexCredentials {
access_token: "test".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
}));
let read_guard = provider
.service_tier
.read()
.expect("service tier read lock should be available");
let (tx, rx) = std::sync::mpsc::channel();
let provider_for_write = Arc::clone(&provider);
let handle = std::thread::spawn(move || {
let result = provider_for_write.set_service_tier("priority");
tx.send(result).expect("send result from setter thread");
});
std::thread::sleep(Duration::from_millis(20));
assert!(
rx.try_recv().is_err(),
"writer should wait for the in-flight snapshot to finish"
);
drop(read_guard);
rx.recv()
.expect("receive service tier setter result")
.expect("service tier update should succeed once read lock is released");
handle.join().expect("join setter thread");
assert_eq!(provider.service_tier(), Some("priority".to_string()));
}
/// The OpenAI catalog endpoint and the chat endpoint must be selected by the
/// same authoritative discriminator: the loaded credential's *shape*
/// (`is_chatgpt_mode`), not the requested credential mode or a token-string
/// sniff. A platform API key (`sk-*`, no refresh/id token) must route to the
/// platform endpoints; a ChatGPT/Codex OAuth session must route to the Codex
/// endpoints. If these ever diverge, OpenAI returns 401.
#[test]
fn openai_catalog_and_chat_endpoints_agree_on_credential_shape() {
// API-key-shaped credential: no refresh token, no id token.
let api_key_creds = CodexCredentials {
access_token: "sk-platform-key".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
};
assert!(
!OpenAIProvider::is_chatgpt_mode(&api_key_creds),
"platform API key must not be treated as ChatGPT/Codex mode"
);
assert!(
OpenAIProvider::responses_url(&api_key_creds).starts_with(OPENAI_API_BASE),
"platform API key chat requests must use the platform API base"
);
// OAuth-shaped credential: has a refresh token (Codex/ChatGPT session).
let oauth_creds = CodexCredentials {
access_token: "oauth-access".to_string(),
refresh_token: "oauth-refresh".to_string(),
id_token: None,
account_id: None,
expires_at: None,
};
assert!(
OpenAIProvider::is_chatgpt_mode(&oauth_creds),
"OAuth session with a refresh token must be treated as ChatGPT/Codex mode"
);
assert!(
OpenAIProvider::responses_url(&oauth_creds).starts_with(CHATGPT_API_BASE),
"OAuth chat requests must use the ChatGPT/Codex API base"
);
// An id-token-only credential is also a ChatGPT/Codex session.
let id_token_creds = CodexCredentials {
access_token: "oauth-access".to_string(),
refresh_token: String::new(),
id_token: Some("id-token".to_string()),
account_id: None,
expires_at: None,
};
assert!(
OpenAIProvider::is_chatgpt_mode(&id_token_creds),
"credential with an id token must be treated as ChatGPT/Codex mode"
);
}
/// Issue #343: the native `openai-api` (Responses API) base URL must be
/// overridable for API-key usage so local/proxied Responses endpoints work,
/// while ChatGPT/Codex OAuth mode stays pinned to the Codex backend.
#[test]
fn responses_url_honors_api_base_override_in_api_key_mode() {
let _guard = jcode_base::storage::lock_test_env();
let _b = EnvVarGuard::remove("JCODE_OPENAI_API_BASE");
let _c = EnvVarGuard::remove("OPENAI_BASE_URL");
let _d = EnvVarGuard::remove("OPENAI_API_BASE");
let api_key_creds = CodexCredentials {
access_token: "sk-platform-key".to_string(),
refresh_token: String::new(),
id_token: None,
account_id: None,
expires_at: None,
};
// Default base when unset.
assert_eq!(
OpenAIProvider::responses_url(&api_key_creds),
format!("{}/responses", OPENAI_API_BASE),
);
// Override is applied (and a trailing slash is tolerated).
let _override = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "http://127.0.0.1:8317/v1/");
assert_eq!(
OpenAIProvider::responses_url(&api_key_creds),
"http://127.0.0.1:8317/v1/responses",
);
// WS URL derives from the same base.
assert_eq!(
OpenAIProvider::responses_ws_url(&api_key_creds),
"ws://127.0.0.1:8317/v1/responses",
);
// Compact endpoint too.
assert_eq!(
OpenAIProvider::responses_compact_url(&api_key_creds),
"http://127.0.0.1:8317/v1/responses/compact",
);
}
#[test]
fn responses_url_ignores_override_in_chatgpt_mode() {
let _guard = jcode_base::storage::lock_test_env();
let _override = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "http://127.0.0.1:8317/v1");
let oauth_creds = CodexCredentials {
access_token: "oauth-access".to_string(),
refresh_token: "oauth-refresh".to_string(),
id_token: None,
account_id: None,
expires_at: None,
};
// ChatGPT/Codex OAuth backend must stay fixed regardless of the override.
assert!(
OpenAIProvider::responses_url(&oauth_creds).starts_with(CHATGPT_API_BASE),
"ChatGPT/Codex mode must ignore the API base override"
);
}
#[test]
fn resolve_api_base_precedence_and_validation() {
let _guard = jcode_base::storage::lock_test_env();
let _a = EnvVarGuard::remove("JCODE_OPENAI_API_BASE");
let _b = EnvVarGuard::remove("OPENAI_BASE_URL");
let _c = EnvVarGuard::remove("OPENAI_API_BASE");
// Default.
assert_eq!(OpenAIProvider::resolve_api_base(), OPENAI_API_BASE);
// JCODE_OPENAI_API_BASE wins over OPENAI_BASE_URL / OPENAI_API_BASE.
let _p1 = EnvVarGuard::set("OPENAI_API_BASE", "https://c.example/v1");
let _p2 = EnvVarGuard::set("OPENAI_BASE_URL", "https://b.example/v1");
let _p3 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "https://a.example/v1");
assert_eq!(OpenAIProvider::resolve_api_base(), "https://a.example/v1");
// A trailing /responses is trimmed so callers don't double it.
let _p4 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "https://a.example/v1/responses");
assert_eq!(OpenAIProvider::resolve_api_base(), "https://a.example/v1");
// Non-URL values are ignored, falling through to the next candidate.
let _p5 = EnvVarGuard::set("JCODE_OPENAI_API_BASE", "not-a-url");
assert_eq!(OpenAIProvider::resolve_api_base(), "https://b.example/v1");
}
@@ -0,0 +1,630 @@
#[test]
fn test_parse_openai_response_completed_captures_incomplete_stop_reason() {
let data = r#"{"type":"response.completed","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#;
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let event = parse_openai_response_event(
data,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected message end");
match event {
StreamEvent::MessageEnd { stop_reason } => {
assert_eq!(stop_reason.as_deref(), Some("max_output_tokens"));
}
other => panic!("expected MessageEnd, got {:?}", other),
}
}
#[test]
fn test_parse_openai_response_completed_without_stop_reason() {
let data = r#"{"type":"response.completed","response":{"status":"completed"}}"#;
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let event = parse_openai_response_event(
data,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected message end");
match event {
StreamEvent::MessageEnd { stop_reason } => {
assert!(stop_reason.is_none());
}
other => panic!("expected MessageEnd, got {:?}", other),
}
}
#[test]
fn test_parse_openai_response_completed_commentary_phase_sets_stop_reason() {
let data = r#"{"type":"response.completed","response":{"status":"completed","output":[{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Still working"}]}]}}"#;
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let event = parse_openai_response_event(
data,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected message end");
match event {
StreamEvent::MessageEnd { stop_reason } => {
assert_eq!(stop_reason.as_deref(), Some("commentary"));
}
other => panic!("expected MessageEnd, got {:?}", other),
}
}
#[test]
fn test_parse_openai_response_incomplete_emits_message_end_with_reason() {
let data = r#"{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"content_filter"}}}"#;
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let event = parse_openai_response_event(
data,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected message end");
match event {
StreamEvent::MessageEnd { stop_reason } => {
assert_eq!(stop_reason.as_deref(), Some("content_filter"));
}
other => panic!("expected MessageEnd, got {:?}", other),
}
}
#[test]
fn test_parse_openai_response_function_call_arguments_streaming() {
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let added = r#"{"type":"response.output_item.added","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"batch","arguments":""}}"#;
assert!(
parse_openai_response_event(
added,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.is_none(),
"output_item.added should just seed tool state"
);
let delta = r#"{"type":"response.function_call_arguments.delta","item_id":"fc_123","delta":"{\"tool_calls\":[{\"tool\":\"read\"}]"}"#;
assert!(
parse_openai_response_event(
delta,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.is_none(),
"argument delta should accumulate state only"
);
let done = r#"{"type":"response.function_call_arguments.done","item_id":"fc_123","arguments":"{\"tool_calls\":[{\"tool\":\"read\"}]}"}"#;
let first = parse_openai_response_event(
done,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected tool start");
match first {
StreamEvent::ToolUseStart { id, name } => {
assert_eq!(id, "call_123");
assert_eq!(name, "batch");
}
other => panic!("expected ToolUseStart, got {:?}", other),
}
match pending.pop_front() {
Some(StreamEvent::ToolInputDelta(delta)) => {
let parsed: Value = serde_json::from_str(&delta).expect("valid args json");
let tool_calls = parsed
.get("tool_calls")
.and_then(|v| v.as_array())
.expect("tool_calls array");
assert_eq!(tool_calls.len(), 1);
}
other => panic!("expected ToolInputDelta, got {:?}", other),
}
assert!(matches!(pending.pop_front(), Some(StreamEvent::ToolUseEnd)));
assert!(streaming_tool_calls.is_empty());
assert!(completed_tool_items.contains("fc_123"));
}
#[test]
fn test_parse_openai_response_output_item_done_skips_duplicate_after_arguments_done() {
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::from(["fc_123".to_string()]);
let mut pending = VecDeque::new();
let duplicate_done = r#"{"type":"response.output_item.done","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"batch","arguments":"{\"tool_calls\":[]}"}}"#;
let event = parse_openai_response_event(
duplicate_done,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
);
assert!(event.is_none(), "duplicate function call should be skipped");
assert!(pending.is_empty());
assert!(!completed_tool_items.contains("fc_123"));
}
#[test]
fn test_parse_openai_response_output_item_done_emits_native_compaction() {
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let compaction_done = r#"{"type":"response.output_item.done","item":{"id":"cmp_123","type":"compaction","encrypted_content":"enc_abc"}}"#;
let event = parse_openai_response_event(
compaction_done,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected compaction event");
match event {
StreamEvent::Compaction {
trigger,
pre_tokens,
openai_encrypted_content,
} => {
assert_eq!(trigger, "openai_native_auto");
assert_eq!(pre_tokens, None);
assert_eq!(openai_encrypted_content.as_deref(), Some("enc_abc"));
}
other => panic!("expected Compaction, got {:?}", other),
}
assert!(pending.is_empty());
}
#[test]
fn test_parse_openai_response_output_item_done_emits_reasoning_item() {
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let reasoning_done = r#"{
"type":"response.output_item.done",
"item":{
"id":"rs_123",
"type":"reasoning",
"status":"completed",
"encrypted_content":"enc_reasoning",
"summary":[{"type":"summary_text","text":"Checked the constraints."}]
}
}"#;
let event = parse_openai_response_event(
reasoning_done,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected reasoning event");
match event {
StreamEvent::OpenAIReasoning {
id,
summary,
encrypted_content,
status,
} => {
assert_eq!(id, "rs_123");
assert_eq!(summary, vec!["Checked the constraints.".to_string()]);
assert_eq!(encrypted_content.as_deref(), Some("enc_reasoning"));
assert_eq!(status.as_deref(), Some("completed"));
}
other => panic!("expected OpenAIReasoning, got {:?}", other),
}
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingStart)));
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingDelta(text)) if text == "Checked the constraints."));
assert!(matches!(pending.pop_front(), Some(StreamEvent::ThinkingEnd)));
}
#[test]
fn test_parse_openai_response_image_generation_saves_metadata_and_emits_event() {
let _lock = jcode_base::storage::lock_test_env();
let original_dir = std::env::current_dir().expect("current dir");
let temp = tempfile::Builder::new()
.prefix("jcode-openai-image-test-")
.tempdir()
.expect("tempdir");
std::env::set_current_dir(temp.path()).expect("set temp cwd");
let mut saw_text_delta = false;
let mut streaming_tool_calls = HashMap::new();
let mut completed_tool_items = HashSet::new();
let mut pending = VecDeque::new();
let data = r#"{
"type":"response.output_item.done",
"item":{
"id":"ig_test_123",
"type":"image_generation_call",
"status":"completed",
"output_format":"png",
"revised_prompt":"A polished robot painter prompt",
"result":"AQID"
}
}"#;
let event = parse_openai_response_event(
data,
&mut saw_text_delta,
&mut streaming_tool_calls,
&mut completed_tool_items,
&mut pending,
)
.expect("expected generated image event");
let (image_path, metadata_path) = match event {
StreamEvent::GeneratedImage {
id,
path,
metadata_path,
output_format,
revised_prompt,
} => {
assert_eq!(id, "ig_test_123");
assert_eq!(output_format, "png");
assert_eq!(revised_prompt.as_deref(), Some("A polished robot painter prompt"));
(path, metadata_path.expect("metadata path"))
}
other => panic!("expected GeneratedImage, got {:?}", other),
};
assert!(std::path::Path::new(&image_path).exists());
assert!(std::path::Path::new(&metadata_path).exists());
match pending.pop_front() {
Some(StreamEvent::TextDelta(markdown)) => {
assert!(markdown.contains("![Generated image]"));
assert!(markdown.contains("Metadata saved"));
}
other => panic!("expected generated image markdown TextDelta, got {:?}", other),
}
let metadata: Value = serde_json::from_slice(
&std::fs::read(&metadata_path).expect("read generated image metadata"),
)
.expect("metadata json");
assert_eq!(metadata["schema_version"], serde_json::json!(1));
assert_eq!(metadata["provider"], serde_json::json!("openai"));
assert_eq!(metadata["native_tool"], serde_json::json!("image_generation"));
assert_eq!(metadata["revised_prompt"], serde_json::json!("A polished robot painter prompt"));
assert!(metadata["response_item"].get("result").is_none());
std::env::set_current_dir(original_dir).expect("restore cwd");
}
#[test]
fn test_build_tools_sets_strict_true() {
let defs = vec![ToolDefinition {
name: "bash".to_string(),
description: "run shell".to_string(),
input_schema: serde_json::json!({
"type": "object",
"required": ["command"],
"properties": { "command": { "type": "string" } }
}),
}];
let api_tools = build_tools(&defs);
assert_eq!(api_tools.len(), 1);
assert_eq!(api_tools[0]["strict"], serde_json::json!(true));
}
#[test]
fn test_build_tools_disables_strict_for_free_form_object_nodes() {
let defs = vec![ToolDefinition {
name: "batch".to_string(),
description: "batch calls".to_string(),
input_schema: serde_json::json!({
"type": "object",
"required": ["tool_calls"],
"properties": {
"tool_calls": {
"type": "array",
"items": {
"type": "object",
"required": ["tool", "parameters"],
"properties": {
"tool": { "type": "string" },
"parameters": { "type": "object" }
}
}
}
}
}),
}];
let api_tools = build_tools(&defs);
assert_eq!(api_tools.len(), 1);
assert_eq!(api_tools[0]["strict"], serde_json::json!(false));
assert_eq!(
api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["properties"]["parameters"]
["type"],
serde_json::json!("object")
);
}
#[test]
fn test_build_tools_normalizes_object_schema_additional_properties() {
let defs = vec![ToolDefinition {
name: "edit".to_string(),
description: "apply edit".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"options": {
"type": "object",
"properties": {
"force": { "type": "boolean" }
}
},
"description": {
"type": "string"
}
},
"required": ["path"]
}),
}];
let api_tools = build_tools(&defs);
assert_eq!(
api_tools[0]["parameters"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
api_tools[0]["parameters"]["properties"]["options"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
api_tools[0]["parameters"]["required"],
serde_json::json!(["description", "options", "path"])
);
assert_eq!(
api_tools[0]["parameters"]["properties"]["description"]["type"],
serde_json::json!(["string", "null"])
);
}
#[test]
fn test_build_tools_rewrites_oneof_to_anyof_for_openai() {
let defs = vec![ToolDefinition {
name: "batch".to_string(),
description: "batch calls".to_string(),
input_schema: serde_json::json!({
"type": "object",
"required": ["tool_calls"],
"properties": {
"tool_calls": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"required": ["tool"],
"properties": {
"tool": { "type": "string" }
}
}
]
}
}
}
}),
}];
let api_tools = build_tools(&defs);
assert!(api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["oneOf"].is_null());
assert_eq!(
api_tools[0]["parameters"]["properties"]["tool_calls"]["items"]["anyOf"][0]["type"],
serde_json::json!("object")
);
}
#[test]
fn test_build_tools_keeps_strict_for_anyof_object_branches_with_properties() {
let defs = vec![ToolDefinition {
name: "schedule".to_string(),
description: "schedule work".to_string(),
input_schema: serde_json::json!({
"type": "object",
"required": ["task"],
"anyOf": [
{
"type": "object",
"required": ["wake_in_minutes"],
"properties": {
"wake_in_minutes": { "type": "integer" }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["wake_at"],
"properties": {
"wake_at": { "type": "string" }
},
"additionalProperties": false
}
],
"properties": {
"task": { "type": "string" },
"wake_in_minutes": { "type": "integer" },
"wake_at": { "type": "string" }
}
}),
}];
let api_tools = build_tools(&defs);
assert_eq!(api_tools[0]["strict"], serde_json::json!(true));
assert_eq!(
api_tools[0]["parameters"]["anyOf"][0]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
api_tools[0]["parameters"]["anyOf"][1]["additionalProperties"],
serde_json::json!(false)
);
}
#[test]
fn test_parse_text_wrapped_tool_call_prefers_trailing_json_object() {
let text = "Status update\nassistant to=functions.batch commentary {}json\n{\"tool_calls\":[{\"tool\":\"read\",\"file_path\":\"src/main.rs\"}]}";
let parsed = parse_text_wrapped_tool_call(text).expect("should parse wrapped tool call");
assert_eq!(parsed.1, "batch");
assert!(parsed.0.contains("Status update"));
let args: Value = serde_json::from_str(&parsed.2).expect("valid args json");
assert!(args.get("tool_calls").is_some());
}
#[test]
fn test_handle_openai_output_item_normalizes_null_arguments() {
let item = serde_json::json!({
"type": "function_call",
"call_id": "call_1",
"name": "bash",
"arguments": "null",
});
let mut saw_text_delta = false;
let mut pending = VecDeque::new();
let first = handle_openai_output_item(item, &mut saw_text_delta, &mut pending)
.expect("expected tool event");
match first {
StreamEvent::ToolUseStart { id, name } => {
assert_eq!(id, "call_1");
assert_eq!(name, "bash");
}
_ => panic!("expected ToolUseStart"),
}
match pending.pop_front() {
Some(StreamEvent::ToolInputDelta(delta)) => assert_eq!(delta, "{}"),
_ => panic!("expected ToolInputDelta"),
}
assert!(matches!(pending.pop_front(), Some(StreamEvent::ToolUseEnd)));
}
#[test]
fn test_handle_openai_output_item_recovers_bright_pearl_fixture() {
let item = serde_json::json!({
"type": "message",
"content": [{
"type": "output_text",
"text": BRIGHT_PEARL_WRAPPED_TOOL_CALL_FIXTURE,
}],
});
let mut saw_text_delta = false;
let mut pending = VecDeque::new();
let mut events = Vec::new();
if let Some(first) = handle_openai_output_item(item, &mut saw_text_delta, &mut pending) {
events.push(first);
}
while let Some(ev) = pending.pop_front() {
events.push(ev);
}
let mut saw_prefix = false;
let mut saw_tool = false;
let mut saw_input = false;
for event in events {
match event {
StreamEvent::TextDelta(text)
if text.contains("Status: I detected pre-existing local edits") =>
{
saw_prefix = true;
}
StreamEvent::ToolUseStart { name, .. } if name == "batch" => {
saw_tool = true;
}
StreamEvent::ToolInputDelta(delta) => {
let args: Value = serde_json::from_str(&delta).expect("valid tool args");
let calls = args
.get("tool_calls")
.and_then(|v| v.as_array())
.expect("tool_calls array");
assert_eq!(calls.len(), 3);
saw_input = true;
}
_ => {}
}
}
assert!(saw_prefix);
assert!(saw_tool);
assert!(saw_input);
}
#[test]
fn test_build_responses_input_rewrites_orphan_tool_output_as_user_message() {
let messages = vec![ChatMessage::tool_result(
"call_orphan",
"orphan result",
false,
)];
let items = build_responses_input(&messages);
let mut saw_rewritten_message = false;
for item in &items {
assert_ne!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call_output")
);
if item.get("type").and_then(|v| v.as_str()) == Some("message")
&& item.get("role").and_then(|v| v.as_str()) == Some("user")
&& let Some(content) = item.get("content").and_then(|v| v.as_array())
{
for part in content {
if part.get("type").and_then(|v| v.as_str()) == Some("input_text") {
let text = part.get("text").and_then(|v| v.as_str()).unwrap_or("");
if text.contains("[Recovered orphaned tool output: call_orphan]")
&& text.contains("orphan result")
{
saw_rewritten_message = true;
}
}
}
}
}
assert!(saw_rewritten_message);
}
@@ -0,0 +1,240 @@
#[test]
fn test_build_response_request_includes_stream_for_http() {
let request = OpenAIProvider::build_response_request(
"gpt-5.4",
"system".to_string(),
&[],
&[],
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert_eq!(request["stream"], serde_json::json!(true));
assert_eq!(request["store"], serde_json::json!(false));
}
#[test]
fn test_websocket_payload_strips_stream_and_background() {
let mut request = OpenAIProvider::build_response_request(
"gpt-5.4",
"system".to_string(),
&[serde_json::json!({"role": "user", "content": "hello"})],
&[],
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert_eq!(request["stream"], serde_json::json!(true));
request["background"] = serde_json::json!(true);
let obj = request.as_object_mut().expect("request is object");
obj.insert(
"type".to_string(),
serde_json::Value::String("response.create".to_string()),
);
obj.remove("stream");
obj.remove("background");
assert!(
request.get("stream").is_none(),
"stream must be stripped for WebSocket payloads"
);
assert!(
request.get("background").is_none(),
"background must be stripped for WebSocket payloads"
);
assert_eq!(request["type"], serde_json::json!("response.create"));
}
#[test]
fn test_websocket_payload_preserves_required_fields() {
let mut request = OpenAIProvider::build_response_request(
"gpt-5.4",
"system prompt".to_string(),
&[serde_json::json!({"role": "user", "content": "hello"})],
&[serde_json::json!({"type": "function", "name": "bash"})],
false,
Some(16384),
Some("high"),
None,
None,
None,
None,
);
let obj = request.as_object_mut().expect("request is object");
obj.insert(
"type".to_string(),
serde_json::Value::String("response.create".to_string()),
);
obj.remove("stream");
obj.remove("background");
assert_eq!(request["type"], "response.create");
assert_eq!(request["model"], "gpt-5.4");
assert_eq!(request["instructions"], "system prompt");
assert!(request["input"].is_array());
assert!(request["tools"].is_array());
assert_eq!(request["max_output_tokens"], serde_json::json!(16384));
assert_eq!(request["reasoning"], serde_json::json!({"effort": "high"}));
assert_eq!(request["tool_choice"], "auto");
}
#[test]
fn test_websocket_continuation_request_excludes_transport_fields() {
let base_request = OpenAIProvider::build_response_request(
"gpt-5.4",
"system".to_string(),
&[],
&[serde_json::json!({"type": "function", "name": "bash"})],
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
Some("flex"),
Some("jcode-test-cache"),
Some("24h"),
Some(160_000),
);
let mut continuation = serde_json::json!({
"type": "response.create",
"previous_response_id": "resp_abc123",
"input": [{"role": "user", "content": "follow up"}],
});
if let Some(model) = base_request.get("model") {
continuation["model"] = model.clone();
}
if let Some(tools) = base_request.get("tools") {
continuation["tools"] = tools.clone();
}
if let Some(instructions) = base_request.get("instructions") {
continuation["instructions"] = instructions.clone();
}
if let Some(context_management) = base_request.get("context_management") {
continuation["context_management"] = context_management.clone();
}
if let Some(service_tier) = base_request.get("service_tier") {
continuation["service_tier"] = service_tier.clone();
}
if let Some(prompt_cache_key) = base_request.get("prompt_cache_key") {
continuation["prompt_cache_key"] = prompt_cache_key.clone();
}
if let Some(prompt_cache_retention) = base_request.get("prompt_cache_retention") {
continuation["prompt_cache_retention"] = prompt_cache_retention.clone();
}
continuation["store"] = serde_json::json!(false);
continuation["parallel_tool_calls"] = serde_json::json!(false);
assert!(
continuation.get("stream").is_none(),
"continuation request must not include stream"
);
assert!(
continuation.get("background").is_none(),
"continuation request must not include background"
);
assert_eq!(continuation["type"], "response.create");
assert_eq!(continuation["previous_response_id"], "resp_abc123");
assert_eq!(continuation["model"], "gpt-5.4");
assert_eq!(continuation["service_tier"], "flex");
assert_eq!(continuation["prompt_cache_key"], "jcode-test-cache");
assert_eq!(continuation["prompt_cache_retention"], "24h");
assert_eq!(
continuation["context_management"],
serde_json::json!([
{
"type": "compaction",
"compact_threshold": 160_000,
}
])
);
}
#[test]
fn test_websocket_continuation_delta_skips_reasoning_items() {
let input = vec![
serde_json::json!({
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "first" }]
}),
serde_json::json!({
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "ok" }]
}),
serde_json::json!({
"type": "reasoning",
"id": "rs_duplicate_from_previous_response",
"summary": []
}),
serde_json::json!({
"type": "function_call_output",
"call_id": "call_1",
"output": "done"
}),
serde_json::json!({
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "continue" }]
}),
];
let (delta, skipped_reasoning) = persistent_ws_incremental_items(&input, 2);
assert_eq!(skipped_reasoning, 1);
assert_eq!(delta.len(), 2);
assert!(
delta
.iter()
.all(|item| item.get("type").and_then(|value| value.as_str()) != Some("reasoning")),
"previous_response_id deltas must not replay rs_* reasoning items"
);
assert_eq!(delta[0]["type"], "function_call_output");
assert_eq!(delta[1]["type"], "message");
}
#[test]
fn swarm_effort_maps_to_xhigh_for_api_and_is_accepted_by_normalize() {
// The swarm sentinel is a valid stored effort...
assert_eq!(
OpenAIProvider::normalize_reasoning_effort("swarm").as_deref(),
Some("swarm")
);
// ...but maps to the strongest real effort when building the request.
assert_eq!(
OpenAIProvider::api_reasoning_effort(Some("swarm")).as_deref(),
Some("xhigh")
);
assert_eq!(
OpenAIProvider::api_reasoning_effort(Some("high")).as_deref(),
Some("high")
);
assert_eq!(OpenAIProvider::api_reasoning_effort(None), None);
let request = OpenAIProvider::build_response_request(
"gpt-5.4",
"system".to_string(),
&[],
&[],
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
OpenAIProvider::api_reasoning_effort(Some("swarm")).as_deref(),
None,
None,
None,
None,
);
assert_eq!(request["reasoning"]["effort"], serde_json::json!("xhigh"));
}
@@ -0,0 +1,446 @@
fn assistant_tool_use(id: &str, name: &str, input: serde_json::Value) -> ChatMessage {
ChatMessage {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input, thought_signature: None, }],
timestamp: None,
tool_duration_ms: None,
}
}
fn user_text(text: &str) -> ChatMessage {
ChatMessage {
role: Role::User,
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
timestamp: None,
tool_duration_ms: None,
}
}
fn response_item_type(item: &serde_json::Value) -> Option<&str> {
item.get("type").and_then(|v| v.as_str())
}
fn response_item_call_id(item: &serde_json::Value) -> Option<&str> {
item.get("call_id").and_then(|v| v.as_str())
}
fn function_call_pos(items: &[serde_json::Value], call_id: &str) -> Option<usize> {
items.iter().position(|item| {
response_item_type(item) == Some("function_call")
&& response_item_call_id(item) == Some(call_id)
})
}
fn function_call_output_pos(items: &[serde_json::Value], call_id: &str) -> Option<usize> {
items.iter().position(|item| {
response_item_type(item) == Some("function_call_output")
&& response_item_call_id(item) == Some(call_id)
})
}
fn function_call_outputs(items: &[serde_json::Value], call_id: &str) -> Vec<String> {
items
.iter()
.filter(|item| {
response_item_type(item) == Some("function_call_output")
&& response_item_call_id(item) == Some(call_id)
})
.filter_map(|item| item.get("output").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
}
#[expect(
clippy::too_many_arguments,
reason = "test helper mirrors the request builder to keep call sites explicit"
)]
fn build_test_response_request(
model_id: &str,
is_chatgpt_mode: bool,
max_output_tokens: Option<u32>,
reasoning_effort: Option<&str>,
service_tier: Option<&str>,
prompt_cache_key: Option<&str>,
prompt_cache_retention: Option<&str>,
native_compaction_threshold: Option<usize>,
) -> serde_json::Value {
OpenAIProvider::build_response_request(
model_id,
"system".to_string(),
&[],
&[],
is_chatgpt_mode,
max_output_tokens,
reasoning_effort,
service_tier,
prompt_cache_key,
prompt_cache_retention,
native_compaction_threshold,
)
}
#[test]
fn test_build_responses_input_injects_missing_tool_output() {
let expected_missing = format!("[Error] {}", TOOL_OUTPUT_MISSING_TEXT);
let messages = vec![
user_text("hi"),
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
];
let items = build_responses_input(&messages);
assert!(function_call_pos(&items, "call_1").is_some());
assert_eq!(
function_call_outputs(&items, "call_1"),
vec![expected_missing]
);
}
#[test]
fn test_build_responses_input_preserves_tool_output() {
let messages = vec![
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
ChatMessage::tool_result("call_1", "ok", false),
];
let items = build_responses_input(&messages);
assert_eq!(function_call_outputs(&items, "call_1"), vec!["ok"]);
}
#[test]
fn test_build_responses_input_reorders_early_tool_output() {
let messages = vec![
ChatMessage::tool_result("call_1", "ok", false),
assistant_tool_use("call_1", "bash", serde_json::json!({"command": "ls"})),
];
let items = build_responses_input(&messages);
let call_pos = function_call_pos(&items, "call_1");
let output_pos = function_call_output_pos(&items, "call_1");
assert!(call_pos.is_some());
assert!(output_pos.is_some());
assert!(output_pos.unwrap() > call_pos.unwrap());
assert_eq!(function_call_outputs(&items, "call_1"), vec!["ok"]);
}
#[test]
fn test_build_responses_input_keeps_image_context_after_tool_output() {
let messages = vec![
assistant_tool_use(
"call_1",
"read",
serde_json::json!({"file_path": "screenshot.png"}),
),
ChatMessage {
role: Role::User,
content: vec![
ContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: "Image: screenshot.png\nImage sent to model for vision analysis."
.to_string(),
is_error: None,
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "ZmFrZQ==".to_string(),
},
ContentBlock::Text {
text:
"[Attached image associated with the preceding tool result: screenshot.png]"
.to_string(),
cache_control: None,
},
],
timestamp: None,
tool_duration_ms: None,
},
];
let items = build_responses_input(&messages);
let output_pos = function_call_output_pos(&items, "call_1");
let mut image_msg_pos = None;
for (idx, item) in items.iter().enumerate() {
match response_item_type(item) {
Some("message") if item.get("role").and_then(|v| v.as_str()) == Some("user") => {
let Some(content) = item.get("content").and_then(|v| v.as_array()) else {
continue;
};
let has_image = content
.iter()
.any(|part| part.get("type").and_then(|v| v.as_str()) == Some("input_image"));
let has_label = content.iter().any(|part| {
part.get("type").and_then(|v| v.as_str()) == Some("input_text")
&& part
.get("text")
.and_then(|v| v.as_str())
.map(|text| text.contains("screenshot.png"))
.unwrap_or(false)
});
if has_image && has_label {
image_msg_pos = Some(idx);
}
}
_ => {}
}
}
assert_eq!(
function_call_outputs(&items, "call_1"),
vec!["Image: screenshot.png\nImage sent to model for vision analysis."]
);
assert!(output_pos.is_some(), "expected function call output item");
assert!(
image_msg_pos.is_some(),
"expected follow-up user image message"
);
assert!(
image_msg_pos.unwrap() > output_pos.unwrap(),
"image context should stay after the tool output"
);
}
#[test]
fn test_build_responses_input_replaces_oversized_native_compaction_with_text() {
let oversized =
"x".repeat(jcode_base::provider::openai_request::OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS + 1);
let messages = vec![ChatMessage {
role: Role::User,
content: vec![ContentBlock::OpenAICompaction {
encrypted_content: oversized,
}],
timestamp: None,
tool_duration_ms: None,
}];
let items = build_responses_input(&messages);
assert!(
items
.iter()
.all(|item| response_item_type(item) != Some("compaction")),
"oversized native compaction must not be sent to OpenAI"
);
let fallback = items
.iter()
.find(|item| response_item_type(item) == Some("message"))
.expect("fallback text message should be present");
let text = fallback["content"][0]["text"]
.as_str()
.expect("fallback message should contain text");
assert!(text.contains("OpenAI native compaction state was discarded"));
assert!(text.contains("safe replay limit"));
}
#[test]
fn test_build_responses_input_injects_only_missing_outputs() {
let expected_missing = format!("[Error] {}", TOOL_OUTPUT_MISSING_TEXT);
let messages = vec![
assistant_tool_use("call_a", "bash", serde_json::json!({"command": "pwd"})),
assistant_tool_use("call_b", "bash", serde_json::json!({"command": "whoami"})),
ChatMessage::tool_result("call_b", "done", false),
];
let items = build_responses_input(&messages);
assert_eq!(
function_call_outputs(&items, "call_a"),
vec![expected_missing]
);
assert_eq!(function_call_outputs(&items, "call_b"), vec!["done"]);
}
#[test]
fn test_openai_retryable_error_patterns() {
assert!(is_retryable_error(
"stream disconnected before completion: transport error"
));
assert!(is_retryable_error(
"falling back from websockets to https transport. stream disconnected before completion"
));
assert!(is_retryable_error(
"OpenAI HTTPS stream ended before message completion marker"
));
// TLS transport errors must be retryable (previously omitted from the
// OpenAI-specific list, causing immediate user-facing failures).
assert!(is_retryable_error(
"stream error: io error: received fatal alert: badrecordmac"
));
assert!(is_retryable_error("io error: broken pipe (os error 32)"));
assert!(is_retryable_error("connection aborted"));
}
#[test]
fn test_parse_max_output_tokens_defaults_to_safe_value() {
assert_eq!(
OpenAIProvider::parse_max_output_tokens(None),
Some(DEFAULT_MAX_OUTPUT_TOKENS)
);
assert_eq!(
OpenAIProvider::parse_max_output_tokens(Some("")),
Some(DEFAULT_MAX_OUTPUT_TOKENS)
);
}
#[test]
fn test_parse_max_output_tokens_allows_disable_and_override() {
assert_eq!(OpenAIProvider::parse_max_output_tokens(Some("0")), None);
assert_eq!(
OpenAIProvider::parse_max_output_tokens(Some("32768")),
Some(32768)
);
assert_eq!(
OpenAIProvider::parse_max_output_tokens(Some("not-a-number")),
Some(DEFAULT_MAX_OUTPUT_TOKENS)
);
}
#[test]
fn test_build_response_request_for_gpt_5_4_1m_uses_base_model_without_extra_flags() {
let request = build_test_response_request(
"gpt-5.4",
true,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
Some("xhigh"),
Some("unused"),
Some("unused"),
None,
None,
);
assert_eq!(request["model"], serde_json::json!("gpt-5.4"));
assert!(request.get("model_context_window").is_none());
assert!(request.get("max_output_tokens").is_none());
assert!(request.get("prompt_cache_key").is_none());
assert!(request.get("prompt_cache_retention").is_none());
assert_eq!(
request["reasoning"],
serde_json::json!({ "effort": "xhigh" })
);
assert_eq!(request["service_tier"], serde_json::json!("unused"));
assert!(
request["tools"]
.as_array()
.expect("tools should be an array")
.contains(&serde_json::json!({ "type": "image_generation" }))
);
}
#[test]
fn test_build_response_request_omits_image_generation_for_codex_models() {
// Codex models reject the hosted image_generation tool, so it must not be
// attached even in ChatGPT mode (issue #369).
let request = build_test_response_request(
"gpt-5.3-codex",
true,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert!(
!request["tools"]
.as_array()
.expect("tools should be an array")
.contains(&serde_json::json!({ "type": "image_generation" })),
"codex models must not receive the image_generation tool"
);
}
#[test]
fn test_build_response_request_keeps_image_generation_for_non_codex_chatgpt_models() {
let request = build_test_response_request(
"gpt-5.5",
true,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert!(
request["tools"]
.as_array()
.expect("tools should be an array")
.contains(&serde_json::json!({ "type": "image_generation" })),
"non-codex ChatGPT models should still receive image_generation"
);
}
#[test]
fn test_build_response_request_omits_long_context_for_plain_gpt_5_4() {
let request = build_test_response_request(
"gpt-5.4",
true,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert!(request.get("model_context_window").is_none());
}
#[test]
fn test_build_response_request_defaults_extended_cache_retention_for_gpt_5_5() {
let request = build_test_response_request(
"gpt-5.5",
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
None,
None,
);
assert_eq!(request["prompt_cache_retention"], serde_json::json!("24h"));
assert_eq!(
request["max_output_tokens"],
serde_json::json!(DEFAULT_MAX_OUTPUT_TOKENS)
);
}
#[test]
fn test_build_response_request_respects_configured_cache_retention() {
let request = build_test_response_request(
"gpt-5.5",
false,
Some(DEFAULT_MAX_OUTPUT_TOKENS),
None,
None,
None,
Some("in_memory"),
None,
);
assert_eq!(
request["prompt_cache_retention"],
serde_json::json!("in_memory")
);
}
#[test]
fn test_openai_cache_ttl_is_model_aware() {
assert_eq!(
jcode_base::provider::cache_ttl_for_provider_model("openai", Some("gpt-5.5")),
Some(24 * 60 * 60)
);
assert_eq!(
jcode_base::provider::cache_ttl_for_provider_model("openai", Some("gpt-4o")),
Some(300)
);
}
@@ -0,0 +1,561 @@
#[tokio::test]
#[ignore = "requires real OpenAI OAuth credentials"]
async fn live_openai_catalog_lists_gpt_5_4_family() -> Result<()> {
let Some(catalog) = live_openai_catalog().await? else {
eprintln!("skipping live OpenAI catalog test: no real OAuth credentials");
return Ok(());
};
jcode_base::provider::populate_context_limits(catalog.context_limits.clone());
jcode_base::provider::populate_account_models(catalog.available_models.clone());
assert!(
catalog
.available_models
.iter()
.any(|model| model.starts_with("gpt-5.4")),
"expected GPT-5.4 family in live catalog, got {:?}",
catalog.available_models
);
assert!(
jcode_base::provider::known_openai_model_ids()
.iter()
.any(|model| model == "gpt-5.4"),
"expected GPT-5.4 in display model list"
);
let reports_long_context = catalog
.context_limits
.get("gpt-5.4")
.copied()
.unwrap_or_default()
>= 1_000_000;
assert_eq!(
jcode_base::provider::known_openai_model_ids()
.iter()
.any(|model| model == "gpt-5.4[1m]"),
reports_long_context,
"displayed 1m alias should follow the live catalog"
);
Ok(())
}
#[tokio::test]
#[ignore = "requires real OpenAI OAuth credentials"]
async fn live_openai_gpt_5_4_and_fast_requests_succeed() -> Result<()> {
let Some(catalog) = live_openai_catalog().await? else {
eprintln!("skipping live OpenAI response test: no real OAuth credentials");
return Ok(());
};
jcode_base::provider::populate_context_limits(catalog.context_limits.clone());
jcode_base::provider::populate_account_models(catalog.available_models.clone());
let Some(plain_response) = live_openai_smoke("gpt-5.4", "JCODE_GPT54_OK").await? else {
eprintln!("skipping live OpenAI response test: no real OAuth credentials");
return Ok(());
};
assert!(
plain_response.contains("JCODE_GPT54_OK"),
"unexpected GPT-5.4 response: {}",
plain_response
);
if catalog
.available_models
.iter()
.any(|model| model == "gpt-5.3-codex-spark")
{
let Some(fast_response) =
live_openai_smoke("gpt-5.3-codex-spark", "JCODE_GPT53_SPARK_OK").await?
else {
eprintln!("skipping live OpenAI fast-model test: no real OAuth credentials");
return Ok(());
};
assert!(
fast_response.contains("JCODE_GPT53_SPARK_OK"),
"unexpected gpt-5.3-codex-spark response: {}",
fast_response
);
}
if jcode_base::provider::known_openai_model_ids()
.iter()
.any(|model| model == "gpt-5.4[1m]")
{
let Some(long_context_response) =
live_openai_smoke("gpt-5.4[1m]", "JCODE_GPT54_1M_OK").await?
else {
eprintln!("skipping live OpenAI 1m test: no real OAuth credentials");
return Ok(());
};
assert!(
long_context_response.contains("JCODE_GPT54_1M_OK"),
"unexpected GPT-5.4[1m] response: {}",
long_context_response
);
}
Ok(())
}
#[test]
fn test_should_prefer_websocket_enabled_for_named_models() {
assert!(OpenAIProvider::should_prefer_websocket(
"gpt-5.3-codex-spark"
));
assert!(OpenAIProvider::should_prefer_websocket("gpt-5.3-codex"));
assert!(OpenAIProvider::should_prefer_websocket("gpt-5"));
assert!(OpenAIProvider::should_prefer_websocket("codex-mini"));
assert!(!OpenAIProvider::should_prefer_websocket(""));
}
#[test]
fn test_openai_transport_mode_defaults_to_auto() {
let mode = OpenAITransportMode::from_config(None);
assert_eq!(mode.as_str(), "auto");
}
#[test]
fn test_openai_transport_mode_auto_prefers_websocket_for_openai_models() {
let mode = OpenAITransportMode::from_config(Some("auto"));
assert_eq!(mode.as_str(), "auto");
assert!(OpenAIProvider::should_prefer_websocket("gpt-5.4"));
}
#[tokio::test]
async fn test_record_websocket_fallback_sets_cooldown_for_auto_default_models() {
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
let streaks = Arc::new(RwLock::new(HashMap::new()));
let model = "gpt-5.4";
let (streak, cooldown) = record_websocket_fallback(
&cooldowns,
&streaks,
model,
WebsocketFallbackReason::StreamTimeout,
)
.await;
assert_eq!(streak, 1);
assert_eq!(
cooldown,
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
);
assert!(
websocket_cooldown_remaining(&cooldowns, model)
.await
.is_some(),
"auto websocket default must still be guarded by cooldown after fallback"
);
}
#[tokio::test]
async fn test_websocket_cooldown_helpers_set_clear_and_expire() {
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
let model = "gpt-5.3-codex";
assert!(
websocket_cooldown_remaining(&cooldowns, model)
.await
.is_none()
);
set_websocket_cooldown(&cooldowns, model).await;
let remaining = websocket_cooldown_remaining(&cooldowns, model).await;
assert!(remaining.is_some());
clear_websocket_cooldown(&cooldowns, model).await;
assert!(
websocket_cooldown_remaining(&cooldowns, model)
.await
.is_none()
);
{
let mut guard = cooldowns.write().await;
guard.insert(model.to_string(), Instant::now() - Duration::from_secs(1));
}
assert!(
websocket_cooldown_remaining(&cooldowns, model)
.await
.is_none()
);
assert!(!cooldowns.read().await.contains_key(model));
}
#[test]
fn test_websocket_cooldown_for_streak_scales_and_caps() {
assert_eq!(
websocket_cooldown_for_streak(1, WebsocketFallbackReason::StreamTimeout),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
);
assert_eq!(
websocket_cooldown_for_streak(2, WebsocketFallbackReason::StreamTimeout),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 2)
);
assert_eq!(
websocket_cooldown_for_streak(3, WebsocketFallbackReason::StreamTimeout),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 4)
);
assert_eq!(
websocket_cooldown_for_streak(32, WebsocketFallbackReason::StreamTimeout),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_MAX_SECS)
);
}
#[test]
fn test_websocket_cooldown_for_reason_adjusts_by_failure_type() {
assert_eq!(
websocket_cooldown_for_streak(1, WebsocketFallbackReason::ConnectTimeout),
Duration::from_secs((WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1))
);
assert_eq!(
websocket_cooldown_for_streak(1, WebsocketFallbackReason::ServerRequestedHttps),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 5)
);
assert_eq!(
websocket_cooldown_for_streak(32, WebsocketFallbackReason::ServerRequestedHttps),
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_MAX_SECS * 3)
);
}
#[tokio::test]
async fn test_record_websocket_fallback_tracks_streak_and_cooldown() {
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
let streaks = Arc::new(RwLock::new(HashMap::new()));
let model = "gpt-5.3-codex-spark";
let (streak1, cooldown1) = record_websocket_fallback(
&cooldowns,
&streaks,
model,
WebsocketFallbackReason::StreamTimeout,
)
.await;
assert_eq!(streak1, 1);
assert_eq!(
cooldown1,
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS)
);
let remaining1 = websocket_cooldown_remaining(&cooldowns, model)
.await
.expect("cooldown should be set");
assert!(remaining1 <= cooldown1);
let (streak2, cooldown2) = record_websocket_fallback(
&cooldowns,
&streaks,
model,
WebsocketFallbackReason::StreamTimeout,
)
.await;
assert_eq!(streak2, 2);
assert_eq!(
cooldown2,
Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS * 2)
);
let remaining2 = websocket_cooldown_remaining(&cooldowns, model)
.await
.expect("cooldown should be set");
assert!(remaining2 <= cooldown2);
record_websocket_success(&cooldowns, &streaks, model).await;
assert!(
websocket_cooldown_remaining(&cooldowns, model)
.await
.is_none()
);
let normalized = normalize_transport_model(model).expect("normalized model");
assert!(!streaks.read().await.contains_key(&normalized));
}
#[test]
fn test_websocket_activity_payload_detection() {
assert!(is_websocket_activity_payload(
r#"{"type":"response.created","response":{"id":"resp_1"}}"#
));
assert!(is_websocket_activity_payload(
r#"{"type":"response.reasoning.delta","delta":"thinking"}"#
));
assert!(!is_websocket_activity_payload("not json"));
assert!(!is_websocket_activity_payload(r#"{"foo":"bar"}"#));
}
#[test]
fn test_websocket_first_activity_payload_counts_typed_control_events() {
assert!(is_websocket_first_activity_payload(
r#"{"type":"rate_limits.updated"}"#
));
assert!(is_websocket_first_activity_payload(
r#"{"type":"session.created","session":{}}"#
));
assert!(!is_websocket_first_activity_payload(r#"{"foo":"bar"}"#));
assert!(!is_websocket_first_activity_payload("not json"));
}
#[test]
fn test_websocket_completion_timeout_is_long_enough_for_reasoning() {
let timeout = std::hint::black_box(WEBSOCKET_COMPLETION_TIMEOUT_SECS);
assert!(
timeout >= 120,
"completion timeout regressed to {}s; reasoning models may need several minutes",
timeout
);
}
#[test]
fn test_stream_activity_event_treats_any_stream_event_as_activity() {
assert!(is_stream_activity_event(&StreamEvent::ThinkingStart));
assert!(is_stream_activity_event(&StreamEvent::ThinkingDelta(
"working".to_string()
)));
assert!(is_stream_activity_event(&StreamEvent::TextDelta(
"hello".to_string()
)));
assert!(is_stream_activity_event(&StreamEvent::MessageEnd {
stop_reason: None
}));
}
#[test]
fn test_websocket_activity_payload_counts_response_completed() {
assert!(is_websocket_activity_payload(
r#"{"type":"response.completed","response":{"status":"completed"}}"#
));
}
#[test]
fn test_websocket_activity_payload_counts_in_progress_events() {
assert!(is_websocket_activity_payload(
r#"{"type":"response.in_progress","response":{"status":"in_progress"}}"#
));
}
#[test]
fn test_websocket_activity_payload_ignores_non_response_events() {
assert!(!is_websocket_activity_payload(
r#"{"type":"session.created","session":{}}"#
));
assert!(!is_websocket_activity_payload(
r#"{"type":"rate_limits.updated"}"#
));
assert!(!is_websocket_activity_payload(r#"not json at all"#));
}
#[test]
fn test_websocket_remaining_timeout_secs_uses_idle_time_budget() {
let recent = Instant::now() - Duration::from_secs(2);
let remaining = websocket_remaining_timeout_secs(recent, 8).expect("still within budget");
assert!(
(6..=7).contains(&remaining),
"expected remaining idle budget near 6-7s, got {remaining}"
);
}
#[test]
fn test_websocket_remaining_timeout_secs_expires_after_budget() {
let expired = Instant::now() - Duration::from_secs(9);
assert!(websocket_remaining_timeout_secs(expired, 8).is_none());
}
#[test]
fn test_websocket_next_activity_timeout_uses_request_start_before_first_event() {
let ws_started_at = Instant::now() - Duration::from_secs(3);
let last_api_activity_at = Instant::now() - Duration::from_secs(1);
let remaining =
websocket_next_activity_timeout_secs(ws_started_at, last_api_activity_at, false)
.expect("first-event timeout should still be active");
assert!(
(5..=6).contains(&remaining),
"expected first-event timeout near 5-6s, got {remaining}"
);
}
#[test]
fn test_websocket_next_activity_timeout_resets_after_api_activity() {
let ws_started_at = Instant::now() - Duration::from_secs(299);
let last_api_activity_at = Instant::now() - Duration::from_secs(2);
let remaining = websocket_next_activity_timeout_secs(ws_started_at, last_api_activity_at, true)
.expect("idle timeout should use last activity, not total request age");
assert!(
remaining >= WEBSOCKET_COMPLETION_TIMEOUT_SECS.saturating_sub(3),
"expected full idle budget to reset after activity, got {remaining}"
);
}
#[test]
fn test_websocket_activity_timeout_kind_labels_first_and_next() {
assert_eq!(websocket_activity_timeout_kind(false), "first");
assert_eq!(websocket_activity_timeout_kind(true), "next");
}
#[test]
fn test_websocket_completion_timeout_extends_with_configured_idle_budget() {
use crate::websocket_health::websocket_next_activity_timeout_secs_with_completion;
// A custom completion budget larger than the default should be honored
// once API activity has been seen (issue #434).
let ws_started_at = Instant::now() - Duration::from_secs(400);
let last_api_activity_at = Instant::now() - Duration::from_secs(2);
let remaining = websocket_next_activity_timeout_secs_with_completion(
ws_started_at,
last_api_activity_at,
true,
600,
)
.expect("custom idle budget should still be active");
assert!(
remaining >= 595,
"expected near-full 600s idle budget, got {remaining}"
);
// And an exhausted custom budget still expires.
let stale_activity = Instant::now() - Duration::from_secs(601);
assert!(
websocket_next_activity_timeout_secs_with_completion(
ws_started_at,
stale_activity,
true,
600,
)
.is_none()
);
}
#[test]
fn test_format_status_duration_uses_compact_human_labels() {
assert_eq!(format_status_duration(Duration::from_secs(9)), "9s");
assert_eq!(format_status_duration(Duration::from_secs(125)), "2m 5s");
assert_eq!(format_status_duration(Duration::from_secs(7260)), "2h 1m");
}
#[test]
fn test_summarize_websocket_fallback_reason_classifies_common_failures() {
assert_eq!(
summarize_websocket_fallback_reason("WebSocket connect timed out after 8s"),
"connect timeout"
);
assert_eq!(
summarize_websocket_fallback_reason(
"WebSocket stream timed out waiting for first websocket activity (8s)"
),
"first response timeout"
);
assert_eq!(
summarize_websocket_fallback_reason(
"WebSocket stream timed out waiting for next websocket activity (300s)"
),
"stream timeout"
);
assert_eq!(
summarize_websocket_fallback_reason("server requested fallback"),
"server requested https"
);
assert_eq!(
summarize_websocket_fallback_reason("WebSocket stream closed before response.completed"),
"stream closed early"
);
}
#[test]
fn test_normalize_transport_model_trims_and_lowercases() {
assert_eq!(
normalize_transport_model(" GPT-5.4 "),
Some("gpt-5.4".to_string())
);
assert_eq!(normalize_transport_model(" \t\n "), None);
}
#[tokio::test]
async fn test_record_websocket_success_clears_normalized_keys() {
let cooldowns = Arc::new(RwLock::new(HashMap::new()));
let streaks = Arc::new(RwLock::new(HashMap::new()));
let canonical = "gpt-5.4";
record_websocket_fallback(
&cooldowns,
&streaks,
canonical,
WebsocketFallbackReason::StreamTimeout,
)
.await;
assert!(
websocket_cooldown_remaining(&cooldowns, canonical)
.await
.is_some()
);
record_websocket_success(&cooldowns, &streaks, " GPT-5.4 ").await;
assert!(
websocket_cooldown_remaining(&cooldowns, canonical)
.await
.is_none(),
"success should clear normalized cooldown entries"
);
assert!(
!streaks.read().await.contains_key(canonical),
"success should clear normalized failure streak entries"
);
}
#[tokio::test]
async fn persistent_ws_does_not_reuse_response_cancelled_before_completion() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test websocket listener");
let addr = listener.local_addr().expect("listener local addr");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept websocket client");
let mut ws = tokio_tungstenite::accept_async(stream)
.await
.expect("accept websocket handshake");
let request = ws
.next()
.await
.expect("receive continuation request")
.expect("valid continuation request");
assert!(matches!(request, WsMessage::Text(_)));
ws.send(WsMessage::Text(
r#"{"type":"response.created","response":{"id":"resp_cancelled"}}"#.into(),
))
.await
.expect("send response.created");
ws.send(WsMessage::Text(
r#"{"type":"response.output_text.delta","delta":"partial"}"#.into(),
))
.await
.expect("send partial response event");
});
let (client_ws, _) = connect_async(format!("ws://{}", addr))
.await
.expect("connect websocket client");
let persistent_ws = Arc::new(Mutex::new(Some(PersistentWsState {
ws_stream: client_ws,
last_response_id: "resp_previous".to_string(),
connected_at: Instant::now(),
last_activity_at: Instant::now(),
message_count: 1,
last_input_item_count: 1,
})));
let (tx, rx) = mpsc::channel(1);
drop(rx); // Mirrors a soft interrupt cancelling the active stream consumer.
let result = try_persistent_ws_continuation(
&persistent_ws,
&serde_json::json!({"model": "gpt-5.6-sol"}),
&[
serde_json::json!({"type": "message", "role": "user", "content": "first"}),
serde_json::json!({"type": "message", "role": "user", "content": "interrupt"}),
],
2,
&tx,
)
.await;
assert!(matches!(result, PersistentWsResult::Success));
assert!(
persistent_ws.lock().await.is_none(),
"an incomplete response may contain unseen tool calls and must not be reused"
);
server.await.expect("test websocket server");
}