Skip to content
Closed
2 changes: 1 addition & 1 deletion crates/jcode-app-core/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ pub fn send_macos_turn_notification(
#[cfg(not(target_os = "macos"))]
{
let _ = (title, subtitle, body, sound);
return false;
false
}

#[cfg(target_os = "macos")]
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-app-core/src/server/client_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2834,6 +2834,7 @@ async fn append_context_message(
let _ = client_event_tx.send(event);
}

#[allow(clippy::too_many_arguments)]
async fn start_processing_message(
message: ProcessingMessage,
client_session_id: &str,
Expand Down
13 changes: 0 additions & 13 deletions crates/jcode-base/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,19 +986,6 @@ fn record_auth_probe_step(
timings.push((name, step_start.elapsed().as_millis()));
}

fn token_state(result: anyhow::Result<bool>) -> AuthState {
match result {
Ok(is_expired) => {
if is_expired {
AuthState::Expired
} else {
AuthState::Available
}
}
Err(_) => AuthState::NotConfigured,
}
}

/// Auth state for an OAuth credential that refreshes automatically.
///
/// A short-lived access token is *not* a broken login. Antigravity/Gemini
Expand Down
11 changes: 7 additions & 4 deletions crates/jcode-base/src/memory_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ fn format_content_block_for_relevance(block: &crate::message::ContentBlock) -> O
content, is_error, ..
} => {
if is_error.unwrap_or(false) {
Some(format!(
"[Tool error: {}]",
truncate_chars(content.trim(), MEMORY_CONTEXT_MAX_BLOCK_CHARS / 4)
))
// Keep the serialized block on one physical line. The focused-query
// filter drops tool blocks by their leading marker, so preserving
// payload newlines would let every line after the first escape as
// apparent conversation prose.
let content = truncate_chars(content.trim(), MEMORY_CONTEXT_MAX_BLOCK_CHARS / 4);
let content = content.split_whitespace().collect::<Vec<_>>().join(" ");
Some(format!("[Tool error: {}]", content))
} else {
None
}
Expand Down
35 changes: 35 additions & 0 deletions crates/jcode-base/src/memory_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,41 @@ The bug is in the mouse delta calc.";
);
}

#[test]
fn focused_query_excludes_multiline_tool_errors_but_keeps_later_user_prose() {
let messages = vec![Message {
role: Role::User,
content: vec![
ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "This command was not run.\nUNIQUE_MULTILINE_ERROR_PAYLOAD\nThe target cannot be confirmed.\nThe operation is irreversible."
.to_string(),
is_error: Some(true),
},
ContentBlock::Text {
text: "Keep the token rotation behavior unchanged.".to_string(),
cache_control: None,
},
],
timestamp: None,
tool_duration_ms: None,
}];

let focused = format_focused_query_for_relevance(&messages);

assert!(!focused.contains("This command was not run"), "{focused}");
assert!(
!focused.contains("UNIQUE_MULTILINE_ERROR_PAYLOAD"),
"arbitrary error payload leaked: {focused}"
);
assert!(!focused.contains("cannot be confirmed"), "{focused}");
assert!(!focused.contains("irreversible"), "{focused}");
assert!(
focused.contains("Keep the token rotation behavior unchanged."),
"subsequent user prose was lost: {focused}"
);
}

