diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index 3486499a33..486b3d4238 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -2032,6 +2032,9 @@ impl BridgeState { .then_some((id, path)) }) .collect(); + if candidates.is_empty() { + return Vec::new(); + } // `stat` is the dominant cost with 100k+ sessions. Match the TUI picker // by doing those independent filesystem calls concurrently rather than // serially blocking the API reply long enough for clients to time out. diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index 96e0792a74..9e8357b932 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -1530,6 +1530,36 @@ fn session_records_are_read_from_the_instance_home() { ); } +#[test] +fn unattached_list_sessions_handles_no_session_candidates() { + let home = ScopedJcodeHome::new("empty-session-discovery"); + let sessions_dir = home.path.join("sessions"); + let mut state = BridgeState::default(); + + // Missing directory, empty directory, and entries that are all filtered out. + for layout in 0..3 { + if layout == 1 { + std::fs::create_dir(&sessions_dir).unwrap(); + } else if layout == 2 { + std::fs::write(sessions_dir.join("not-a-session.txt"), "ignored").unwrap(); + std::fs::create_dir(sessions_dir.join("directory.json")).unwrap(); + } + for limit in [None, Some(0), Some(10)] { + let event = only_reply_event(state.api_request_to_legacy(&json!({ + "req": "list_sessions", "id": 1, "limit": limit, + }))); + assert_eq!(event, ApiEvent::Sessions { sessions: vec![] }); + assert_eq!( + only_reply_event(state.api_request_to_legacy(&json!({"req": "ping", "id": 2}))), + ApiEvent::Pong, + ); + } + } + + write_session_record(&home.path, "first_session", &home.path); + assert_eq!(BridgeState::stored_session_ids(None), ["first_session"]); +} + #[test] fn unattached_list_sessions_discovers_all_persisted_records() { let home = ScopedJcodeHome::new("persisted-discovery"); diff --git a/crates/jcode-provider-anthropic/src/lib.rs b/crates/jcode-provider-anthropic/src/lib.rs index 2d3eb4cfbb..a1008dc9d0 100644 --- a/crates/jcode-provider-anthropic/src/lib.rs +++ b/crates/jcode-provider-anthropic/src/lib.rs @@ -406,9 +406,10 @@ pub fn format_content_blocks(blocks: &[ContentBlock], is_oauth: bool) -> Vec ToolDefinition { +fn bash_registry_definition() -> ToolDefinition { ToolDefinition { - name: name.to_string(), - description: format!("{name} description"), - input_schema: json!({"type":"object","properties":{}}), + name: "bash".to_string(), + description: "Run a bash command with the registered execution options.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "command": {"type": "string"}, + "timeout": { + "type": "integer", + "description": "Timeout in MILLISECONDS (not seconds), e.g. 600000 = 10min; kills with exit 124. Omit for no timeout." + }, + "run_in_background": {"type": "boolean"}, + "intent": {"type": "string"}, + "notify": {"type": "boolean"}, + "wake": {"type": "boolean"}, + "stall_wake_seconds": {"type": "integer"}, + "justification": { + "type": "string", + "description": "Explain why the refused command serves the user request." + } + }, + "required": ["command"] + }), + } +} + +#[test] +fn oauth_bash_forwards_registered_schema_and_timeout_units() { + assert!(format_tools(&[], true, false).is_empty()); + let mut registry_bash = bash_registry_definition(); + // A new registry property must survive without another provider-side edit. + registry_bash.input_schema["properties"]["future_execution_option"] = + json!({"type": "boolean", "description": "A newly registered option."}); + + for is_oauth in [false, true] { + let formatted = format_tools(std::slice::from_ref(®istry_bash), is_oauth, false); + assert_eq!(formatted.len(), 1, "Bash must be advertised exactly once"); + let bash = &formatted[0]; + assert_eq!(bash.name, if is_oauth { "Bash" } else { "bash" }); + assert_eq!(bash.input_schema, registry_bash.input_schema); + assert_eq!(bash.description, registry_bash.description); + assert!( + bash.input_schema["properties"]["timeout"]["description"] + .as_str() + .unwrap() + .contains("MILLISECONDS (not seconds)") + ); + assert!(bash.cache_control.is_some()); } } @@ -66,7 +110,7 @@ fn oauth_schedule_wakeup_forwards_the_real_schedule_schema() { fn oauth_bash_schema_advertises_the_justification_escape_hatch() { // Regression for #722: the destructive gate consumes `justification`, // so it has to be discoverable in the advertised schema. - let formatted = format_tools(&[tool_def("bash")], true, false); + let formatted = format_tools(&[bash_registry_definition()], true, false); let bash = formatted .iter() .find(|t| t.name == "Bash") diff --git a/crates/jcode-provider-openrouter-runtime/src/openrouter_provider_impl.rs b/crates/jcode-provider-openrouter-runtime/src/openrouter_provider_impl.rs index 0a157c9a35..ab16e8f3d5 100644 --- a/crates/jcode-provider-openrouter-runtime/src/openrouter_provider_impl.rs +++ b/crates/jcode-provider-openrouter-runtime/src/openrouter_provider_impl.rs @@ -363,6 +363,19 @@ impl Provider for OpenRouterProvider { if let Some(supports_images) = self.static_image_input_support.get(&model_id) { return *supports_images; } + // The direct DeepSeek Flash aliases accept image_url parts (#1221). + // Keep Pro and unverified models text-only, and let explicit per-model + // input configuration above override this narrow built-in allowlist. + if self + .profile_id + .as_deref() + .is_some_and(|id| id.eq_ignore_ascii_case("deepseek")) + { + return matches!( + model_id.as_str(), + "deepseek-flash" | "deepseek-v4-flash" | "deepseek-v4-flash-vision-exp" + ); + } if Self::profile_rejects_image_input(self.profile_id.as_deref()) { return false; } diff --git a/crates/jcode-provider-openrouter-runtime/src/openrouter_tests.rs b/crates/jcode-provider-openrouter-runtime/src/openrouter_tests.rs index 4ec3c723b4..6b540bb140 100644 --- a/crates/jcode-provider-openrouter-runtime/src/openrouter_tests.rs +++ b/crates/jcode-provider-openrouter-runtime/src/openrouter_tests.rs @@ -404,7 +404,7 @@ fn named_openai_compatible_model_with_empty_input_preserves_image_support() { } #[test] -fn direct_deepseek_profile_does_not_advertise_image_input_support() { +fn direct_deepseek_profile_unknown_model_does_not_advertise_image_input_support() { let provider = OpenRouterProvider { profile_id: Some("deepseek".to_string()), supports_provider_features: false, @@ -414,6 +414,159 @@ fn direct_deepseek_profile_does_not_advertise_image_input_support() { assert!(!provider.supports_image_input()); } +#[test] +fn deepseek_image_input_capability_matrix() { + for (profile, model, provider_features, expected) in [ + ("deepseek", "deepseek-flash", false, true), + ("deepseek", "deepseek-v4-flash", false, true), + ("deepseek", "deepseek-v4-flash-vision-exp", false, true), + ("DeepSeek", "DEEPSEEK-FLASH", false, true), + ("deepseek", "deepseek:deepseek-flash", false, true), + ("deepseek", "deepseek-v4-pro", false, false), + ("deepseek", "deepseek-pro", false, false), + ("deepseek", "deepseek-chat", false, false), + ("deepseek", "deepseek-reasoner", false, false), + ("deepseek", "unknown", false, false), + ("deepseek", "deepseek-v4-flash-free", false, false), + ("deepseek", "deepseek-flash-future", false, false), + ("deepseek", "deepseek/deepseek-flash", false, false), + ("zai", "deepseek-flash", false, false), + ("zai", "glm-5", false, false), + ("openrouter", "deepseek-flash", true, false), + ("custom", "unknown", false, true), + ] { + let provider = OpenRouterProvider { + profile_id: Some(profile.to_string()), + model: Arc::new(RwLock::new(model.to_string())), + supports_provider_features: provider_features, + ..make_custom_compatible_provider() + }; + assert_eq!( + provider.supports_image_input(), + expected, + "profile={profile}, model={model}" + ); + } +} + +#[test] +fn deepseek_image_input_explicit_model_inputs_take_precedence() { + let _lock = ENV_LOCK.lock(); + let _namespace = EnvVarGuard::remove("JCODE_OPENROUTER_CACHE_NAMESPACE"); + for profile_id in ["deepseek", "zai"] { + let profile = jcode_base::config::NamedProviderConfig { + base_url: "http://localhost:1234/v1".to_string(), + auth: jcode_base::config::NamedProviderAuth::None, + default_model: Some("deepseek-flash".to_string()), + models: vec![ + jcode_base::config::NamedProviderModelConfig { + id: "deepseek-flash".to_string(), + input: vec!["text".to_string()], + ..Default::default() + }, + jcode_base::config::NamedProviderModelConfig { + id: "deepseek-v4-pro".to_string(), + input: vec!["text".to_string(), "image".to_string()], + ..Default::default() + }, + ], + ..Default::default() + }; + let provider = OpenRouterProvider::new_named_openai_compatible(profile_id, &profile) + .expect("named profile should initialize without auth"); + assert!( + !provider.supports_image_input(), + "{profile_id}: explicit text-only Flash" + ); + provider.set_model("deepseek-v4-pro").unwrap(); + assert!( + provider.supports_image_input(), + "{profile_id}: explicit image-capable Pro" + ); + } +} + +#[test] +fn deepseek_image_input_captured_requests_preserve_only_allowed_pixels() { + let _lock = ENV_LOCK.lock(); + // A real 1x1 PNG, retained byte-for-byte in the outbound data URL. + let pixels = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aK1cAAAAASUVORK5CYII="; + let messages = vec![Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: "describe this".to_string(), + cache_control: None, + }, + ContentBlock::Image { + media_type: "image/png".to_string(), + data: pixels.to_string(), + }, + ], + timestamp: None, + tool_duration_ms: None, + }]; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + for (profile, model, override_support, expected) in [ + ("deepseek", "deepseek-flash", None, true), + ("deepseek", "deepseek-v4-flash", None, true), + ("deepseek", "deepseek-v4-flash-vision-exp", None, true), + ("deepseek", "deepseek-v4-pro", None, false), + ("deepseek", "unknown", None, false), + ("zai", "deepseek-flash", None, false), + ("deepseek", "deepseek-flash", Some(false), false), + ("deepseek", "deepseek-v4-pro", Some(true), true), + ] { + let (api_base, request_rx) = spawn_single_response_chat_server(); + let provider = OpenRouterProvider { + api_base, + model: Arc::new(RwLock::new(model.to_string())), + profile_id: Some(profile.to_string()), + supports_provider_features: false, + supports_model_catalog: false, + static_image_input_support: override_support + .map(|value| HashMap::from([(model.to_string(), value)])) + .unwrap_or_default(), + ..make_custom_compatible_provider() + }; + rt.block_on(async { + let mut stream = provider.complete(&messages, &[], "", None).await.unwrap(); + while let Some(event) = stream.next().await { + event.expect("local fixture stream should succeed"); + } + }); + let request = request_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let body = parse_captured_request_body(&request); + assert_eq!(body["model"], model); + let parts = body["messages"] + .as_array() + .unwrap() + .iter() + .filter_map(|message| message["content"].as_array()) + .flatten() + .filter(|part| part["type"] == "image_url") + .collect::>(); + assert_eq!( + parts.len(), + usize::from(expected), + "{profile}/{model}: {body}" + ); + if expected { + assert_eq!( + parts[0]["image_url"]["url"], + format!("data:image/png;base64,{pixels}") + ); + assert!(!request.contains("Image omitted"), "{body}"); + } else { + assert!(request.contains("Image omitted"), "{body}"); + assert!(!request.contains(pixels), "{body}"); + } + } +} + #[test] fn direct_zai_profile_does_not_advertise_image_input_support() { let provider = OpenRouterProvider {