From 0e6d000da00b5105c7269001cd8d3223e1e59abf Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:03:25 -0700 Subject: [PATCH 01/14] fix: isolate pinned todos config-off test (fixes #877) --- crates/jcode-tui/src/tui/app/tests/todo_card.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/jcode-tui/src/tui/app/tests/todo_card.rs b/crates/jcode-tui/src/tui/app/tests/todo_card.rs index 6518f6cea2..5ebb68ebb5 100644 --- a/crates/jcode-tui/src/tui/app/tests/todo_card.rs +++ b/crates/jcode-tui/src/tui/app/tests/todo_card.rs @@ -205,6 +205,12 @@ impl PinTodosEnvGuard { crate::config::invalidate_config_cache(); Self } + + fn disable() -> Self { + crate::env::set_var("JCODE_PIN_TODOS", "0"); + crate::config::invalidate_config_cache(); + Self + } } impl Drop for PinTodosEnvGuard { @@ -219,6 +225,7 @@ impl Drop for PinTodosEnvGuard { #[test] fn pinned_todos_payload_stays_empty_when_config_off() { let _env_lock = crate::storage::lock_test_env(); + let _pin_guard = PinTodosEnvGuard::disable(); let mut app = create_test_app(); let session_id = app.session.id.clone(); crate::todo::save_todos(&session_id, &[pinned_band_todo("t1", "pin me", "pending")]).unwrap(); From 5a0e7718fd766e067c9bee26ce23080459a58d1c Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:05:27 -0700 Subject: [PATCH 02/14] fix: avoid duplicate Codex quota windows (fixes #869) --- crates/jcode-base/src/usage/openai_helpers.rs | 10 ++++++- crates/jcode-base/src/usage/tests.rs | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/jcode-base/src/usage/openai_helpers.rs b/crates/jcode-base/src/usage/openai_helpers.rs index a96de909ac..9d43821208 100644 --- a/crates/jcode-base/src/usage/openai_helpers.rs +++ b/crates/jcode-base/src/usage/openai_helpers.rs @@ -104,7 +104,15 @@ pub(super) fn classify_openai_limits(limits: &[UsageLimit]) -> OpenAIUsageData { } if five_hour.is_none() { - five_hour = generic_non_spark.first().cloned(); + five_hour = generic_non_spark + .iter() + .find(|w| { + seven_day + .as_ref() + .map(|weekly| weekly.name != w.name || weekly.resets_at != w.resets_at) + .unwrap_or(true) + }) + .cloned(); } if seven_day.is_none() { seven_day = generic_non_spark diff --git a/crates/jcode-base/src/usage/tests.rs b/crates/jcode-base/src/usage/tests.rs index 8b2bc0b0a2..e995aecef8 100644 --- a/crates/jcode-base/src/usage/tests.rs +++ b/crates/jcode-base/src/usage/tests.rs @@ -227,6 +227,33 @@ fn test_classify_openai_limits_recognizes_five_weekly_and_spark() { assert_eq!(classified.spark.as_ref().map(|w| w.usage_ratio), Some(0.75)); } +#[test] +fn test_classify_openai_limits_does_not_duplicate_weekly_window() { + let limits = vec![ + UsageLimit { + name: "Codex weekly".to_string(), + usage_percent: 25.0, + resets_at: Some("2026-01-07T00:00:00Z".to_string()), + }, + UsageLimit { + name: "Codex 7-day window".to_string(), + usage_percent: 50.0, + resets_at: Some("2026-01-14T00:00:00Z".to_string()), + }, + ]; + + let classified = openai_helpers::classify_openai_limits(&limits); + + assert_eq!( + classified.seven_day.as_ref().map(|window| window.name.as_str()), + Some("Codex weekly") + ); + assert_eq!( + classified.five_hour.as_ref().map(|window| window.name.as_str()), + Some("Codex 7-day window") + ); +} + #[test] fn test_parse_usage_percent_supports_used_limit_shape() { let mut obj = serde_json::Map::new(); From e7b191e053df700ccd1d6a9107ae665c854c4e55 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:06:49 -0700 Subject: [PATCH 03/14] style: format quota regression test --- crates/jcode-base/src/usage/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/usage/tests.rs b/crates/jcode-base/src/usage/tests.rs index e995aecef8..42daf9b607 100644 --- a/crates/jcode-base/src/usage/tests.rs +++ b/crates/jcode-base/src/usage/tests.rs @@ -245,11 +245,17 @@ fn test_classify_openai_limits_does_not_duplicate_weekly_window() { let classified = openai_helpers::classify_openai_limits(&limits); assert_eq!( - classified.seven_day.as_ref().map(|window| window.name.as_str()), + classified + .seven_day + .as_ref() + .map(|window| window.name.as_str()), Some("Codex weekly") ); assert_eq!( - classified.five_hour.as_ref().map(|window| window.name.as_str()), + classified + .five_hour + .as_ref() + .map(|window| window.name.as_str()), Some("Codex 7-day window") ); } From 446601bc3c9337aa67d3e638814e7ee1c05a9253 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:07:41 -0700 Subject: [PATCH 04/14] style: apply workspace formatting --- crates/jcode-desktop2/src/scene.rs | 23 +++++----------------- crates/jcode-desktop2/src/tests/actions.rs | 5 +---- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/crates/jcode-desktop2/src/scene.rs b/crates/jcode-desktop2/src/scene.rs index e3e89ef302..0461a4bf7d 100644 --- a/crates/jcode-desktop2/src/scene.rs +++ b/crates/jcode-desktop2/src/scene.rs @@ -1311,12 +1311,7 @@ fn draw_transcript( Affine::scale(scale), color, None, - &Rect::new( - x0, - y0, - x1, - y1, - ), + &Rect::new(x0, y0, x1, y1), ); } if let Some(selection) = model.selection.as_ref() @@ -1941,18 +1936,10 @@ mod tests { for scale in [1.0, 1.25, 1.5, 1.75, 2.0, 2.5] { let hairline = 1.0 / scale; let origin = 13.37; - let (_, first_bottom) = diff_band_y( - Rect::new(0.0, 0.0, 100.0, 19.2), - origin, - hairline, - false, - ); - let (second_top, _) = diff_band_y( - Rect::new(0.0, 19.2, 100.0, 38.4), - origin, - hairline, - false, - ); + let (_, first_bottom) = + diff_band_y(Rect::new(0.0, 0.0, 100.0, 19.2), origin, hairline, false); + let (second_top, _) = + diff_band_y(Rect::new(0.0, 19.2, 100.0, 38.4), origin, hairline, false); let overlap_px = (first_bottom - second_top) * scale; assert!( (overlap_px - 1.0).abs() < 1e-9, diff --git a/crates/jcode-desktop2/src/tests/actions.rs b/crates/jcode-desktop2/src/tests/actions.rs index f1399296e3..e706918562 100644 --- a/crates/jcode-desktop2/src/tests/actions.rs +++ b/crates/jcode-desktop2/src/tests/actions.rs @@ -1590,10 +1590,7 @@ fn a_new_session_preserves_the_old_panel_until_the_new_one_attaches() { let (updates_tx, updates_rx) = channel(); let (commands_tx, _commands_rx) = channel(); let commands = crate::harness::CommandSender::for_test(commands_tx); - app.harness = Some(( - updates_rx, - commands.clone(), - )); + app.harness = Some((updates_rx, commands.clone())); app.new_session(); From 552a8c1dd12f386397da3246e6fe939552ad24af Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:07:55 -0700 Subject: [PATCH 05/14] style: apply workspace formatting --- crates/jcode-desktop2/src/scene.rs | 23 +++++----------------- crates/jcode-desktop2/src/tests/actions.rs | 5 +---- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/crates/jcode-desktop2/src/scene.rs b/crates/jcode-desktop2/src/scene.rs index e3e89ef302..0461a4bf7d 100644 --- a/crates/jcode-desktop2/src/scene.rs +++ b/crates/jcode-desktop2/src/scene.rs @@ -1311,12 +1311,7 @@ fn draw_transcript( Affine::scale(scale), color, None, - &Rect::new( - x0, - y0, - x1, - y1, - ), + &Rect::new(x0, y0, x1, y1), ); } if let Some(selection) = model.selection.as_ref() @@ -1941,18 +1936,10 @@ mod tests { for scale in [1.0, 1.25, 1.5, 1.75, 2.0, 2.5] { let hairline = 1.0 / scale; let origin = 13.37; - let (_, first_bottom) = diff_band_y( - Rect::new(0.0, 0.0, 100.0, 19.2), - origin, - hairline, - false, - ); - let (second_top, _) = diff_band_y( - Rect::new(0.0, 19.2, 100.0, 38.4), - origin, - hairline, - false, - ); + let (_, first_bottom) = + diff_band_y(Rect::new(0.0, 0.0, 100.0, 19.2), origin, hairline, false); + let (second_top, _) = + diff_band_y(Rect::new(0.0, 19.2, 100.0, 38.4), origin, hairline, false); let overlap_px = (first_bottom - second_top) * scale; assert!( (overlap_px - 1.0).abs() < 1e-9, diff --git a/crates/jcode-desktop2/src/tests/actions.rs b/crates/jcode-desktop2/src/tests/actions.rs index f1399296e3..e706918562 100644 --- a/crates/jcode-desktop2/src/tests/actions.rs +++ b/crates/jcode-desktop2/src/tests/actions.rs @@ -1590,10 +1590,7 @@ fn a_new_session_preserves_the_old_panel_until_the_new_one_attaches() { let (updates_tx, updates_rx) = channel(); let (commands_tx, _commands_rx) = channel(); let commands = crate::harness::CommandSender::for_test(commands_tx); - app.harness = Some(( - updates_rx, - commands.clone(), - )); + app.harness = Some((updates_rx, commands.clone())); app.new_session(); From f24552ab10572d710fa13f949fd85a384c320d49 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:53:38 -0700 Subject: [PATCH 06/14] ci: validate integrated branch From b86833ba2e91f422afc83fc0cb3ca0f038456e37 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:19:16 -0700 Subject: [PATCH 07/14] desktop2: show provider request lifecycle status --- crates/jcode-desktop2/src/harness.rs | 41 +++++++++++++++++++ .../jcode-harness-api-server/src/translate.rs | 4 ++ .../src/translate_tests.rs | 19 +++++++++ crates/jcode-harness-api/src/events.rs | 9 ++++ 4 files changed, 73 insertions(+) diff --git a/crates/jcode-desktop2/src/harness.rs b/crates/jcode-desktop2/src/harness.rs index 41a2649995..d7d51e1134 100644 --- a/crates/jcode-desktop2/src/harness.rs +++ b/crates/jcode-desktop2/src/harness.rs @@ -21,6 +21,23 @@ use std::time::{Duration, Instant}; /// and any other client describe the same failure the same way. pub use jcode_sdk::{SocketState, Stage, describe_disconnect, explain}; +/// Use the same concise lifecycle vocabulary as the TUI. The API intentionally +/// carries the daemon's stable wire strings, so this remains tolerant of a new +/// phase added by a newer daemon. +fn connection_phase_label(phase: String) -> String { + match phase.as_str() { + "authenticating" => "refreshing auth".to_string(), + "connecting" => "connecting".to_string(), + "sending request" => "sending context".to_string(), + "waiting for response" => "waiting for response".to_string(), + "streaming" => "streaming".to_string(), + _ if phase.starts_with("retrying (") && phase.ends_with(')') => { + format!("retrying {}", &phase[10..phase.len() - 1]) + } + _ => phase, + } +} + /// UI-facing updates produced by the connection worker. #[derive(Debug)] pub enum HarnessUpdate { @@ -551,6 +568,13 @@ fn run( ui.send(HarnessUpdate::Activity("thinking".into())); ui.send(HarnessUpdate::Reasoning(text)); } + ApiEvent::ConnectionPhase { phase, .. } => { + // Match the TUI's user-facing vocabulary rather than exposing + // the provider protocol's `sending request` wording. These + // events arrive before reasoning/text, which is precisely when + // a generic "thinking" label otherwise looks hung. + ui.send(HarnessUpdate::Activity(connection_phase_label(phase))); + } ApiEvent::ToolStart { call_id, name, .. } => { tool_input.remove(&call_id); current_call = call_id.clone(); @@ -678,6 +702,23 @@ fn run( mod command_sender_tests { use super::*; + #[test] + fn provider_phases_use_tui_status_labels() { + assert_eq!(connection_phase_label("connecting".into()), "connecting"); + assert_eq!( + connection_phase_label("sending request".into()), + "sending context" + ); + assert_eq!( + connection_phase_label("waiting for response".into()), + "waiting for response" + ); + assert_eq!( + connection_phase_label("retrying (2/4)".into()), + "retrying 2/4" + ); + } + #[test] fn new_session_bypasses_an_occupied_command_queue() { let (tx, rx) = channel(); diff --git a/crates/jcode-harness-api-server/src/translate.rs b/crates/jcode-harness-api-server/src/translate.rs index 260cef2199..af513c8502 100644 --- a/crates/jcode-harness-api-server/src/translate.rs +++ b/crates/jcode-harness-api-server/src/translate.rs @@ -776,6 +776,10 @@ impl BridgeState { session_id: session(self), duration_secs: event["duration_secs"].as_f64(), })], + "connection_phase" => vec![ServerFrame::event(ApiEvent::ConnectionPhase { + session_id: session(self), + phase: event["phase"].as_str().unwrap_or("connecting").to_string(), + })], "tool_start" => vec![ServerFrame::event(ApiEvent::ToolStart { session_id: session(self), call_id: event["id"].as_str().unwrap_or("").to_string(), diff --git a/crates/jcode-harness-api-server/src/translate_tests.rs b/crates/jcode-harness-api-server/src/translate_tests.rs index e014830e9b..dc722b90b3 100644 --- a/crates/jcode-harness-api-server/src/translate_tests.rs +++ b/crates/jcode-harness-api-server/src/translate_tests.rs @@ -82,6 +82,25 @@ fn state_with_session() -> BridgeState { } } +#[test] +fn connection_phase_is_forwarded_to_api_clients() { + let mut state = state_with_session(); + let frames = state.legacy_event_to_api(&json!({ + "type": "connection_phase", + "phase": "sending request", + })); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].reply_to, None); + assert_eq!( + frames[0].event, + ApiEvent::ConnectionPhase { + session_id: "s1".into(), + phase: "sending request".into(), + } + ); +} + #[test] fn create_session_maps_to_subscribe() { let mut state = BridgeState::default(); diff --git a/crates/jcode-harness-api/src/events.rs b/crates/jcode-harness-api/src/events.rs index 7da5b59442..ee9b2a9adc 100644 --- a/crates/jcode-harness-api/src/events.rs +++ b/crates/jcode-harness-api/src/events.rs @@ -133,6 +133,15 @@ pub enum ApiEvent { /// Session-level status change (idle, generating, tool_running, ...). SessionStatus { session_id: String, status: String }, + /// Provider request lifecycle. The value uses the daemon's stable display + /// vocabulary, for example `connecting`, `sending request`, `waiting for + /// response`, `streaming`, or `retrying (2/4)`. + /// + /// This is separate from `SessionStatus`: a session can be `generating` + /// throughout all of these phases, while clients need the finer progress to + /// avoid looking stuck before the model emits its first token. + ConnectionPhase { session_id: String, phase: String }, + /// The provider and model serving the attached session. /// /// Sent unsolicited after attach, and again whenever the model changes, so From cfa891283563e02d5afe8162dba4ed36bf3a35be Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:26:10 -0700 Subject: [PATCH 08/14] sdk: expose connection phase events in TypeScript --- sdk/typescript/src/protocol.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/typescript/src/protocol.ts b/sdk/typescript/src/protocol.ts index fba1e8b468..7358b72761 100644 --- a/sdk/typescript/src/protocol.ts +++ b/sdk/typescript/src/protocol.ts @@ -151,6 +151,7 @@ export type ApiEvent = description: string; } | { ev: "session_status"; session_id: string; status: string } + | { ev: "connection_phase"; session_id: string; phase: string } | { ev: "model_info"; session_id: string; provider?: string; model?: string } | { ev: "models"; session_id: string; models: string[]; current?: string } | { @@ -240,6 +241,7 @@ export const KNOWN_EVENT_KINDS = [ "message_accepted", "permission_request", "session_status", + "connection_phase", "model_info", "models", "runtime_info", From e6883134e69d3ed98c1db9c7190ff4508963a87d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:26:26 -0700 Subject: [PATCH 09/14] test: cover desktop connection phase labels --- crates/jcode-desktop2/src/harness.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/jcode-desktop2/src/harness.rs b/crates/jcode-desktop2/src/harness.rs index d7d51e1134..08cf4cb86b 100644 --- a/crates/jcode-desktop2/src/harness.rs +++ b/crates/jcode-desktop2/src/harness.rs @@ -704,6 +704,10 @@ mod command_sender_tests { #[test] fn provider_phases_use_tui_status_labels() { + assert_eq!( + connection_phase_label("authenticating".into()), + "refreshing auth" + ); assert_eq!(connection_phase_label("connecting".into()), "connecting"); assert_eq!( connection_phase_label("sending request".into()), @@ -717,6 +721,11 @@ mod command_sender_tests { connection_phase_label("retrying (2/4)".into()), "retrying 2/4" ); + assert_eq!(connection_phase_label("streaming".into()), "streaming"); + assert_eq!( + connection_phase_label("negotiating proxy".into()), + "negotiating proxy" + ); } #[test] From 659b8cc155c1aeb48009cbf2db19a540c573314b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:32:45 -0700 Subject: [PATCH 10/14] feat: add Grok Build ACP provider --- Cargo.lock | 99 +++ Cargo.toml | 2 + crates/jcode-base/src/auth/grok_build.rs | 16 + crates/jcode-base/src/auth/integration.rs | 1 + crates/jcode-base/src/auth/mod.rs | 49 ++ crates/jcode-base/src/auth/status_types.rs | 2 + crates/jcode-base/src/provider/activation.rs | 3 + crates/jcode-base/src/provider/external.rs | 3 + crates/jcode-base/src/provider/selection.rs | 1 + .../Cargo.toml | 30 + .../src/bin/fake_acp.rs | 126 +++ .../src/lib.rs | 809 ++++++++++++++++++ .../tests/fake_acp.rs | 143 ++++ crates/jcode-provider-metadata/src/catalog.rs | 19 +- crates/jcode-provider-metadata/src/lib.rs | 2 + crates/jcode-tui/src/tui/app/auth.rs | 11 + src/cli/args.rs | 2 +- src/cli/args/tests.rs | 3 + src/cli/auth_test/choice.rs | 7 + src/cli/commands/report_info.rs | 1 + src/cli/login.rs | 24 + src/cli/provider_init.rs | 25 + src/cli/provider_init_tests.rs | 1 + src/cli/startup.rs | 4 + 24 files changed, 1381 insertions(+), 2 deletions(-) create mode 100644 crates/jcode-base/src/auth/grok_build.rs create mode 100644 crates/jcode-provider-grok-build-runtime/Cargo.toml create mode 100644 crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs create mode 100644 crates/jcode-provider-grok-build-runtime/src/lib.rs create mode 100644 crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs diff --git a/Cargo.lock b/Cargo.lock index e5a33d8d89..8542e33f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,37 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "agent-client-protocol" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759" +dependencies = [ + "agent-client-protocol-schema", + "anyhow", + "async-broadcast", + "async-trait", + "derive_more", + "futures", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" +dependencies = [ + "anyhow", + "derive_more", + "schemars", + "serde", + "serde_json", + "strum 0.28.0", +] + [[package]] name = "agentgrep" version = "0.1.6" @@ -245,6 +276,18 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -1766,6 +1809,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -3334,6 +3378,7 @@ dependencies = [ "jcode-provider-cursor-runtime", "jcode-provider-doctor", "jcode-provider-gemini-runtime", + "jcode-provider-grok-build-runtime", "jcode-provider-openai-runtime", "jcode-provider-openrouter-runtime", "jcode-selfdev-types", @@ -4044,6 +4089,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "jcode-provider-grok-build-runtime" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "futures", + "jcode-message-types", + "jcode-provider-core", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", +] + [[package]] name = "jcode-provider-metadata" version = "0.1.0" @@ -7435,6 +7497,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -7533,6 +7620,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -8464,6 +8562,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 4bcf9ff47e..cc5239823d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ members = [ "crates/jcode-provider-openrouter-runtime", "crates/jcode-provider-anthropic-runtime", "crates/jcode-provider-openai-runtime", + "crates/jcode-provider-grok-build-runtime", "crates/jcode-provider-doctor", "crates/jcode-tui-markdown", "crates/jcode-tui-messages", @@ -200,6 +201,7 @@ jcode-provider-claude-cli-runtime = { path = "crates/jcode-provider-claude-cli-r jcode-provider-openrouter-runtime = { path = "crates/jcode-provider-openrouter-runtime" } jcode-provider-anthropic-runtime = { path = "crates/jcode-provider-anthropic-runtime" } jcode-provider-openai-runtime = { path = "crates/jcode-provider-openai-runtime" } +jcode-provider-grok-build-runtime = { path = "crates/jcode-provider-grok-build-runtime" } jcode-selfdev-types = { path = "crates/jcode-selfdev-types" } # Archive extraction (for auto-update) diff --git a/crates/jcode-base/src/auth/grok_build.rs b/crates/jcode-base/src/auth/grok_build.rs new file mode 100644 index 0000000000..d7325e3c6b --- /dev/null +++ b/crates/jcode-base/src/auth/grok_build.rs @@ -0,0 +1,16 @@ +//! Local Grok CLI discovery for the delegated Grok Build provider. + +use std::path::PathBuf; + +pub const CLI_PATH_ENV: &str = "JCODE_GROK_CLI_PATH"; + +pub fn cli_path() -> PathBuf { + std::env::var_os(CLI_PATH_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("grok")) +} + +pub fn cli_available() -> bool { + super::command_exists(cli_path().to_string_lossy().as_ref()) +} diff --git a/crates/jcode-base/src/auth/integration.rs b/crates/jcode-base/src/auth/integration.rs index 8030e595c0..461a22babd 100644 --- a/crates/jcode-base/src/auth/integration.rs +++ b/crates/jcode-base/src/auth/integration.rs @@ -64,6 +64,7 @@ pub fn runtime_id_for_login_provider( LoginProviderTarget::Azure => Some(RuntimeProviderId::AzureOpenAi), LoginProviderTarget::OpenAiCompatible(_) => Some(RuntimeProviderId::OpenAiCompatible), LoginProviderTarget::Cursor => Some(RuntimeProviderId::Cursor), + LoginProviderTarget::GrokBuild => Some(RuntimeProviderId::GrokBuild), LoginProviderTarget::Copilot => Some(RuntimeProviderId::Copilot), LoginProviderTarget::Gemini => Some(RuntimeProviderId::Gemini), LoginProviderTarget::Antigravity => Some(RuntimeProviderId::Antigravity), diff --git a/crates/jcode-base/src/auth/mod.rs b/crates/jcode-base/src/auth/mod.rs index 7d8a719454..9a71d895c0 100644 --- a/crates/jcode-base/src/auth/mod.rs +++ b/crates/jcode-base/src/auth/mod.rs @@ -13,6 +13,7 @@ pub mod external; pub mod gemini; pub mod google; pub(crate) mod google_oauth; +pub mod grok_build; pub mod integration; pub mod lifecycle; pub mod login_diagnostics; @@ -399,6 +400,7 @@ impl AuthStatus { || self.antigravity == AuthState::Available || self.gemini == AuthState::Available || self.cursor == AuthState::Available + || self.grok_build == AuthState::Available } /// Emit a structured, non-secret snapshot of which providers currently have @@ -434,6 +436,7 @@ impl AuthStatus { ("antigravity", self.antigravity.label().to_string()), ("gemini", self.gemini.label().to_string()), ("cursor", self.cursor.label().to_string()), + ("grok_build", self.grok_build.label().to_string()), ], ); } @@ -466,6 +469,7 @@ impl AuthStatus { LoginProviderAuthStateKey::Antigravity => self.antigravity, LoginProviderAuthStateKey::Gemini => self.gemini, LoginProviderAuthStateKey::Cursor => self.cursor, + LoginProviderAuthStateKey::GrokBuild => self.grok_build, LoginProviderAuthStateKey::Google => self.google, } } @@ -530,6 +534,7 @@ impl AuthStatus { AuthState::NotConfigured } } + crate::provider_catalog::LoginProviderTarget::GrokBuild => self.grok_build, crate::provider_catalog::LoginProviderTarget::OpenAiCompatible(profile) => { if crate::provider_catalog::openai_compatible_profile_is_configured(profile) { AuthState::Available @@ -601,6 +606,13 @@ impl AuthStatus { "not configured".to_string() } } + crate::provider_catalog::LoginProviderTarget::GrokBuild => { + if self.grok_build == AuthState::Available { + "Grok CLI installed; cached subscription login is verified over ACP at request time".to_string() + } else { + "Grok CLI not installed or not found on PATH".to_string() + } + } crate::provider_catalog::LoginProviderTarget::OpenAiCompatible(profile) => { let resolved = crate::provider_catalog::resolve_openai_compatible_profile(profile); if self.state_for_provider(provider) == AuthState::Available { @@ -825,6 +837,21 @@ impl AuthStatus { AuthValidationMethod::PresenceCheck, ) } + crate::provider_catalog::LoginProviderTarget::GrokBuild => ( + if state == AuthState::Available { + AuthCredentialSource::LocalCliSession + } else { + AuthCredentialSource::None + }, + if state == AuthState::Available { + "Grok CLI cached login (credential remains owned by Grok CLI)".to_string() + } else { + "Grok CLI unavailable".to_string() + }, + AuthExpiryConfidence::Unknown, + AuthRefreshSupport::ExternalManaged, + AuthValidationMethod::CommandProbe, + ), crate::provider_catalog::LoginProviderTarget::OpenAiCompatible(profile) => { // Prefer the active named config profile's credential location // (set via `--provider-profile`) over the built-in profile env @@ -971,6 +998,13 @@ fn build_auth_status_uncached(mode: AuthProbeMode) -> (AuthStatus, Vec<(&'static record_auth_probe_step(&mut timings, "cursor", || { probe_cursor_status(&mut status, mode) }); + record_auth_probe_step(&mut timings, "grok_build", || { + status.grok_build = if grok_build::cli_available() { + AuthState::Available + } else { + AuthState::NotConfigured + } + }); record_auth_probe_step(&mut timings, "google", || probe_google_status(&mut status)); (status, timings) @@ -1278,6 +1312,21 @@ fn assessment_for_key( AuthValidationMethod::CompositeProbe, ) } + LoginProviderAuthStateKey::GrokBuild => ( + if state == AuthState::Available { + AuthCredentialSource::LocalCliSession + } else { + AuthCredentialSource::None + }, + if state == AuthState::Available { + "Grok CLI cached login".to_string() + } else { + "Grok CLI unavailable".to_string() + }, + AuthExpiryConfidence::Unknown, + AuthRefreshSupport::ExternalManaged, + AuthValidationMethod::CommandProbe, + ), LoginProviderAuthStateKey::Google => { let (source, detail) = summarize_sources(vec![google_source()]); ( diff --git a/crates/jcode-base/src/auth/status_types.rs b/crates/jcode-base/src/auth/status_types.rs index ba0b0f67d0..b068ea3b20 100644 --- a/crates/jcode-base/src/auth/status_types.rs +++ b/crates/jcode-base/src/auth/status_types.rs @@ -46,6 +46,8 @@ pub struct AuthStatus { pub gemini: AuthState, /// Cursor provider configured via Cursor Agent plus API key or CLI session pub cursor: AuthState, + /// Grok Build CLI is installed. Runtime auth is delegated to its cached login. + pub grok_build: AuthState, /// Google/Gmail OAuth configured pub google: AuthState, /// Google Gmail has send capability (Full tier) diff --git a/crates/jcode-base/src/provider/activation.rs b/crates/jcode-base/src/provider/activation.rs index aadccbc64b..070054b77d 100644 --- a/crates/jcode-base/src/provider/activation.rs +++ b/crates/jcode-base/src/provider/activation.rs @@ -18,6 +18,7 @@ pub enum RuntimeProviderId { AzureOpenAi, Bedrock, Cursor, + GrokBuild, Copilot, Gemini, Antigravity, @@ -37,6 +38,7 @@ impl RuntimeProviderId { Self::AzureOpenAi => "azure-openai", Self::Bedrock => "bedrock", Self::Cursor => "cursor", + Self::GrokBuild => "grok-build", Self::Copilot => "copilot", Self::Gemini => "gemini", Self::Antigravity => "antigravity", @@ -56,6 +58,7 @@ impl RuntimeProviderId { Self::AzureOpenAi => "Azure OpenAI", Self::Bedrock => "AWS Bedrock", Self::Cursor => "Cursor", + Self::GrokBuild => "Grok Build", Self::Copilot => "GitHub Copilot", Self::Gemini => "Gemini", Self::Antigravity => "Antigravity", diff --git a/crates/jcode-base/src/provider/external.rs b/crates/jcode-base/src/provider/external.rs index f2fdce3949..96bd012964 100644 --- a/crates/jcode-base/src/provider/external.rs +++ b/crates/jcode-base/src/provider/external.rs @@ -39,6 +39,9 @@ pub const ANTHROPIC_RUNTIME: &str = "anthropic"; /// Registry key for the OpenAI (Codex) provider runtime. pub const OPENAI_RUNTIME: &str = "openai"; +/// Registry key for Grok Build's Grok CLI ACP runtime. +pub const GROK_BUILD_RUNTIME: &str = "grok-build"; + /// Construction spec for the OpenRouter / OpenAI-compatible runtime family. /// Unlike the other providers, one concrete runtime type serves several /// distinct identities (the real OpenRouter aggregator, a pinned OpenRouter diff --git a/crates/jcode-base/src/provider/selection.rs b/crates/jcode-base/src/provider/selection.rs index fca623f3c8..1b283dc561 100644 --- a/crates/jcode-base/src/provider/selection.rs +++ b/crates/jcode-base/src/provider/selection.rs @@ -101,6 +101,7 @@ impl MultiProvider { LoginProviderTarget::AutoImport | LoginProviderTarget::Jcode | LoginProviderTarget::Azure + | LoginProviderTarget::GrokBuild | LoginProviderTarget::Google => None, } } diff --git a/crates/jcode-provider-grok-build-runtime/Cargo.toml b/crates/jcode-provider-grok-build-runtime/Cargo.toml new file mode 100644 index 0000000000..68b536a4cd --- /dev/null +++ b/crates/jcode-provider-grok-build-runtime/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jcode-provider-grok-build-runtime" +version = "0.1.0" +edition = "2024" +description = "Grok Build subscription runtime for jcode over the Grok CLI ACP stdio transport" + +[lib] +name = "jcode_provider_grok_build_runtime" +path = "src/lib.rs" + +[[bin]] +name = "jcode-fake-grok-acp" +path = "src/bin/fake_acp.rs" +test = false +bench = false + +[dependencies] +agent-client-protocol = { version = "=0.10.4", features = ["unstable_session_model", "unstable_session_resume", "unstable_session_usage"] } +anyhow = "1" +async-trait = "0.1" +futures = "0.3" +jcode-message-types = { path = "../jcode-message-types" } +jcode-provider-core = { path = "../jcode-provider-core" } +serde_json = "1" +tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "sync", "time"] } +tokio-stream = "0.1" +tokio-util = { version = "0.7", features = ["compat"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs new file mode 100644 index 0000000000..f849fb3082 --- /dev/null +++ b/crates/jcode-provider-grok-build-runtime/src/bin/fake_acp.rs @@ -0,0 +1,126 @@ +use serde_json::{Value, json}; +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, Write}; + +fn append_log(value: &Value) { + let path = std::env::var("JCODE_FAKE_GROK_ACP_LOG").expect("fake log path"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open fake log"); + writeln!(file, "{value}").expect("write fake log"); +} + +fn send(value: Value) { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + writeln!(stdout, "{value}").expect("write response"); + stdout.flush().expect("flush response"); +} + +fn response(id: Value, result: Value) { + send(json!({"jsonrpc":"2.0", "id":id, "result":result})); +} + +fn main() { + let stdin = std::io::stdin(); + for line in BufReader::new(stdin.lock()).lines() { + let line = line.expect("read request"); + let value: Value = serde_json::from_str(&line).expect("valid JSON-RPC request"); + append_log(&value); + let Some(method) = value.get("method").and_then(Value::as_str) else { + continue; + }; + let id = value.get("id").cloned().unwrap_or(Value::Null); + match method { + "initialize" => response( + id, + json!({ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "sessionCapabilities": {"resume": {}} + }, + "authMethods": [ + {"id":"xai.api_key", "name":"xAI API key"}, + {"id":"grok.com", "name":"Grok.com"}, + {"id":"cached_token", "name":"Cached token"} + ], + "agentInfo": {"name":"fake-grok", "version":"1.0.0"}, + "_meta": { + "modelState": { + "currentModelId":"grok-4.5", + "availableModels":[ + {"modelId":"grok-4.5", "name":"Grok 4.5"}, + {"modelId":"grok-code-fast-1", "name":"Grok Code Fast"} + ] + } + } + }), + ), + "authenticate" => response(id, json!({})), + "session/new" => response( + id, + json!({ + "sessionId":"fake-session-new", + "models": { + "currentModelId":"grok-4.5", + "availableModels":[ + {"modelId":"grok-4.5", "name":"Grok 4.5"}, + {"modelId":"grok-code-fast-1", "name":"Grok Code Fast"} + ] + } + }), + ), + "session/resume" => response( + id, + json!({ + "models": { + "currentModelId":"grok-code-fast-1", + "availableModels":[ + {"modelId":"grok-4.5", "name":"Grok 4.5"}, + {"modelId":"grok-code-fast-1", "name":"Grok Code Fast"} + ] + } + }), + ), + "session/set_model" => response(id, json!({})), + "session/prompt" => { + if std::env::var_os("JCODE_FAKE_GROK_ACP_HANG").is_some() { + continue; + } + send(json!({ + "jsonrpc":"2.0", + "method":"_x.ai/settings/update", + "params":{"ignored":true} + })); + send(json!({ + "jsonrpc":"2.0", + "method":"session/update", + "params":{ + "sessionId":"fake-session-new", + "update":{ + "sessionUpdate":"agent_thought_chunk", + "content":{"type":"text", "text":"thinking"} + } + } + })); + send(json!({ + "jsonrpc":"2.0", + "method":"session/update", + "params":{ + "sessionId":"fake-session-new", + "update":{ + "sessionUpdate":"agent_message_chunk", + "content":{"type":"text", "text":"AUTH_TEST_OK"} + } + } + })); + response(id, json!({"stopReason":"end_turn"})); + } + "session/cancel" => break, + other => panic!("unexpected ACP method: {other}"), + } + } +} diff --git a/crates/jcode-provider-grok-build-runtime/src/lib.rs b/crates/jcode-provider-grok-build-runtime/src/lib.rs new file mode 100644 index 0000000000..326953829c --- /dev/null +++ b/crates/jcode-provider-grok-build-runtime/src/lib.rs @@ -0,0 +1,809 @@ +//! Grok Build subscription provider over the installed Grok CLI's ACP server. +//! +//! This runtime deliberately has no xAI HTTP or API-key path. Authentication is +//! delegated to `grok agent stdio`, which consumes the Grok CLI's own cached +//! login after ACP `initialize` advertises the supported subscription method. + +use acp::Agent as _; +use agent_client_protocol as acp; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; +use futures::Stream; +use jcode_message_types::{ + ContentBlock as JcodeContentBlock, Message, Role, StreamEvent, ToolDefinition, +}; +use jcode_provider_core::{EventStream, ModelRoute, Provider}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +use std::task::{Context as TaskContext, Poll}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +const DEFAULT_MODEL: &str = "grok-4.5"; +const ACP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const ACP_PROMPT_TIMEOUT: Duration = Duration::from_secs(60 * 60); +const STDERR_LIMIT: usize = 64 * 1024; + +#[derive(Clone, Debug)] +pub struct GrokBuildProcess { + pub command: PathBuf, + pub args: Vec, + pub env: BTreeMap, +} + +impl GrokBuildProcess { + pub fn from_env() -> Self { + let command = std::env::var_os("JCODE_GROK_CLI_PATH") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("grok")); + Self { + command, + args: vec![ + "agent".to_string(), + "stdio".to_string(), + "--no-auto-update".to_string(), + ], + env: BTreeMap::new(), + } + } +} + +#[derive(Clone)] +pub struct GrokBuildProvider { + process: GrokBuildProcess, + model: Arc>, + models: Arc>>, + model_selected: Arc, +} + +impl GrokBuildProvider { + pub fn new() -> Self { + Self::with_process(GrokBuildProcess::from_env()) + } + + pub fn with_process(process: GrokBuildProcess) -> Self { + Self { + process, + model: Arc::new(RwLock::new(DEFAULT_MODEL.to_string())), + models: Arc::new(RwLock::new(Vec::new())), + model_selected: Arc::new(AtomicBool::new(false)), + } + } + + /// Verify that the CLI can initialize and authenticate with its own cached + /// subscription credential. This never reads or forwards credential data. + pub async fn authenticate_cached_cli(&self) -> Result<()> { + let process = self.process.clone(); + run_on_acp_thread_with_process(process, move |connection| { + Box::pin(async move { + let initialized = initialize_and_authenticate(&connection).await?; + Ok::<_, anyhow::Error>(models_from_initialize(&initialized)) + }) + }) + .await + .map(|_| ()) + .with_context(|| cached_login_hint("Grok Build authentication failed")) + } + + fn update_models(&self, discovered: DiscoveredModels) { + if !discovered.available.is_empty() { + *self + .models + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = discovered.available; + } + if let Some(current) = discovered.current.filter(|model| !model.trim().is_empty()) { + let mut selected = self + .model + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if selected.as_str() == DEFAULT_MODEL + || !self + .models + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains(&*selected) + { + *selected = current; + } + } + } +} + +impl Default for GrokBuildProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for GrokBuildProvider { + async fn complete( + &self, + messages: &[Message], + _tools: &[ToolDefinition], + system: &str, + resume_session_id: Option<&str>, + ) -> Result { + let prompt = build_prompt(messages, system, resume_session_id.is_some())?; + let process = self.process.clone(); + // Before catalog prefetch or an explicit `--model`/picker choice, let + // Grok CLI keep its advertised current model instead of forcing our + // display fallback onto a newer CLI catalog. + let selected_model = self + .model_selected + .load(Ordering::Acquire) + .then(|| self.model()); + let resume_session_id = resume_session_id.map(ToOwned::to_owned); + let (tx, rx) = mpsc::channel(128); + let (cancel_tx, cancel_rx) = oneshot::channel(); + + let thread = std::thread::Builder::new() + .name("jcode-grok-build-acp".to_string()) + .spawn(move || { + if let Err(error) = run_turn_thread( + process, + selected_model, + resume_session_id, + prompt, + tx.clone(), + cancel_rx, + ) { + let _ = tx.blocking_send(Ok(StreamEvent::Error { + message: format!("{error:#}"), + retry_after_secs: None, + })); + } + }) + .context("Failed to start Grok Build ACP runtime thread")?; + + Ok(Box::pin(GrokEventStream { + inner: ReceiverStream::new(rx), + cancel: Some(cancel_tx), + thread: Some(thread), + })) + } + + fn name(&self) -> &str { + "grok-build" + } + + fn display_name(&self) -> String { + "Grok Build".to_string() + } + + fn model(&self) -> String { + self.model + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn set_model(&self, model: &str) -> Result<()> { + let model = model.strip_prefix("grok-build:").unwrap_or(model).trim(); + if model.is_empty() { + bail!("Grok Build model cannot be empty"); + } + let available = self + .models + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !available.is_empty() && !available.iter().any(|candidate| candidate == model) { + bail!( + "Model '{model}' is not advertised by Grok Build. Available models: {}", + available.join(", ") + ); + } + drop(available); + *self + .model + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = model.to_string(); + self.model_selected.store(true, Ordering::Release); + Ok(()) + } + + fn available_models_display(&self) -> Vec { + self.models + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn available_models_for_switching(&self) -> Vec { + self.available_models_display() + } + + fn model_routes(&self) -> Vec { + self.available_models_display() + .into_iter() + .map(|model| ModelRoute { + model, + provider: "Grok Build".to_string(), + api_method: "grok-build-acp".to_string(), + available: true, + detail: "Grok Build subscription via Grok CLI ACP".to_string(), + cheapness: None, + }) + .collect() + } + + async fn prefetch_models(&self) -> Result<()> { + let process = self.process.clone(); + let discovered = run_on_acp_thread_with_process(process, move |connection| { + Box::pin(async move { + let initialized = initialize_and_authenticate(&connection).await?; + Ok::<_, anyhow::Error>(models_from_initialize(&initialized)) + }) + }) + .await + .with_context(|| cached_login_hint("Failed to discover Grok Build models"))?; + self.update_models(discovered); + Ok(()) + } + + fn active_auth_method_label(&self) -> Option<&'static str> { + Some("Grok CLI cached login") + } + + fn handles_tools_internally(&self) -> bool { + true + } + + fn transport(&self) -> Option { + Some("ACP stdio".to_string()) + } + + fn fork(&self) -> Arc { + let fork = Self::with_process(self.process.clone()); + *fork + .model + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = self.model(); + *fork + .models + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = self.available_models_display(); + fork.model_selected.store( + self.model_selected.load(Ordering::Acquire), + Ordering::Release, + ); + Arc::new(fork) + } +} + +struct GrokEventStream { + inner: ReceiverStream>, + cancel: Option>, + thread: Option>, +} + +impl Stream for GrokEventStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_next(cx) + } +} + +impl Drop for GrokEventStream { + fn drop(&mut self) { + if let Some(cancel) = self.cancel.take() { + let _ = cancel.send(()); + } + // Joining can block while the child handles cancellation. Detach here; + // dropping the child in the ACP thread has kill_on_drop enabled. + self.thread.take(); + } +} + +#[derive(Default, Debug)] +struct DiscoveredModels { + current: Option, + available: Vec, +} + +fn models_from_initialize(response: &acp::InitializeResponse) -> DiscoveredModels { + let state = response + .meta + .as_ref() + .and_then(|meta| meta.get("modelState")); + models_from_value(state) +} + +fn models_from_value(value: Option<&Value>) -> DiscoveredModels { + let Some(object) = value.and_then(Value::as_object) else { + return DiscoveredModels::default(); + }; + let current = object + .get("currentModelId") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let mut available = Vec::new(); + if let Some(models) = object.get("availableModels").and_then(Value::as_array) { + for value in models { + let id = value.as_str().or_else(|| { + value.as_object().and_then(|model| { + ["modelId", "id", "name"] + .into_iter() + .find_map(|key| model.get(key).and_then(Value::as_str)) + }) + }); + if let Some(id) = id.filter(|id| !id.trim().is_empty()) + && !available.iter().any(|known| known == id) + { + available.push(id.to_string()); + } + } + } + if let Some(current) = current.as_ref() + && !available.iter().any(|model| model == current) + { + available.insert(0, current.clone()); + } + DiscoveredModels { current, available } +} + +fn select_subscription_auth_method( + response: &acp::InitializeResponse, +) -> Result { + let allowed = response.auth_methods.iter().filter(|method| { + let id = method.id().0.as_ref().to_ascii_lowercase(); + id != "xai.api_key" && !id.contains("api_key") && !id.contains("api-key") + }); + for preferred in ["cached_token", "grok.com"] { + if let Some(method) = allowed + .clone() + .find(|method| method.id().0.as_ref() == preferred) + { + return Ok(method.id().clone()); + } + } + if let Some(method) = allowed.into_iter().find(|method| { + let id = method.id().0.as_ref().to_ascii_lowercase(); + let name = method.name().to_ascii_lowercase(); + id.contains("grok") || id.contains("cached") || name.contains("grok") + }) { + return Ok(method.id().clone()); + } + let advertised = response + .auth_methods + .iter() + .map(|method| method.id().0.as_ref()) + .collect::>() + .join(", "); + bail!( + "Grok CLI did not advertise a cached subscription authentication method (advertised: {})", + if advertised.is_empty() { + "none" + } else { + &advertised + } + ) +} + +async fn initialize_and_authenticate( + connection: &acp::ClientSideConnection, +) -> Result { + let initialize = acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_info(acp::Implementation::new("jcode", env!("CARGO_PKG_VERSION")).title("Jcode")); + let response = timeout_request("initialize", connection.initialize(initialize)).await?; + if response.protocol_version != acp::ProtocolVersion::V1 { + bail!( + "Grok CLI negotiated unsupported ACP protocol version {:?}", + response.protocol_version + ); + } + let auth_method = select_subscription_auth_method(&response)?; + let mut meta = Map::new(); + meta.insert("headless".to_string(), Value::Bool(true)); + timeout_request( + "authenticate", + connection.authenticate(acp::AuthenticateRequest::new(auth_method).meta(meta)), + ) + .await?; + Ok(response) +} + +async fn timeout_request( + name: &'static str, + future: impl std::future::Future>, +) -> Result { + tokio::time::timeout(ACP_REQUEST_TIMEOUT, future) + .await + .map_err(|_| { + anyhow!( + "Grok CLI ACP {name} timed out after {}s", + ACP_REQUEST_TIMEOUT.as_secs() + ) + })? + .map_err(|error| anyhow!("Grok CLI ACP {name} failed: {error}")) +} + +fn run_turn_thread( + process: GrokBuildProcess, + selected_model: Option, + resume_session_id: Option, + prompt: String, + tx: mpsc::Sender>, + cancel_rx: oneshot::Receiver<()>, +) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("Failed to build Grok Build ACP Tokio runtime")?; + let local = tokio::task::LocalSet::new(); + local.block_on(&runtime, async move { + with_connection(process, tx.clone(), async move |connection| { + initialize_and_authenticate(&connection).await?; + let cwd = std::env::current_dir().context("Failed to determine working directory")?; + let (session_id, session_model) = if let Some(session_id) = resume_session_id { + let response = timeout_request( + "session/resume", + connection.resume_session(acp::ResumeSessionRequest::new( + session_id.clone(), + cwd, + )), + ) + .await?; + (acp::SessionId::new(session_id), response.models) + } else { + let response = timeout_request( + "session/new", + connection.new_session(acp::NewSessionRequest::new(cwd).mcp_servers(Vec::new())), + ) + .await?; + (response.session_id, response.models) + }; + + tx.send(Ok(StreamEvent::SessionId(session_id.0.to_string()))) + .await + .map_err(|_| anyhow!("Grok Build stream consumer closed"))?; + + let current_model = session_model + .as_ref() + .map(|models| models.current_model_id.0.as_ref()); + if let Some(selected_model) = selected_model + && current_model != Some(selected_model.as_str()) + { + timeout_request( + "session/set_model", + connection.set_session_model(acp::SetSessionModelRequest::new( + session_id.clone(), + selected_model, + )), + ) + .await?; + } + + let prompt_request = acp::PromptRequest::new( + session_id.clone(), + vec![acp::ContentBlock::Text(acp::TextContent::new(prompt))], + ); + tokio::pin!(cancel_rx); + let response = tokio::select! { + response = tokio::time::timeout(ACP_PROMPT_TIMEOUT, connection.prompt(prompt_request)) => { + response + .map_err(|_| anyhow!("Grok CLI ACP session/prompt timed out after {}s", ACP_PROMPT_TIMEOUT.as_secs()))? + .map_err(|error| anyhow!("Grok CLI ACP session/prompt failed: {error}"))? + } + _ = &mut cancel_rx => { + connection.cancel(acp::CancelNotification::new(session_id.clone())).await + .map_err(|error| anyhow!("Failed to cancel Grok CLI ACP prompt: {error}"))?; + // `cancel` queues a JSON-RPC notification. Give the local + // connection driver one scheduling turn to flush it before + // dropping the kill-on-drop child process. + tokio::time::sleep(Duration::from_millis(25)).await; + return Ok(()); + } + }; + tx.send(Ok(StreamEvent::MessageEnd { + stop_reason: Some(format!("{:?}", response.stop_reason).to_ascii_lowercase()), + })) + .await + .map_err(|_| anyhow!("Grok Build stream consumer closed"))?; + Ok(()) + }) + .await + }) +} + +type LocalConnectionFuture = Pin> + 'static>>; + +async fn run_on_acp_thread_with_process( + process: GrokBuildProcess, + operation: impl FnOnce(acp::ClientSideConnection) -> LocalConnectionFuture + Send + 'static, +) -> Result { + let (result_tx, result_rx) = oneshot::channel(); + std::thread::Builder::new() + .name("jcode-grok-build-acp-probe".to_string()) + .spawn(move || { + let result = (|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let local = tokio::task::LocalSet::new(); + local.block_on(&runtime, async move { + with_connection(process, mpsc::channel(1).0, operation).await + }) + })(); + let _ = result_tx.send(result); + }) + .context("Failed to start Grok Build ACP probe thread")?; + result_rx + .await + .context("Grok Build ACP probe thread exited without a result")? +} + +async fn with_connection( + process: GrokBuildProcess, + event_tx: mpsc::Sender>, + operation: F, +) -> Result +where + F: FnOnce(acp::ClientSideConnection) -> Fut, + Fut: std::future::Future> + 'static, +{ + let mut command = Command::new(&process.command); + command + .args(&process.args) + .envs(&process.env) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = command.spawn().with_context(|| { + format!( + "Failed to launch '{}'. Install the Grok CLI and run `grok login` first", + process.command.display() + ) + })?; + let stdin = child + .stdin + .take() + .context("Grok CLI stdin was unavailable")?; + let stdout = child + .stdout + .take() + .context("Grok CLI stdout was unavailable")?; + let stderr = child + .stderr + .take() + .context("Grok CLI stderr was unavailable")?; + let stderr_capture = Arc::new(std::sync::Mutex::new(String::new())); + let stderr_task = tokio::task::spawn_local(capture_stderr(stderr, Arc::clone(&stderr_capture))); + + let client = GrokAcpClient { tx: event_tx }; + let (connection, io) = + acp::ClientSideConnection::new(client, stdin.compat_write(), stdout.compat(), |future| { + tokio::task::spawn_local(future); + }); + let io_task = tokio::task::spawn_local(io); + let result = operation(connection).await; + drop(child); + io_task.abort(); + stderr_task.abort(); + result.map_err(|error| { + let stderr = stderr_capture + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if stderr.trim().is_empty() { + error + } else { + error.context(format!("Grok CLI stderr: {}", stderr.trim())) + } + }) +} + +async fn capture_stderr( + mut stderr: tokio::process::ChildStderr, + capture: Arc>, +) { + let mut buffer = [0_u8; 4096]; + loop { + let Ok(read) = stderr.read(&mut buffer).await else { + return; + }; + if read == 0 { + return; + } + let mut output = capture + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if output.len() < STDERR_LIMIT { + let remaining = STDERR_LIMIT - output.len(); + output.push_str(&String::from_utf8_lossy(&buffer[..read.min(remaining)])); + } + } +} + +struct GrokAcpClient { + tx: mpsc::Sender>, +} + +#[async_trait(?Send)] +impl acp::Client for GrokAcpClient { + async fn request_permission( + &self, + request: acp::RequestPermissionRequest, + ) -> acp::Result { + let selected = request.options.iter().find(|option| { + matches!( + option.kind, + acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways + ) + }); + let outcome = match selected { + Some(option) => acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option.option_id.clone()), + ), + None => acp::RequestPermissionOutcome::Cancelled, + }; + Ok(acp::RequestPermissionResponse::new(outcome)) + } + + async fn session_notification( + &self, + notification: acp::SessionNotification, + ) -> acp::Result<()> { + let event = match notification.update { + acp::SessionUpdate::AgentMessageChunk(chunk) => { + text_from_acp_content(chunk.content).map(StreamEvent::TextDelta) + } + acp::SessionUpdate::AgentThoughtChunk(chunk) => { + text_from_acp_content(chunk.content).map(StreamEvent::ThinkingDelta) + } + acp::SessionUpdate::ToolCall(call) => { + Some(StreamEvent::StatusDetail { detail: call.title }) + } + acp::SessionUpdate::ToolCallUpdate(update) => update + .fields + .title + .map(|detail| StreamEvent::StatusDetail { detail }), + _ => None, + }; + if let Some(event) = event { + let _ = self.tx.send(Ok(event)).await; + } + Ok(()) + } +} + +fn text_from_acp_content(content: acp::ContentBlock) -> Option { + match content { + acp::ContentBlock::Text(text) => Some(text.text), + _ => None, + } +} + +fn build_prompt(messages: &[Message], system: &str, resumed: bool) -> Result { + let latest_user = messages + .iter() + .rev() + .find(|message| message.role == Role::User) + .map(message_text) + .filter(|text| !text.trim().is_empty()) + .ok_or_else(|| anyhow!("No user prompt found for Grok Build request"))?; + + let mut sections = Vec::new(); + if !system.trim().is_empty() { + sections.push(format!("\n{}\n", system.trim())); + } + if !resumed { + let history = messages + .iter() + .take(messages.len().saturating_sub(1)) + .filter_map(|message| { + let text = message_text(message); + (!text.trim().is_empty()).then(|| { + let role = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + format!("<{role}>\n{text}\n") + }) + }) + .collect::>() + .join("\n\n"); + if !history.is_empty() { + sections.push(history); + } + } + sections.push(latest_user); + Ok(sections.join("\n\n")) +} + +fn message_text(message: &Message) -> String { + message + .content + .iter() + .filter_map(|block| match block { + JcodeContentBlock::Text { text, .. } => Some(text.clone()), + JcodeContentBlock::ToolResult { content, .. } => Some(content.clone()), + _ => None, + }) + .collect::>() + .join("\n\n") +} + +fn cached_login_hint(prefix: &str) -> String { + format!( + "{prefix}. Grok Build uses the Grok CLI subscription login, not XAI_API_KEY. Run `grok login` and retry" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn chooses_cached_subscription_auth_and_rejects_api_key_only() { + let response = acp::InitializeResponse::new(acp::ProtocolVersion::V1).auth_methods(vec![ + acp::AuthMethod::Agent(acp::AuthMethodAgent::new("xai.api_key", "xAI API key")), + acp::AuthMethod::Agent(acp::AuthMethodAgent::new("grok.com", "Grok.com")), + acp::AuthMethod::Agent(acp::AuthMethodAgent::new("cached_token", "Cached token")), + ]); + assert_eq!( + select_subscription_auth_method(&response) + .unwrap() + .0 + .as_ref(), + "cached_token" + ); + + let grok_com_only = + acp::InitializeResponse::new(acp::ProtocolVersion::V1).auth_methods(vec![ + acp::AuthMethod::Agent(acp::AuthMethodAgent::new("grok.com", "Grok.com")), + ]); + assert_eq!( + select_subscription_auth_method(&grok_com_only) + .unwrap() + .0 + .as_ref(), + "grok.com" + ); + + let api_only = acp::InitializeResponse::new(acp::ProtocolVersion::V1).auth_methods(vec![ + acp::AuthMethod::Agent(acp::AuthMethodAgent::new("xai.api_key", "xAI API key")), + ]); + assert!(select_subscription_auth_method(&api_only).is_err()); + } + + #[test] + fn parses_dynamic_models_from_initialize_meta() { + let state = json!({ + "currentModelId": "grok-4.5", + "availableModels": [ + {"modelId": "grok-4.5", "name": "Grok 4.5"}, + "grok-code-fast-1", + {"id": "grok-4.5"} + ] + }); + let models = models_from_value(Some(&state)); + assert_eq!(models.current.as_deref(), Some("grok-4.5")); + assert_eq!(models.available, ["grok-4.5", "grok-code-fast-1"]); + } + + #[test] + fn resumed_prompt_sends_only_outer_system_and_latest_user() { + let messages = vec![ + Message::user("old"), + Message::assistant_text("old answer"), + Message::user("new"), + ]; + let prompt = build_prompt(&messages, "outer", true).unwrap(); + assert!(prompt.contains("outer")); + assert!(prompt.ends_with("new")); + assert!(!prompt.contains("old answer")); + } +} diff --git a/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs new file mode 100644 index 0000000000..e3a5c7b141 --- /dev/null +++ b/crates/jcode-provider-grok-build-runtime/tests/fake_acp.rs @@ -0,0 +1,143 @@ +use futures::StreamExt; +use jcode_message_types::{Message, StreamEvent}; +use jcode_provider_core::Provider; +use jcode_provider_grok_build_runtime::{GrokBuildProcess, GrokBuildProvider}; +use std::collections::BTreeMap; +use std::path::Path; + +fn fake_process(log: &Path) -> GrokBuildProcess { + let mut env = BTreeMap::new(); + env.insert( + "JCODE_FAKE_GROK_ACP_LOG".to_string(), + log.display().to_string(), + ); + GrokBuildProcess { + command: env!("CARGO_BIN_EXE_jcode-fake-grok-acp").into(), + args: Vec::new(), + env, + } +} + +#[tokio::test(flavor = "current_thread")] +async fn fake_subprocess_covers_handshake_models_new_prompt_and_auth_isolation() { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("acp.jsonl"); + let provider = GrokBuildProvider::with_process(fake_process(&log)); + + provider.prefetch_models().await.unwrap(); + assert_eq!( + provider.available_models_display(), + ["grok-4.5", "grok-code-fast-1"] + ); + provider.set_model("grok-code-fast-1").unwrap(); + + let mut stream = provider + .complete( + &[Message::user("Reply exactly AUTH_TEST_OK")], + &[], + "outer-system", + None, + ) + .await + .unwrap(); + let mut events = Vec::new(); + while let Some(event) = stream.next().await { + events.push(event.unwrap()); + } + assert!( + events + .iter() + .any(|event| matches!(event, StreamEvent::SessionId(id) if id == "fake-session-new")) + ); + assert!( + events + .iter() + .any(|event| matches!(event, StreamEvent::ThinkingDelta(text) if text == "thinking")) + ); + assert!( + events + .iter() + .any(|event| matches!(event, StreamEvent::TextDelta(text) if text == "AUTH_TEST_OK")) + ); + assert!( + events + .iter() + .any(|event| matches!(event, StreamEvent::MessageEnd { .. })) + ); + + let requests = std::fs::read_to_string(&log).unwrap(); + assert!(requests.contains("\"method\":\"initialize\"")); + assert!(requests.contains("\"method\":\"authenticate\"")); + assert!(requests.contains("\"methodId\":\"cached_token\"")); + assert!(!requests.contains("\"methodId\":\"xai.api_key\"")); + assert!(requests.contains("\"method\":\"session/new\"")); + assert!(requests.contains("\"mcpServers\":[]")); + assert!(requests.contains("\"method\":\"session/set_model\"")); + assert!(requests.contains("outer-system")); +} + +#[tokio::test(flavor = "current_thread")] +async fn fake_subprocess_resumes_without_history_replay_or_model_reset() { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("resume.jsonl"); + let provider = GrokBuildProvider::with_process(fake_process(&log)); + provider.prefetch_models().await.unwrap(); + provider.set_model("grok-code-fast-1").unwrap(); + + let mut stream = provider + .complete( + &[ + Message::user("old prompt"), + Message::assistant_text("old answer"), + Message::user("new prompt"), + ], + &[], + "outer-system", + Some("existing-session"), + ) + .await + .unwrap(); + while stream.next().await.is_some() {} + + let requests = std::fs::read_to_string(&log).unwrap(); + assert!(requests.contains("\"method\":\"session/resume\"")); + assert!(requests.contains("\"sessionId\":\"existing-session\"")); + assert!(!requests.contains("old answer")); + assert!(requests.contains("new prompt")); + assert!(!requests.contains("\"method\":\"session/set_model\"")); +} + +#[tokio::test(flavor = "current_thread")] +async fn dropping_stream_cancels_prompt_and_terminates_subprocess() { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("cancel.jsonl"); + let mut process = fake_process(&log); + process + .env + .insert("JCODE_FAKE_GROK_ACP_HANG".to_string(), "1".to_string()); + let provider = GrokBuildProvider::with_process(process); + + let mut stream = provider + .complete(&[Message::user("wait")], &[], "", None) + .await + .unwrap(); + let session = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) + .await + .expect("session setup timed out") + .expect("stream closed before session setup") + .unwrap(); + assert!(matches!(session, StreamEvent::SessionId(_))); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + drop(stream); + + let mut cancelled = false; + for _ in 0..40 { + let requests = std::fs::read_to_string(&log).unwrap_or_default(); + if requests.contains("\"method\":\"session/cancel\"") { + cancelled = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!(cancelled, "stream drop did not send session/cancel"); +} diff --git a/crates/jcode-provider-metadata/src/catalog.rs b/crates/jcode-provider-metadata/src/catalog.rs index bb624e4bfd..75187309c7 100644 --- a/crates/jcode-provider-metadata/src/catalog.rs +++ b/crates/jcode-provider-metadata/src/catalog.rs @@ -963,6 +963,22 @@ pub const XAI_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor order: LoginProviderSurfaceOrder::new(Some(33), Some(33), Some(33), Some(33), Some(33)), }; +/// Grok Build is intentionally a separate identity from `xai`: it delegates +/// OAuth/token ownership to the installed Grok CLI and never consumes +/// `XAI_API_KEY`. +pub const GROK_BUILD_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { + id: "grok-build", + display_name: "Grok Build", + auth_kind: LoginProviderAuthKind::Cli, + auth_state_key: LoginProviderAuthStateKey::GrokBuild, + auth_status_method: "Grok CLI cached login", + aliases: &[], + menu_detail: "Grok Build subscription via installed Grok CLI", + recommended: false, + target: LoginProviderTarget::GrokBuild, + order: LoginProviderSurfaceOrder::new(Some(100), Some(100), Some(100), Some(100), Some(100)), +}; + pub const NVIDIA_NIM_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescriptor { id: "nvidia-nim", display_name: "NVIDIA NIM", @@ -1137,7 +1153,7 @@ pub const GOOGLE_LOGIN_PROVIDER: LoginProviderDescriptor = LoginProviderDescript order: LoginProviderSurfaceOrder::new(Some(13), None, None, None, None), }; -pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 49] = [ +pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 50] = [ AUTO_IMPORT_LOGIN_PROVIDER, CLAUDE_LOGIN_PROVIDER, ANTHROPIC_API_LOGIN_PROVIDER, @@ -1174,6 +1190,7 @@ pub(crate) const LOGIN_PROVIDERS: [LoginProviderDescriptor; 49] = [ FIREWORKS_LOGIN_PROVIDER, MINIMAX_LOGIN_PROVIDER, XAI_LOGIN_PROVIDER, + GROK_BUILD_LOGIN_PROVIDER, NVIDIA_NIM_LOGIN_PROVIDER, XIAOMI_MIMO_LOGIN_PROVIDER, META_MUSE_LOGIN_PROVIDER, diff --git a/crates/jcode-provider-metadata/src/lib.rs b/crates/jcode-provider-metadata/src/lib.rs index 673ec6288b..e9aabff794 100644 --- a/crates/jcode-provider-metadata/src/lib.rs +++ b/crates/jcode-provider-metadata/src/lib.rs @@ -34,6 +34,7 @@ pub enum LoginProviderTarget { Azure, OpenAiCompatible(OpenAiCompatibleProfile), Cursor, + GrokBuild, Copilot, Gemini, Antigravity, @@ -53,6 +54,7 @@ pub enum LoginProviderAuthStateKey { Gemini, Antigravity, Cursor, + GrokBuild, Google, } diff --git a/crates/jcode-tui/src/tui/app/auth.rs b/crates/jcode-tui/src/tui/app/auth.rs index 77f9459a5a..6d858fe5f7 100644 --- a/crates/jcode-tui/src/tui/app/auth.rs +++ b/crates/jcode-tui/src/tui/app/auth.rs @@ -561,6 +561,17 @@ impl App { self.start_openai_compatible_profile_login(profile) } crate::provider_catalog::LoginProviderTarget::Cursor => self.start_cursor_login(), + crate::provider_catalog::LoginProviderTarget::GrokBuild => { + crate::telemetry::record_auth_surface_blocked( + provider.id, + provider.auth_kind.label(), + ); + self.push_display_message(DisplayMessage::system( + "Grok Build authentication is owned by the Grok CLI. Run `jcode login --provider grok-build` (or `grok login`) in a terminal, then reopen the model picker." + .to_string(), + )); + self.set_status_notice("Grok Build: run grok login in a terminal"); + } crate::provider_catalog::LoginProviderTarget::Copilot => self.start_copilot_login(), crate::provider_catalog::LoginProviderTarget::Gemini => self.start_gemini_login(), crate::provider_catalog::LoginProviderTarget::Antigravity => { diff --git a/src/cli/args.rs b/src/cli/args.rs index a8dc00689e..d1148197e8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -31,7 +31,7 @@ pub(crate) enum ProviderAuthArg { #[command(version = jcode_build_meta::version())] #[command(about = "J-Code: A coding agent using Claude Max or ChatGPT Pro subscriptions")] pub(crate) struct Args { - /// Initial provider to use (jcode, claude, openai, openai-api, openrouter, azure, opencode, opencode-go, zai, 302ai, baseten, cortecs, comtegra, deepseek, fpt, firmware, huggingface, moonshotai, nebius, scaleway, stackit, groq, mistral, perplexity, togetherai, deepinfra, xai, nvidia-nim, lmstudio, ollama, chutes, cerebras, alibaba-coding-plan, openai-compatible, cursor, copilot, gemini, antigravity, google, or auto-detect). Interactive sessions can switch providers with /model. + /// Initial provider to use (jcode, claude, openai, openai-api, openrouter, azure, opencode, opencode-go, zai, 302ai, baseten, cortecs, comtegra, deepseek, fpt, firmware, huggingface, moonshotai, nebius, scaleway, stackit, groq, mistral, perplexity, togetherai, deepinfra, xai, grok-build, nvidia-nim, lmstudio, ollama, chutes, cerebras, alibaba-coding-plan, openai-compatible, cursor, copilot, gemini, antigravity, google, or auto-detect). Interactive sessions can switch providers with /model. #[arg(short, long, default_value = "auto", global = true)] pub(crate) provider: ProviderChoice, diff --git a/src/cli/args/tests.rs b/src/cli/args/tests.rs index 89b8b1d2d6..9bd74804d4 100644 --- a/src/cli/args/tests.rs +++ b/src/cli/args/tests.rs @@ -47,6 +47,9 @@ fn test_provider_choice_aliases_parse() { let args = Args::try_parse_from(["jcode", "--provider", "grok", "run", "smoke"]).unwrap(); assert_eq!(args.provider, ProviderChoice::Xai); + let args = Args::try_parse_from(["jcode", "--provider", "grok-build"]).unwrap(); + assert_eq!(args.provider, ProviderChoice::GrokBuild); + let args = Args::try_parse_from(["jcode", "--provider", "cgc", "run", "smoke"]).unwrap(); assert_eq!(args.provider, ProviderChoice::Comtegra); } diff --git a/src/cli/auth_test/choice.rs b/src/cli/auth_test/choice.rs index 39d6a88a3f..466da62a6c 100644 --- a/src/cli/auth_test/choice.rs +++ b/src/cli/auth_test/choice.rs @@ -58,6 +58,13 @@ pub(crate) fn tool_smoke_skip_detail_for_choice( ); } + if matches!(choice, super::provider_init::ProviderChoice::GrokBuild) { + return Some( + "Skipped: Grok Build executes its isolated ACP coding-tool loop internally; it does not expose Jcode tool calls for the outer auth-test harness. Basic provider smoke validates the subscription transport." + .to_string(), + ); + } + if matches!(choice, super::provider_init::ProviderChoice::Fpt) { let model = effective_openai_compatible_auth_test_model( crate::provider_catalog::FPT_PROFILE, diff --git a/src/cli/commands/report_info.rs b/src/cli/commands/report_info.rs index 2e9a77891e..22c10fdc42 100644 --- a/src/cli/commands/report_info.rs +++ b/src/cli/commands/report_info.rs @@ -575,6 +575,7 @@ pub(super) fn list_cli_providers() -> Vec { ProviderChoice::TogetherAi, ProviderChoice::Deepinfra, ProviderChoice::Xai, + ProviderChoice::GrokBuild, ProviderChoice::Chutes, ProviderChoice::Cerebras, ProviderChoice::AlibabaCodingPlan, diff --git a/src/cli/login.rs b/src/cli/login.rs index 30b39c1e1b..dd74a96c38 100644 --- a/src/cli/login.rs +++ b/src/cli/login.rs @@ -295,6 +295,9 @@ pub async fn run_login_provider( LoginProviderTarget::OpenAiApiKey => { login_openai_api_key_flow().map(|_| LoginFlowOutcome::Completed) } + LoginProviderTarget::GrokBuild => login_grok_build_flow() + .await + .map(|_| LoginFlowOutcome::Completed), LoginProviderTarget::OpenRouter => { login_openrouter_flow().map(|_| LoginFlowOutcome::Completed) } @@ -403,6 +406,27 @@ pub async fn run_login_provider( Ok(()) } +async fn login_grok_build_flow() -> Result<()> { + let cli = crate::auth::grok_build::cli_path(); + let status = tokio::process::Command::new(&cli) + .arg("login") + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await + .with_context(|| { + format!( + "Failed to launch '{}'. Install the Grok CLI, then retry `jcode login --provider grok-build`", + cli.display() + ) + })?; + if !status.success() { + anyhow::bail!("`{} login` exited with status {status}", cli.display()); + } + Ok(()) +} + fn maybe_persist_default_provider_after_login( provider: LoginProviderDescriptor, options: &LoginOptions, diff --git a/src/cli/provider_init.rs b/src/cli/provider_init.rs index 678b373eb2..b962a276a9 100644 --- a/src/cli/provider_init.rs +++ b/src/cli/provider_init.rs @@ -90,6 +90,9 @@ pub enum ProviderChoice { Minimax, #[value(alias = "x.ai", alias = "x-ai", alias = "grok")] Xai, + /// Grok Build subscription via the authenticated Grok CLI ACP transport. + #[value(name = "grok-build")] + GrokBuild, #[value(alias = "nvidia", alias = "nim")] NvidiaNim, #[value(alias = "xiaomi", alias = "mimo", alias = "xiaomi-mimo-api")] @@ -171,6 +174,7 @@ impl ProviderChoice { Self::Fireworks => "fireworks", Self::Minimax => "minimax", Self::Xai => "xai", + Self::GrokBuild => "grok-build", Self::NvidiaNim => "nvidia-nim", Self::XiaomiMimo => "xiaomi-mimo", Self::MetaMuse => "meta-muse", @@ -326,6 +330,10 @@ const PROVIDER_CHOICE_LOGIN_PROVIDERS: &[(ProviderChoice, LoginProviderDescripto ProviderChoice::Xai, crate::provider_catalog::XAI_LOGIN_PROVIDER, ), + ( + ProviderChoice::GrokBuild, + crate::provider_catalog::GROK_BUILD_LOGIN_PROVIDER, + ), ( ProviderChoice::NvidiaNim, crate::provider_catalog::NVIDIA_NIM_LOGIN_PROVIDER, @@ -1285,6 +1293,13 @@ pub async fn login_and_bootstrap_provider( disable_subscription_runtime_mode(); Arc::new(provider::MultiProvider::with_preference(true)) } + LoginProviderTarget::GrokBuild => { + disable_subscription_runtime_mode(); + crate::provider::external::instantiate_external_provider( + crate::provider::external::GROK_BUILD_RUNTIME, + ) + .ok_or_else(|| anyhow::anyhow!("Grok Build runtime is not registered"))? + } LoginProviderTarget::OpenAiApiKey => { disable_subscription_runtime_mode(); select_initial_model_provider("openai"); @@ -1502,6 +1517,16 @@ async fn init_provider_with_options( crate::env::set_var("JCODE_ACTIVE_PROVIDER", "gemini"); Arc::new(jcode_provider_gemini_runtime::GeminiProvider::new()) } + ProviderChoice::GrokBuild => { + disable_subscription_runtime_mode(); + init_notice("Using Grok Build subscription via the authenticated Grok CLI"); + clear_initial_model_provider(); + crate::env::set_var("JCODE_ACTIVE_PROVIDER", "grok-build"); + crate::provider::external::instantiate_external_provider( + crate::provider::external::GROK_BUILD_RUNTIME, + ) + .ok_or_else(|| anyhow::anyhow!("Grok Build runtime is not registered"))? + } ProviderChoice::Openrouter => { disable_subscription_runtime_mode(); ensure_external_api_key_auth_allowed_for_explicit_choice("OPENROUTER_API_KEY")?; diff --git a/src/cli/provider_init_tests.rs b/src/cli/provider_init_tests.rs index b5a90e465c..e0bc534ea3 100644 --- a/src/cli/provider_init_tests.rs +++ b/src/cli/provider_init_tests.rs @@ -46,6 +46,7 @@ fn test_provider_choice_arg_values() { assert_eq!(ProviderChoice::Fireworks.as_arg_value(), "fireworks"); assert_eq!(ProviderChoice::Minimax.as_arg_value(), "minimax"); assert_eq!(ProviderChoice::Xai.as_arg_value(), "xai"); + assert_eq!(ProviderChoice::GrokBuild.as_arg_value(), "grok-build"); assert_eq!(ProviderChoice::XiaomiMimo.as_arg_value(), "xiaomi-mimo"); assert_eq!(ProviderChoice::MetaMuse.as_arg_value(), "meta-muse"); assert_eq!(ProviderChoice::Celeris.as_arg_value(), "celeris"); diff --git a/src/cli/startup.rs b/src/cli/startup.rs index 0e7895c986..7558e1a10a 100644 --- a/src/cli/startup.rs +++ b/src/cli/startup.rs @@ -133,6 +133,10 @@ pub async fn run() -> Result<()> { /// registration in this one function so the composition-root wiring stays /// discoverable as more providers move out of the base crate. pub fn register_external_provider_runtimes() { + crate::provider::external::register_external_provider( + crate::provider::external::GROK_BUILD_RUNTIME, + || std::sync::Arc::new(jcode_provider_grok_build_runtime::GrokBuildProvider::new()), + ); crate::provider::external::register_external_provider( crate::provider::external::GEMINI_RUNTIME, || std::sync::Arc::new(jcode_provider_gemini_runtime::GeminiProvider::new()), From 1ab482c77e8afbecb80e955164e19718fe6dd5c6 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:03:17 -0700 Subject: [PATCH 11/14] fix(todo): normalize completed statuses for auto-poke --- crates/jcode-app-core/src/tool/todo.rs | 28 +++++++++++++++++++- crates/jcode-base/src/todo.rs | 36 ++++++++++++++++++++++++++ src/cli/commands.rs | 14 ++++++---- src/cli/commands_tests.rs | 28 ++++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/jcode-app-core/src/tool/todo.rs b/crates/jcode-app-core/src/tool/todo.rs index b2d9ebea8d..a66779ac53 100644 --- a/crates/jcode-app-core/src/tool/todo.rs +++ b/crates/jcode-app-core/src/tool/todo.rs @@ -656,6 +656,12 @@ fn normalize_todo_input(mut input: Value) -> Value { let Some(fields) = item.as_object_mut() else { continue; }; + if key == "todos" + && let Some(Value::String(status)) = fields.get_mut("status") + && let Some(canonical) = crate::todo::canonical_todo_status(status) + { + *status = canonical.to_string(); + } for key in [ "confidence", "completion_confidence", @@ -727,7 +733,8 @@ impl Tool for TodoTool { }, "status": { "type": "string", - "description": "Status." + "enum": ["pending", "in_progress", "completed", "cancelled"], + "description": "Status. Use completed when the task is done." }, "priority": { "type": "string", @@ -1222,6 +1229,25 @@ mod tests { ); } + #[test] + fn normalizes_natural_and_case_varied_todo_statuses() { + let parsed = parse(json!({ + "todos": [ + {"content": "a", "status": "done", "priority": "high", "id": "1", "confidence": "verified"}, + {"content": "b", "status": " Finished ", "priority": "low", "id": "2", "confidence": "validated"}, + {"content": "c", "status": "Canceled", "priority": "low", "id": "3", "confidence": "plausible"} + ] + })) + .expect("status synonyms should parse"); + let statuses: Vec<_> = parsed + .todos + .expect("todos present") + .into_iter() + .map(|todo| todo.status) + .collect(); + assert_eq!(statuses, ["completed", "completed", "cancelled"]); + } + #[test] fn accepts_float_confidence_and_empty_string_as_none() { let input = json!({ diff --git a/crates/jcode-base/src/todo.rs b/crates/jcode-base/src/todo.rs index 8dac3ead30..fa8a29036e 100644 --- a/crates/jcode-base/src/todo.rs +++ b/crates/jcode-base/src/todo.rs @@ -26,6 +26,42 @@ pub use jcode_task_types::{ TodoPlanField, }; +/// Return the canonical todo status for model-written status vocabulary. +/// +/// The todo tool historically accepted any string, so persisted sessions can +/// contain natural completion synonyms such as `done` or `finished`. Keep this +/// helper tolerant for those sessions even though new tool calls advertise a +/// constrained vocabulary. +pub fn canonical_todo_status(status: &str) -> Option<&'static str> { + let status = status.trim(); + if status.eq_ignore_ascii_case("pending") { + Some("pending") + } else if status.eq_ignore_ascii_case("in_progress") + || status.eq_ignore_ascii_case("in progress") + || status.eq_ignore_ascii_case("in-progress") + { + Some("in_progress") + } else if status.eq_ignore_ascii_case("completed") + || status.eq_ignore_ascii_case("complete") + || status.eq_ignore_ascii_case("done") + || status.eq_ignore_ascii_case("finished") + { + Some("completed") + } else if status.eq_ignore_ascii_case("cancelled") || status.eq_ignore_ascii_case("canceled") { + Some("cancelled") + } else { + None + } +} + +pub fn todo_status_is_completed(status: &str) -> bool { + canonical_todo_status(status) == Some("completed") +} + +pub fn todo_status_is_cancelled(status: &str) -> bool { + canonical_todo_status(status) == Some("cancelled") +} + /// Whether the plan's intent understanding is solid enough to work against. pub fn intent_understanding_passes(state: Option) -> bool { state.is_some_and(|state| state >= IntentUnderstanding::Clear) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 8fc2314291..82fb3784a6 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -2582,9 +2582,10 @@ fn take_run_gate_digest_if_turn_ended( already_delivered: bool, todos: &[crate::todo::TodoItem], ) -> Option { - let work_remains = todos - .iter() - .any(|todo| todo.status != "completed" && todo.status != "cancelled"); + let work_remains = todos.iter().any(|todo| { + !crate::todo::todo_status_is_completed(&todo.status) + && !crate::todo::todo_status_is_cancelled(&todo.status) + }); if work_remains { return None; } @@ -2598,7 +2599,10 @@ fn build_run_auto_poke_follow_up_from_todos( ) -> Option { let incomplete: Vec<_> = todos .iter() - .filter(|todo| todo.status != "completed" && todo.status != "cancelled") + .filter(|todo| { + !crate::todo::todo_status_is_completed(&todo.status) + && !crate::todo::todo_status_is_cancelled(&todo.status) + }) .cloned() .collect(); if !incomplete.is_empty() { @@ -2635,7 +2639,7 @@ fn build_run_todo_validation_message( ) -> Option<(String, bool)> { let completed: Vec<&crate::todo::TodoItem> = todos .iter() - .filter(|todo| todo.status == "completed") + .filter(|todo| crate::todo::todo_status_is_completed(&todo.status)) .collect(); if completed.is_empty() { return None; diff --git a/src/cli/commands_tests.rs b/src/cli/commands_tests.rs index d540cf1452..b324417136 100644 --- a/src/cli/commands_tests.rs +++ b/src/cli/commands_tests.rs @@ -405,6 +405,34 @@ fn run_auto_poke_followup_prioritizes_incomplete_todos() { } } +#[test] +fn run_auto_poke_treats_completion_synonyms_and_case_as_finished() { + for status in ["done", "finished", "complete", "Completed", " DONE "] { + let todos = vec![test_todo( + "a", + status, + "high", + Some(ConfidenceState::Verified), + Some(ConfidenceState::Verified), + )]; + assert!( + build_run_auto_poke_follow_up_from_todos(&todos, false, None).is_none(), + "status {status:?} should not trigger an incomplete-todo poke" + ); + } +} + +#[test] +fn run_auto_poke_treats_cancelled_spelling_variants_as_finished() { + for status in ["cancelled", "canceled", "Cancelled"] { + let todos = vec![test_todo("a", status, "high", None, None)]; + assert!( + build_run_auto_poke_follow_up_from_todos(&todos, false, None).is_none(), + "status {status:?} should not trigger an incomplete-todo poke" + ); + } +} + /// Headless `jcode run` is what the benchmarks and scripted use go through, so /// the deferred quality review must reach that path too, not only the TUI. #[test] From 9bfbfa889b94a3ad7f6c9824c07728078d432656 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:03:55 -0700 Subject: [PATCH 12/14] chore(release): prepare v0.75.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.75.1.json | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.75.1.json diff --git a/Cargo.lock b/Cargo.lock index 8542e33f50..10c8cd7280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3355,7 +3355,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.75.0" +version = "0.75.1" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index cc5239823d..74d0430201 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.75.0" +version = "0.75.1" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index bfe1a4356a..38036fae0d 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.75.1", + "date": "2026-08-11" + }, { "version": "0.75.0", "date": "2026-08-10" diff --git a/changelog/v0.75.1.json b/changelog/v0.75.1.json new file mode 100644 index 0000000000..dcd281bba9 --- /dev/null +++ b/changelog/v0.75.1.json @@ -0,0 +1,18 @@ +{ + "version": "0.75.1", + "date": "2026-08-11", + "title": "More reliable autonomous runs", + "highlights": [ + "Todo completion synonyms such as done and finished no longer trigger false auto-poke loops", + "Grok Build is available as an ACP provider" + ], + "improvements": [ + "Todo status values are documented and constrained in the tool schema", + "Desktop and SDK clients expose provider request lifecycle status" + ], + "fixes": [ + "Todo statuses are normalized on write and compared case-insensitively during headless run completion checks", + "Codex quota windows no longer appear more than once", + "Pinned todo configuration tests no longer leak process-global configuration" + ] +} From d1f24ad7f67f47d582ac36f5e66852da9a32786e Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:11:09 -0700 Subject: [PATCH 13/14] fix(todo): reject unknown status values --- crates/jcode-app-core/src/tool/todo.rs | 37 +++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/jcode-app-core/src/tool/todo.rs b/crates/jcode-app-core/src/tool/todo.rs index a66779ac53..dc0fd9ece2 100644 --- a/crates/jcode-app-core/src/tool/todo.rs +++ b/crates/jcode-app-core/src/tool/todo.rs @@ -7,7 +7,7 @@ use crate::todo::{ feedback_loop_passes, intent_understanding_passes, load_goals, load_plan, load_todos, save_goals, save_plan, save_todos, update_todo_review_cycle, }; -use anyhow::Result; +use anyhow::{Result, bail}; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; @@ -72,6 +72,21 @@ struct TodoInput { plan: Option, } +fn parse_todo_input(input: Value) -> Result { + let params: TodoInput = serde_json::from_value(normalize_todo_input(input))?; + if let Some(todo) = params.todos.as_ref().and_then(|todos| { + todos + .iter() + .find(|todo| crate::todo::canonical_todo_status(&todo.status).is_none()) + }) { + bail!( + "invalid todo status {:?}; expected one of: pending, in_progress, completed, cancelled", + todo.status + ); + } + Ok(params) +} + /// Normalize a goal's group label: trimmed, with empty/whitespace collapsed /// to `None` (the implicit goal of an ungrouped list). fn goal_group_key(group: Option<&str>) -> Option { @@ -844,7 +859,7 @@ impl Tool for TodoTool { } async fn execute(&self, input: Value, ctx: ToolContext) -> Result { - let params: TodoInput = serde_json::from_value(normalize_todo_input(input))?; + let params = parse_todo_input(input)?; let is_write = params.todos.is_some() || params.goals.is_some() || params.plan.is_some(); let operation = if is_write { "write" } else { "read" }; let result = if is_write { @@ -1185,8 +1200,8 @@ mod tests { } } - fn parse(input: Value) -> Result { - serde_json::from_value(normalize_todo_input(input)) + fn parse(input: Value) -> Result { + parse_todo_input(input) } #[test] @@ -1248,6 +1263,20 @@ mod tests { assert_eq!(statuses, ["completed", "completed", "cancelled"]); } + #[test] + fn rejects_unknown_todo_statuses_with_valid_vocabulary() { + let error = parse(json!({ + "todos": [ + {"content": "a", "status": "blocked", "priority": "high", "id": "1", "confidence": "plausible"} + ] + })) + .err() + .expect("unknown status should be rejected"); + let message = error.to_string(); + assert!(message.contains("invalid todo status \"blocked\"")); + assert!(message.contains("pending, in_progress, completed, cancelled")); + } + #[test] fn accepts_float_confidence_and_empty_string_as_none() { let input = json!({ From 30ec7b33719889d61de23aab15c481fd155a1eb9 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:11:24 -0700 Subject: [PATCH 14/14] chore(release): prepare v0.75.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- changelog/index.json | 4 ++++ changelog/v0.75.2.json | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 changelog/v0.75.2.json diff --git a/Cargo.lock b/Cargo.lock index 10c8cd7280..e0ad1be484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3355,7 +3355,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jcode" -version = "0.75.1" +version = "0.75.2" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 74d0430201..fa0833168d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jcode" -version = "0.75.1" +version = "0.75.2" description = "Possibly the greatest coding agent ever built — blazing-fast TUI, multi-model, swarm coordination, 30+ tools" edition = "2024" autobins = false diff --git a/changelog/index.json b/changelog/index.json index 38036fae0d..db84003eed 100644 --- a/changelog/index.json +++ b/changelog/index.json @@ -1,5 +1,9 @@ { "entries": [ + { + "version": "0.75.2", + "date": "2026-08-11" + }, { "version": "0.75.1", "date": "2026-08-11" diff --git a/changelog/v0.75.2.json b/changelog/v0.75.2.json new file mode 100644 index 0000000000..d49ab7cd7d --- /dev/null +++ b/changelog/v0.75.2.json @@ -0,0 +1,14 @@ +{ + "version": "0.75.2", + "date": "2026-08-11", + "title": "Strict todo status validation", + "highlights": [ + "The todo tool now rejects unknown status values instead of storing them silently" + ], + "improvements": [ + "Invalid status errors list the accepted pending, in_progress, completed, and cancelled values" + ], + "fixes": [ + "Unknown model-written status strings can no longer leave todo completion behavior ambiguous" + ] +}