#[test]
fn focus_query_text_falls_back_when_all_stripped() {
let raw = "<system-reminder>\nonly boilerplate\n</system-reminder>\n[Tool: read]";
Expand Down
45 changes: 44 additions & 1 deletion crates/jcode-base/src/provider/catalog_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,13 @@ pub fn remote_model_routes_fallback(
continue;
}

if model.contains('/')
&& let Some(route) = remote_openai_compatible_route_for_model(model)
{
routes.push(route);
continue;
Comment on lines +892 to +896

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Profile order overrides selection

For slash-prefixed models, this branch performs the global compatible-profile lookup before checking the provider selected for the current remote session. When Baseten and Hugging Face both advertise zai-org/GLM-4.7, with Hugging Face selected, the lookup returns Baseten because it appears first in the catalog. Check remote_current_openai_compatible_route_for_model(remote_provider_name, model) first, and use the global lookup only when the selected provider cannot validate the model.

Artifacts

Focused duplicate-profile routing test source

  • This preserved authored test configures Hugging Face and Baseten for the same slash model while selecting Hugging Face, with the takeaway that the expected selected-provider route is explicitly asserted.

Focused duplicate-profile routing test output

  • This captured cargo test output shows the selected Hugging Face route resolving to Baseten and the assertion failing, with the takeaway that the provider override remains reproducible.

Working tree restoration check

  • This captured git diff and status check confirms no tracked source changes remain after removing the focused test-only modification, with the takeaway that the working tree source is restored.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-base/src/provider/catalog_routes.rs
Line: 892-896

Comment:
**Profile order overrides selection**

For slash-prefixed models, this branch performs the global compatible-profile lookup before checking the provider selected for the current remote session. When Baseten and Hugging Face both advertise `zai-org/GLM-4.7`, with Hugging Face selected, the lookup returns Baseten because it appears first in the catalog. Check `remote_current_openai_compatible_route_for_model(remote_provider_name, model)` first, and use the global lookup only when the selected provider cannot validate the model.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
Comment on lines +892 to +897

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Profile order overrides selection

When two configured OpenAI-compatible profiles advertise the same slash-prefixed model, this fallback performs the global compatible-profile lookup before checking the selected provider. With Baseten listed before the selected Hugging Face profile, a request for zai-org/GLM-4.7 routes to Baseten instead of Hugging Face. Prefer remote_current_openai_compatible_route_for_model(remote_provider_name, model) before the global lookup, retaining the global lookup only when the current provider cannot validate the model.

Artifacts

Failure-path reproducer with Baseten first and Hugging Face selected second

  • This authored Rust source configures both compatible profiles and invokes the exact fallback path, establishing the ordered-profile scenario.

Fallback output routes selected Hugging Face model to Baseten

  • The executed `cargo run` capture exits 0 and shows the fallback chose Baseten, proving the incorrect endpoint selection.

Selected-provider comparison reproducer for Hugging Face

  • This authored Rust source uses the same profiles and model but invokes the selected-profile resolver that the fallback should prefer.

Selected-provider resolver output routes model to Hugging Face

  • The executed `cargo run` capture exits 0 and shows Hugging Face, establishing the expected selected-provider route.

Existing focused Rust test output

  • The focused existing test command exits 0, but reports zero matching tests due to its non-module-qualified filter and therefore does not prove the required two-profile order scenario.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-base/src/provider/catalog_routes.rs
Line: 892-897

Comment:
**Profile order overrides selection**

When two configured OpenAI-compatible profiles advertise the same slash-prefixed model, this fallback performs the global compatible-profile lookup before checking the selected provider. With Baseten listed before the selected Hugging Face profile, a request for `zai-org/GLM-4.7` routes to Baseten instead of Hugging Face. Prefer `remote_current_openai_compatible_route_for_model(remote_provider_name, model)` before the global lookup, retaining the global lookup only when the current provider cannot validate the model.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +892 to +897

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Selected compatible provider is ignored for overlapping slash models

When two configured OpenAI-compatible providers advertise the same slash-prefixed model, this global lookup returns the first catalog match before the selected remote provider is considered. For example, selecting OpenCode Go while both OpenCode Go and OpenCode Zen advertise shared/vendor-model routes the request to OpenCode Zen. This can send prompts to a provider the user did not select. Resolve remote_current_openai_compatible_route_for_model(remote_provider_name, model) first for slash models, then use the global compatible-profile lookup only if the selected provider cannot serve that model.

Artifacts

Rust reproduction fixture for selected OpenCode Go with duplicate slash model

  • The fixture configures two compatible profiles with the same slash model and requests the OpenCode Go route, ending with the takeaway: it directly reproduces the provider-selection defect.

Captured failed provider-routing reproduction output

  • The captured cargo run reports selected OpenCode Go but actual OpenCode Zen and exits 101, ending with the takeaway: current fallback routing selects the first global profile instead of the requested provider.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-base/src/provider/catalog_routes.rs
Line: 892-897

Comment:
**Selected compatible provider is ignored for overlapping slash models**

When two configured OpenAI-compatible providers advertise the same slash-prefixed model, this global lookup returns the first catalog match before the selected remote provider is considered. For example, selecting OpenCode Go while both OpenCode Go and OpenCode Zen advertise `shared/vendor-model` routes the request to OpenCode Zen. This can send prompts to a provider the user did not select. Resolve `remote_current_openai_compatible_route_for_model(remote_provider_name, model)` first for slash models, then use the global compatible-profile lookup only if the selected provider cannot serve that model.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +892 to +897

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Selected compatible provider is ignored for overlapping slash models

For slash-prefixed models, this branch resolves remote_openai_compatible_route_for_model(model) globally and returns its first catalog match before considering remote_provider_name. When two configured compatible providers advertise the same model, the fallback can send the request and its prompt to a provider the user did not select. Resolve remote_current_openai_compatible_route_for_model(remote_provider_name, model) first, and use the global compatible-profile lookup only when the selected provider cannot serve the model.

Artifacts

Focused Rust overlapping-provider reproduction source

  • The authored isolated Rust test writes matching catalogs for OpenCode Zen and OpenCode Go, invokes the exact helpers and fallback builder, and asserts the divergent routes, proving the current behavior.

Reproduction harness dependency manifest

  • The authored manifest records the focused harness dependencies used to compile against the current repository library, making the reproduction setup inspectable.

Current-provider helper output for overlapping catalog

  • The executed baseline test passed and printed that the OpenCode Go current-provider helper returns `openai-compatible:opencode-go`, establishing the route expected from the active provider.

Fallback output for overlapping catalog

  • The executed fallback test passed and printed global and fallback routes as OpenCode Zen while the current-provider route was OpenCode Go, demonstrating that the fallback preempts the active provider.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-base/src/provider/catalog_routes.rs
Line: 892-897

Comment:
**Selected compatible provider is ignored for overlapping slash models**

For slash-prefixed models, this branch resolves `remote_openai_compatible_route_for_model(model)` globally and returns its first catalog match before considering `remote_provider_name`. When two configured compatible providers advertise the same model, the fallback can send the request and its prompt to a provider the user did not select. Resolve `remote_current_openai_compatible_route_for_model(remote_provider_name, model)` first, and use the global compatible-profile lookup only when the selected provider cannot serve the model.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


if model.contains('/') {
let cached = openrouter_cached;
let auto_detail = cached
Expand Down Expand Up @@ -1103,7 +1110,7 @@ pub fn remote_current_openai_compatible_route_for_model(
remote_provider_name: Option<&str>,
model: &str,
) -> Option<ModelRoute> {
if model.trim().is_empty() || model.contains('/') || provider_for_model(model).is_some() {
if model.trim().is_empty() || (!model.contains('/') && provider_for_model(model).is_some()) {
return None;
}

Expand All @@ -1115,6 +1122,13 @@ pub fn remote_current_openai_compatible_route_for_model(
return None;
}
let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile);
if model.contains('/')
&& !remote_openai_compatible_profile_models(&resolved, profile)
.iter()
.any(|candidate| candidate.0 == model)
{
return None;
}

Some(ModelRoute {
model: model.to_string(),
Expand Down Expand Up @@ -1522,6 +1536,35 @@ mod tests {
assert!(!route.detail.contains("fallback"));
}

#[test]
fn slash_model_fallback_prefers_matching_compatible_profile() {
let guard = EnvGuard::new();
let model = "vendouple/gpt-5.6-sol";
guard.save_opencode_cache("https://opencode.ai/zen/v1", &[model]);

let routes = remote_model_routes_fallback(Some("OpenCode Zen"), &[model.to_string()]);

assert_eq!(routes.len(), 1, "unexpected fallback routes: {routes:?}");
assert_eq!(routes[0].provider, "OpenCode Zen");
assert_eq!(routes[0].api_method, "openai-compatible:opencode");
assert!(routes[0].available);
}

#[test]
fn current_compatible_profile_accepts_only_cataloged_slash_models() {
let guard = EnvGuard::new();
let model = "vendouple/gpt-5.6-sol";
guard.save_opencode_cache("https://opencode.ai/zen/v1", &[model]);

let route = remote_current_openai_compatible_route_for_model(Some("OpenCode Zen"), model)
.expect("cataloged slash model should use the current compatible profile");
assert_eq!(route.api_method, "openai-compatible:opencode");
assert!(
remote_current_openai_compatible_route_for_model(Some("OpenCode Zen"), "unknown/model")
.is_none()
);
}

#[test]
fn remote_compatible_route_marks_static_model_list_fallback() {
let _guard = EnvGuard::new();
Expand Down
8 changes: 4 additions & 4 deletions crates/jcode-provider-core/src/openai_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ pub fn schema_supports_strict(schema: &Value) -> bool {
// `description`) is valid JSON Schema, but strict normalization turns it
// into an untyped `anyOf` branch that makes OpenAI reject the entire tool
// catalog. Fall back to non-strict instead. See issue #713.
if let Some(Value::Object(props)) = map.get("properties") {
if props.values().any(|prop| !schema_has_type_info(prop)) {
return false;
}
if let Some(Value::Object(props)) = map.get("properties")
&& props.values().any(|prop| !schema_has_type_info(prop))
{
return false;
}

map.values().all(schema_supports_strict)
Expand Down
8 changes: 8 additions & 0 deletions crates/jcode-tui/src/tui/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@ pub(super) fn handle_cancel_command(app: &mut App, trimmed: &str) -> bool {
return false;
}

let pending_retry = app.rate_limit_reset.is_some() && app.rate_limit_pending_message.is_some();
if app.is_processing {
app.cancel_requested = true;
app.interleave_message = None;
Expand All @@ -847,6 +848,13 @@ pub(super) fn handle_cancel_command(app: &mut App, trimmed: &str) -> bool {
} else {
app.set_status_notice("Interrupting...");
}
} else if pending_retry {
app.clear_pending_remote_retry();
if matches!(app.status, ProcessingStatus::WaitingForNetwork { .. }) {
app.status = ProcessingStatus::Idle;
app.status_detail = None;
}
app.set_status_notice("Pending retry cancelled");
} else {
app.push_display_message(DisplayMessage::system(
"Nothing to cancel: no prompt or operation is in progress.".to_string(),
Expand Down
37 changes: 32 additions & 5 deletions crates/jcode-tui/src/tui/app/misc_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ impl ResolvedTokenPricing {
}
}

fn remote_provider_is_inherently_billed(provider_name: &str) -> bool {
provider_name.contains("opencode")
|| provider_name.contains("openrouter")
|| provider_name.contains("bedrock")
|| provider_name.contains("cerebras")
|| provider_name.contains("compatible")
|| crate::provider_catalog::openai_compatible_profile_id_for_display_name(provider_name)
.and_then(crate::provider_catalog::openai_compatible_profile_by_id)
.is_some_and(|profile| profile.requires_api_key)
}

/// Update cost calculation based on token usage (for API-key providers)
impl App {
pub(super) fn current_streaming_tps_elapsed(&self) -> Duration {
Expand Down Expand Up @@ -358,11 +369,7 @@ impl App {
api_key_billed
} else {
// Providers that are inherently cost-based when proxied remotely.
provider_name.contains("opencode")
|| provider_name.contains("openrouter")
|| provider_name.contains("bedrock")
|| provider_name.contains("cerebras")
|| provider_name.contains("compatible")
remote_provider_is_inherently_billed(&provider_name)
};
if !billed {
return None;
Expand Down Expand Up @@ -555,3 +562,23 @@ impl App {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::remote_provider_is_inherently_billed;

#[test]
fn remote_billing_recognizes_deepseek_display_name() {
assert!(remote_provider_is_inherently_billed("DeepSeek"));
}

#[test]
fn remote_billing_does_not_meter_no_auth_compatible_profiles() {
for provider_name in ["LM Studio", "Ollama"] {
assert!(
!remote_provider_is_inherently_billed(provider_name),
"{provider_name} should not be billed per token"
);
}
}
}
2 changes: 2 additions & 0 deletions crates/jcode-tui/src/tui/app/onboarding_graph.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(dead_code)]

//! The onboarding state-space graph, as data.
//!
//! Onboarding is not one flow: it is a product of independent state spaces (UI
Expand Down
20 changes: 20 additions & 0 deletions crates/jcode-tui/src/tui/app/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ pub(super) use server_events::handle_server_event;

const CONNECTION_MESSAGE_TITLE: &str = "Connection";
const RELOAD_MARKER_MAX_AGE: Duration = Duration::from_secs(30);

fn handle_ctrl_kill_to_end(app: &mut App, code: KeyCode, modifiers: KeyModifiers) -> bool {
// Match the local draft semantics before remote navigation can claim Ctrl+K.
// Ctrl+Shift+K remains reserved for scrolling.
if modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::SHIFT)
&& matches!(code, KeyCode::Char('k'))
&& !app.input.is_empty()
{
input::delete_input_to_end(app);
return true;
}

false
}

pub(super) enum RemoteEventOutcome {
Continue,
Reconnect,
Expand Down Expand Up @@ -1823,6 +1839,10 @@ fn handle_disconnected_key_internal(
let mut modifiers = modifiers;
ctrl_bracket_fallback_to_esc(&mut code, &mut modifiers);

if handle_ctrl_kill_to_end(app, code, modifiers) {
return Ok(());
}

if input::handle_navigation_shortcuts(app, code, modifiers) {
return Ok(());
}
Expand Down
4 changes: 4 additions & 0 deletions crates/jcode-tui/src/tui/app/remote/key_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,10 @@ async fn handle_remote_key_internal(
return Ok(());
}

if handle_ctrl_kill_to_end(app, code, modifiers) {
return Ok(());
}

if let Some(amount) = app.scroll_keys.scroll_amount(code, modifiers) {
if amount < 0 {
app.scroll_up((-amount) as usize);
Expand Down
1 change: 1 addition & 0 deletions crates/jcode-tui/src/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ include!("tests/keybinding_hot_reload.rs");
include!("tests/terminal_setup_command.rs");
include!("tests/issue_497_copy_ctrl_c.rs");
include!("tests/issue_699_ctrl_d_delete.rs");
include!("tests/issue_832_remote_ctrl_k.rs");
include!("tests/spinner_slash_commands.rs");
include!("tests/command_suggestions_cache.rs");
include!("tests/skill_invocation_multi_word.rs");
Expand Down
40 changes: 40 additions & 0 deletions crates/jcode-tui/src/tui/app/tests/issue_496_input_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ fn test_stop_command_processing_requests_interrupt() {
assert!(app.cancel_requested, "'/stop' must interrupt like /cancel");
}

#[test]
fn test_cancel_and_stop_clear_pending_rate_limit_retry() {
for command in ["/cancel", "/stop"] {
let mut app = create_test_app();
let retry_at = Instant::now() + Duration::from_secs(30);
app.rate_limit_reset = Some(retry_at);
app.rate_limit_pending_message = Some(PendingRemoteMessage {
content: "retry me".to_string(),
images: vec![],
is_system: false,
system_reminder: None,
auto_retry: false,
retry_attempts: 0,
retry_at: Some(retry_at),
});

app.set_input_for_test(command);
app.submit_input();

assert!(
app.rate_limit_reset.is_none(),
"{command} must disarm the retry timer"
);
assert!(
app.rate_limit_pending_message.is_none(),
"{command} must discard the pending retry payload"
);
assert!(
!app.cancel_requested,
"{command} must not leak cancellation into the next turn"
);
assert_eq!(
app.status_notice
.as_ref()
.map(|(message, _)| message.as_str()),
Some("Pending retry cancelled")
);
}
}

fn pending_api_key_login() -> crate::tui::app::PendingLogin {
crate::tui::app::PendingLogin::ApiKeyProfile {
provider_id: "openrouter".to_string(),
Expand Down
Loading