Files
wehub-resource-sync a789495a98
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

616 lines
20 KiB
Rust

use super::*;
use chrono::Utc;
use jcode_provider_antigravity::{
FetchAvailableModelsResponse, parse_fetch_available_models_response,
};
use jcode_provider_core::Provider;
use tokio_stream::StreamExt;
#[test]
fn parse_fetch_available_models_response_discovers_metadata_and_priority_order() {
let response: FetchAvailableModelsResponse = serde_json::from_value(serde_json::json!({
"defaultAgentModelId": "gemini-3.1-pro-high",
"commandModelIds": ["gemini-3-flash"],
"models": {
"claude-opus-4-6-thinking": {
"displayName": "Claude Opus 4.6 (Thinking)",
"quotaInfo": { "remainingFraction": 1, "resetTime": "2026-04-24T20:53:26Z" },
"recommended": true,
"modelProvider": "MODEL_PROVIDER_ANTHROPIC"
},
"gemini-3.1-pro-high": {
"displayName": "Gemini 3.1 Pro (High)",
"quotaInfo": { "remainingFraction": 0.25 }
},
"gemini-3-flash": {
"displayName": "Gemini 3 Flash",
"quotaInfo": { "remainingFraction": 0, "resetTime": "2026-04-24T21:53:26Z" }
},
"gpt-oss-120b-medium": {}
}
}))
.expect("parse response");
let parsed = parse_fetch_available_models_response(&response);
assert_eq!(
parsed.default_model_id.as_deref(),
Some("gemini-3.1-pro-high")
);
let parsed = parsed.models;
assert_eq!(parsed[0].id, "claude-opus-4-6-thinking");
assert_eq!(parsed[1].id, "gemini-3.1-pro-high");
assert_eq!(parsed[2].id, "gpt-oss-120b-medium");
assert_eq!(
parsed[0].display_name.as_deref(),
Some("Claude Opus 4.6 (Thinking)")
);
assert_eq!(parsed[1].remaining_fraction_milli, Some(250));
let flash = parsed
.iter()
.find(|model| model.id == "gemini-3-flash")
.expect("gemini flash model");
assert!(!flash.available);
assert_eq!(flash.remaining_fraction_milli, Some(0));
}
#[test]
fn client_metadata_uses_backend_accepted_platform() {
assert_eq!(metadata_platform(), "PLATFORM_UNSPECIFIED");
assert!(client_metadata_header().contains("\"platform\":\"PLATFORM_UNSPECIFIED\""));
}
#[test]
fn available_models_display_includes_dynamic_cache_and_current_override() {
let provider = AntigravityProvider::new();
*provider.fetched_catalog.write().expect("catalog lock") = vec![
CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
},
CatalogModel {
id: "gemini-3-pro-high".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: false,
available: true,
remaining_fraction_milli: Some(1000),
},
];
provider
.set_model("custom-antigravity-model")
.expect("set custom model");
let models = provider.available_models_display();
assert!(models.contains(&"claude-opus-4-6-thinking".to_string()));
assert!(models.contains(&"gemini-3-pro-high".to_string()));
assert!(models.contains(&"custom-antigravity-model".to_string()));
}
#[test]
fn available_models_display_seeds_from_persisted_catalog() {
let _guard = jcode_base::storage::lock_test_env();
let temp = tempfile::TempDir::new().expect("temp dir");
let previous = std::env::var_os("JCODE_HOME");
jcode_base::env::set_var("JCODE_HOME", temp.path());
let path = jcode_base::provider::antigravity::persisted_catalog_path().expect("catalog path");
jcode_base::storage::write_json(
&path,
&PersistedCatalog {
models: vec![CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: Some("Claude Opus 4.6 (Thinking)".to_string()),
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
}],
fetched_at_rfc3339: Utc::now().to_rfc3339(),
default_model_id: Some("gemini-3-flash".to_string()),
},
)
.expect("write persisted catalog");
let provider = AntigravityProvider::new();
assert!(
provider
.available_models_display()
.contains(&"claude-opus-4-6-thinking".to_string())
);
assert_eq!(
provider.backend_default_model().as_deref(),
Some("gemini-3-flash")
);
if let Some(previous) = previous {
jcode_base::env::set_var("JCODE_HOME", previous);
} else {
jcode_base::env::remove_var("JCODE_HOME");
}
}
#[test]
fn catalog_detail_mentions_quota_and_reset() {
let detail = catalog_model_detail(&CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: Some("Claude Opus 4.6 (Thinking)".to_string()),
reset_time: Some("2026-04-24T20:53:26Z".to_string()),
tag_title: Some("New".to_string()),
model_provider: Some("MODEL_PROVIDER_ANTHROPIC".to_string()),
max_tokens: Some(250_000),
max_output_tokens: Some(64_000),
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
});
assert!(detail.contains("recommended"));
assert!(detail.contains("quota 100.0%"));
assert!(detail.contains("resets 2026-04-24T20:53:26Z"));
}
#[test]
fn catalog_stale_handles_invalid_timestamp() {
assert!(catalog_is_stale("not-a-time"));
}
#[test]
fn resolve_model_for_request_maps_default_alias_to_real_model() {
let provider = AntigravityProvider::new();
// With no backend default and no catalog, the alias resolves to the
// hardcoded fallback rather than the literal "default" (which 404s).
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = Vec::new();
assert_eq!(
provider.resolve_model_for_request("default"),
DEFAULT_FALLBACK_MODEL
);
assert_eq!(
provider.resolve_model_for_request(" "),
DEFAULT_FALLBACK_MODEL
);
// A backend-advertised default takes precedence over the fallback.
*provider
.backend_default_model
.write()
.expect("default lock") = Some("gemini-3.5-flash-low".to_string());
assert_eq!(
provider.resolve_model_for_request("default"),
"gemini-3.5-flash-low"
);
// Explicit model ids are always passed through untouched.
assert_eq!(
provider.resolve_model_for_request("claude-sonnet-4-6"),
"claude-sonnet-4-6"
);
// ...except the catalog-advertised-but-unserviceable `gemini-3.1-pro-high`,
// which is remapped to the equivalent working "Gemini 3.1 Pro (High)" id.
assert_eq!(
provider.resolve_model_for_request("gemini-3.1-pro-high"),
"gemini-pro-agent"
);
// Its sibling `-low` works as-is and must not be remapped.
assert_eq!(
provider.resolve_model_for_request("gemini-3.1-pro-low"),
"gemini-3.1-pro-low"
);
}
#[test]
fn remap_unsupported_model_only_touches_broken_pro_high() {
assert_eq!(
remap_unsupported_model("gemini-3.1-pro-high"),
"gemini-pro-agent"
);
for model in [
"gemini-3.1-pro-low",
"gemini-pro-agent",
"gemini-3-flash",
"claude-sonnet-4-6",
"gpt-oss-120b-medium",
"default",
] {
assert_eq!(remap_unsupported_model(model), model);
}
}
#[test]
fn resolve_model_for_request_default_prefers_gemini_catalog_model() {
let provider = AntigravityProvider::new();
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = vec![
CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
},
CatalogModel {
id: "gemini-3-flash".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: false,
available: true,
remaining_fraction_milli: Some(1000),
},
];
// Even though Claude is listed first (and recommended), the default alias
// resolves to the Gemini model, which works reliably with tool use on the
// Cloud Code backend. Claude on this backend rejects jcode's tool schemas.
assert_eq!(
provider.resolve_model_for_request("default"),
"gemini-3-flash"
);
}
#[test]
fn resolve_model_for_request_default_falls_back_to_any_catalog_model_without_gemini() {
let provider = AntigravityProvider::new();
*provider
.backend_default_model
.write()
.expect("default lock") = None;
*provider.fetched_catalog.write().expect("catalog lock") = vec![CatalogModel {
id: "claude-opus-4-6-thinking".to_string(),
display_name: None,
reset_time: None,
tag_title: None,
model_provider: None,
max_tokens: None,
max_output_tokens: None,
recommended: true,
available: true,
remaining_fraction_milli: Some(1000),
}];
// With no Gemini model available, fall back to the first available catalog
// model rather than the hardcoded default.
assert_eq!(
provider.resolve_model_for_request("default"),
"claude-opus-4-6-thinking"
);
}
#[tokio::test]
async fn complete_uses_native_https_transport_not_cli_subprocess() {
let provider = AntigravityProvider::new();
let mut stream = provider
.complete(&[], &[], "say hello", None)
.await
.expect("create stream");
let first_event = stream
.next()
.await
.expect("first event")
.expect("connection event");
match first_event {
StreamEvent::ConnectionType { connection } => {
assert_eq!(connection, "https");
assert_ne!(connection, "cli subprocess");
}
other => panic!("expected connection type, got {other:?}"),
}
}
#[test]
fn model_is_claude_detects_anthropic_models_only() {
assert!(model_is_claude("claude-sonnet-4-6"));
assert!(model_is_claude("claude-opus-4-6-thinking"));
assert!(model_is_claude("CLAUDE-SONNET"));
assert!(!model_is_claude("gemini-3-flash"));
assert!(!model_is_claude("gpt-oss-120b-medium"));
assert!(!model_is_claude("default"));
}
#[test]
fn flatten_schema_combiners_collapses_anyof_to_first_branch() {
// Mirrors the real `bg` tool's `status_filter` schema that the Antigravity
// Claude backend rejects.
let schema = serde_json::json!({
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
],
"description": "Status filter string or array."
});
let flattened = flatten_schema_combiners(&schema);
assert!(flattened.get("anyOf").is_none(), "anyOf must be removed");
assert_eq!(flattened["type"], serde_json::json!("string"));
// Sibling metadata is preserved onto the chosen branch.
assert_eq!(
flattened["description"],
serde_json::json!("Status filter string or array.")
);
}
#[test]
fn flatten_schema_combiners_recurses_into_nested_properties() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"oneOf": [
{ "type": "string" },
{ "type": "array" }
]
},
"name": { "type": "string" }
}
});
let flattened = flatten_schema_combiners(&schema);
assert_eq!(
flattened["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
assert!(
flattened["properties"]["status_filter"]
.get("oneOf")
.is_none()
);
// Untouched branches are preserved verbatim.
assert_eq!(
flattened["properties"]["name"]["type"],
serde_json::json!("string")
);
}
#[test]
fn flatten_schema_combiners_collapses_allof_inside_array_items() {
let schema = serde_json::json!({
"type": "array",
"items": {
"allOf": [
{ "type": "object", "properties": { "tool": { "type": "string" } } }
]
}
});
let flattened = flatten_schema_combiners(&schema);
assert!(flattened["items"].get("allOf").is_none());
assert_eq!(flattened["items"]["type"], serde_json::json!("object"));
}
#[test]
fn flatten_schema_combiners_leaves_combiner_free_schema_unchanged() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"task_ids": { "type": "array", "items": { "type": "string" } },
"intent": { "type": "string" }
},
"required": ["intent"]
});
assert_eq!(flatten_schema_combiners(&schema), schema);
}
#[test]
fn model_is_gemini_detects_gemini_models_only() {
assert!(model_is_gemini("gemini-3-flash"));
assert!(model_is_gemini("gemini-2.5-pro"));
assert!(model_is_gemini("GEMINI-3-FLASH"));
assert!(!model_is_gemini("claude-sonnet-4-6"));
assert!(!model_is_gemini("gpt-oss-120b-medium"));
assert!(!model_is_gemini("default"));
}
#[test]
fn strip_numeric_schema_bounds_drops_array_and_string_and_object_bounds() {
// Mirrors the real `batch` tool schema that the gpt-oss backend rejects
// because it re-encodes the integer bound as the string "10".
let schema = serde_json::json!({
"type": "object",
"properties": {
"tool_calls": {
"type": "array",
"items": { "type": "object" },
"minItems": 1,
"maxItems": 10
},
"name": { "type": "string", "minLength": 1, "maxLength": 64 }
},
"minProperties": 1,
"maxProperties": 5
});
let stripped = strip_numeric_schema_bounds(&schema);
let tool_calls = &stripped["properties"]["tool_calls"];
assert!(tool_calls.get("minItems").is_none());
assert!(tool_calls.get("maxItems").is_none());
// Structural keys are preserved.
assert_eq!(tool_calls["type"], serde_json::json!("array"));
assert_eq!(tool_calls["items"]["type"], serde_json::json!("object"));
let name = &stripped["properties"]["name"];
assert!(name.get("minLength").is_none());
assert!(name.get("maxLength").is_none());
assert_eq!(name["type"], serde_json::json!("string"));
assert!(stripped.get("minProperties").is_none());
assert!(stripped.get("maxProperties").is_none());
}
#[test]
fn antigravity_compatible_schema_passes_gemini_through_unchanged() {
// Gemini is the native backend path; it accepts everything jcode emits, so
// the schema must be byte-identical (combiners and numeric bounds intact).
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
assert_eq!(
antigravity_compatible_schema(&schema, "gemini-3-flash"),
schema,
"Gemini path must not rewrite the schema"
);
}
#[test]
fn antigravity_compatible_schema_flattens_combiners_for_claude_but_keeps_bounds() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
let out = antigravity_compatible_schema(&schema, "claude-sonnet-4-6");
// Combiner collapsed (Anthropic strictness)...
assert!(out["properties"]["status_filter"].get("anyOf").is_none());
assert_eq!(
out["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
// ...but numeric bounds are retained for Claude (it accepts them).
assert_eq!(
out["properties"]["tool_calls"]["maxItems"],
serde_json::json!(10)
);
}
#[test]
fn antigravity_compatible_schema_strips_bounds_and_combiners_for_gpt_oss() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"status_filter": {
"anyOf": [
{ "type": "string" },
{ "items": { "type": "string" }, "type": "array" }
]
},
"tool_calls": { "type": "array", "minItems": 1, "maxItems": 10 }
}
});
let out = antigravity_compatible_schema(&schema, "gpt-oss-120b-medium");
// Combiner collapsed AND numeric bounds dropped (OpenAI-compatible bridge).
assert!(out["properties"]["status_filter"].get("anyOf").is_none());
assert_eq!(
out["properties"]["status_filter"]["type"],
serde_json::json!("string")
);
assert!(out["properties"]["tool_calls"].get("minItems").is_none());
assert!(out["properties"]["tool_calls"].get("maxItems").is_none());
assert_eq!(
out["properties"]["tool_calls"]["type"],
serde_json::json!("array")
);
}
#[test]
fn is_retryable_empty_turn_detects_malformed_function_call() {
// Empty content + MALFORMED_FUNCTION_CALL is the transient Gemini-3 failure we
// retry transparently.
let response: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {},
"finishReason": "MALFORMED_FUNCTION_CALL",
"finishMessage": "Malformed function call: print(default_api.read(...))"
}]
}
}))
.expect("decode malformed response");
assert!(is_retryable_empty_turn(&response));
}
#[test]
fn is_retryable_empty_turn_ignores_normal_and_productive_turns() {
// A normal STOP turn with text is never retried.
let with_text: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {"parts": [{"text": "hello"}]},
"finishReason": "STOP"
}]
}
}))
.expect("decode text response");
assert!(!is_retryable_empty_turn(&with_text));
// A turn with a function call is productive even with no text.
let with_call: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{
"content": {"parts": [{"functionCall": {"name": "read", "args": {}}}]},
"finishReason": "STOP"
}]
}
}))
.expect("decode function call response");
assert!(!is_retryable_empty_turn(&with_call));
// An empty STOP turn (legitimately empty answer) is not retried in a loop.
let empty_stop: CodeAssistGenerateResponse = serde_json::from_value(serde_json::json!({
"response": {
"candidates": [{ "content": {}, "finishReason": "STOP" }]
}
}))
.expect("decode empty stop response");
assert!(!is_retryable_empty_turn(&empty_stop));
}