Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ members = [
"crates/jcode-provider-gemini",
"crates/jcode-provider-gemini-runtime",
"crates/jcode-provider-cursor-runtime",
"crates/jcode-provider-cursor-acp-runtime",
"crates/jcode-provider-antigravity-runtime",
"crates/jcode-provider-copilot-runtime",
"crates/jcode-provider-openrouter-runtime",
Expand Down Expand Up @@ -209,6 +210,7 @@ jcode-provider-doctor = { path = "crates/jcode-provider-doctor" }
# provider registry at startup (src/cli/startup.rs).
jcode-provider-gemini-runtime = { path = "crates/jcode-provider-gemini-runtime" }
jcode-provider-cursor-runtime = { path = "crates/jcode-provider-cursor-runtime" }
jcode-provider-cursor-acp-runtime = { path = "crates/jcode-provider-cursor-acp-runtime" }
jcode-provider-antigravity-runtime = { path = "crates/jcode-provider-antigravity-runtime" }
jcode-provider-copilot-runtime = { path = "crates/jcode-provider-copilot-runtime" }
jcode-provider-openrouter-runtime = { path = "crates/jcode-provider-openrouter-runtime" }
Expand Down
24 changes: 24 additions & 0 deletions crates/jcode-app-core/src/agent/turn_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,30 @@ impl Agent {

// If provider handles tools internally (like Claude Code CLI), only run native tools locally
if self.provider.handles_tools_internally() {
// Persist tool results that the provider already executed. The
// retain below filters tool_calls to native-only, but the
// provider's results (from StreamEvent::ToolResult) must still
// be saved so the conversation has matching tool_use ->
// tool_result pairs. Without this, the session stores
// ContentBlock::ToolUse with no corresponding
// ContentBlock::ToolResult, leaving an invalid history that
// breaks provider replays.
if !sdk_tool_results.is_empty() {
for tc in &tool_calls {
if let Some((content, is_error)) = sdk_tool_results.remove(&tc.id) {
let content = cap_sdk_tool_content_for_history(&tc.name, content);
self.add_message(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: tc.id.clone(),
content,
is_error: if is_error { Some(true) } else { None },
}],
);
}
}
self.session.save()?;
}
tool_calls.retain(|tc| JCODE_NATIVE_TOOLS.contains(&tc.name.as_str()));
if tool_calls.is_empty() {
if !generated_image_contexts.is_empty() {
Expand Down
24 changes: 24 additions & 0 deletions crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,30 @@ impl Agent {
));

if self.provider.handles_tools_internally() {
// Persist tool results that the provider already executed. The
// retain below filters tool_calls to native-only, but the
// provider's results (from StreamEvent::ToolResult) must still
// be saved so the conversation has matching tool_use ->
// tool_result pairs. Without this, the session stores
// ContentBlock::ToolUse (added above at line ~1064) with no
// corresponding ContentBlock::ToolResult, leaving an invalid
// history that breaks provider replays.
if !sdk_tool_results.is_empty() {
for tc in &tool_calls {
if let Some((content, is_error)) = sdk_tool_results.remove(&tc.id) {
let content = cap_sdk_tool_content_for_history(&tc.name, content);
self.add_message(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: tc.id.clone(),
content,
is_error: if is_error { Some(true) } else { None },
}],
);
}
}
self.session.save()?;
}
tool_calls.retain(|tc| JCODE_NATIVE_TOOLS.contains(&tc.name.as_str()));
if tool_calls.is_empty() {
// === INJECTION POINT D: After provider-handled tools, before next API call ===
Expand Down
2 changes: 1 addition & 1 deletion crates/jcode-app-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,8 @@ pub use self::socket::{
};
use self::socket::{signal_ready_fd, socket_has_live_listener};

pub use self::util::{MANAGED_CLOUD_LIVE_RELOAD_MARKER, ServerIdentity};
pub(crate) use self::util::server_has_newer_binary;
pub use self::util::{MANAGED_CLOUD_LIVE_RELOAD_MARKER, ServerIdentity};
use self::util::{
debug_control_allowed, embedding_idle_unload_secs, git_common_dir_for, reload_exec_target,
startup_headless_recovery_test_delay, swarm_id_for_dir, swarm_id_for_session,
Expand Down
15 changes: 9 additions & 6 deletions crates/jcode-app-core/src/server/client_lifecycle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,8 +1289,10 @@ fn accepted_reload_recovery_continuation_marks_intent_delivered() -> anyhow::Res
let message_count = agent.lock().await.message_count();
let event_count = event_counter.load(std::sync::atomic::Ordering::Relaxed);

// A stale History continuation arriving after the final answer is a
// no-op even if it carries an invalid skill. No turn/skill/status edits.
// A stale History continuation whose reminder was already delivered
// still carries user-shaped content, so e6d9cb47c semantics run it as
// a real turn; the invalid skill then errors out. No turn/skill/status
// edits happen beyond that terminal event.
start_processing_message(
ProcessingMessage {
id: 78,
Expand Down Expand Up @@ -1330,10 +1332,11 @@ fn accepted_reload_recovery_continuation_marks_intent_delivered() -> anyhow::Res
event_count
);
assert!(processing_done_rx.try_recv().is_err());
assert!(matches!(
client_event_rx.try_recv(),
Ok(ServerEvent::Done { id: 78 })
));
let received = client_event_rx.try_recv();
assert!(
matches!(received, Ok(ServerEvent::Error { id: 78, .. })),
"invalid skill on a content-carrying send must terminal-error, got {received:?}"
);
assert!(client_event_rx.try_recv().is_err());

// Unrelated reminders and ordinary prompts (even the exact body) are
Expand Down
2 changes: 1 addition & 1 deletion crates/jcode-app-core/src/server/client_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ pub(super) async fn start_processing_message(
result
.as_ref()
.err()
.map(|error| crate::util::format_error_chain(error))
.map(crate::util::format_error_chain)
.as_deref(),
);
// Mobile push: fire-and-forget, skipped for user cancellation and for
Expand Down
3 changes: 2 additions & 1 deletion crates/jcode-app-core/src/server/debug_command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,12 +611,13 @@ pub(super) async fn execute_debug_command(
"openai" | "codex" => jcode_provider_core::DEFAULT_OPENAI_MODEL,
"openrouter" => "anthropic/claude-sonnet-4",
"cursor" => "gpt-5",
"cursor-acp" => "default",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Provider command misses Cursor ACP

set_provider:cursor-acp passes the bare model name default to agent.set_model() without selecting the Cursor ACP runtime. When another provider is active, this either fails or changes that provider instead. With Gemini active, the command returned success but the next completion still went to Gemini using default; with Claude active, the command failed model validation. Cursor ACP was available in both checks.

Select the Cursor ACP route explicitly and use its advertised current model so the command actually switches the session before reporting success.

Artifacts

Evidence from the check

  • Uploaded the authored Rust harness that exercised the real handler and router with offline runtimes, preserving the executable reproduction.

Evidence from the check

  • Uploaded the authored Python runner that captured both handler versions and restored the tracked source, preserving the comparison procedure.

Command output from the check

  • Uploaded the existing before-change execution capture, showing explicit command rejection and unchanged provider routing.

Command output from the check

  • Uploaded the existing after-change execution capture, showing that neither attempted provider switch activated ACP.

View artifacts

TREX Ran code and verified through TREX

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-app-core/src/server/debug_command_exec.rs
Line: 614

Comment:
**Provider command misses Cursor ACP**

`set_provider:cursor-acp` passes the bare model name `default` to `agent.set_model()` without selecting the Cursor ACP runtime. When another provider is active, this either fails or changes that provider instead. With Gemini active, the command returned success but the next completion still went to Gemini using `default`; with Claude active, the command failed model validation. Cursor ACP was available in both checks.

Select the Cursor ACP route explicitly and use its advertised current model so the command actually switches the session before reporting success.

---

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

"copilot" => "copilot:claude-sonnet-4",
"gemini" => "gemini-2.5-pro",
"antigravity" => "default",
_ => {
return Err(anyhow::anyhow!(
"Unknown provider '{}'. Use: claude, openai, openrouter, cursor, copilot, gemini, antigravity",
"Unknown provider '{}'. Use: claude, openai, openrouter, cursor, cursor-acp, copilot, gemini, antigravity",
provider
));
}
Expand Down
2 changes: 1 addition & 1 deletion crates/jcode-app-core/src/server/debug_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ SERVER COMMANDS (server: prefix or no prefix):
create_session:selfdev:<path> - Create headless self-dev session
destroy_session:<id> - Destroy a session
set_model:<model> - Switch model (may change provider)
set_provider:<name> - Switch provider (claude/openai/openrouter/cursor/copilot/gemini/antigravity)
set_provider:<name> - Switch provider (claude/openai/openrouter/cursor/cursor-acp/copilot/gemini/antigravity)
trigger_extraction - Force end-of-session memory extraction
available_models - List all available models
reload - Trigger server reload with current binary
Expand Down
35 changes: 16 additions & 19 deletions crates/jcode-app-core/src/server/lane_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,27 +210,24 @@ fn schedule_queued_retry(session_id: &str) {
}
let session_id = session_id.to_string();
tokio::spawn(async move {
loop {
tokio::time::sleep(QUEUED_RETRY_INTERVAL).await;
let still_queued = queued_sessions()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&session_id);
if !still_queued {
return;
}
let Ok(todos) = crate::todo::load_todos(&session_id) else {
return;
};
let lanes = dispatchable_lanes(&todos);
if lanes.is_empty() {
return;
}
// dispatch re-inserts the session when the cap still holds; that
// insert starts a fresh retry loop, so this one ends.
dispatch(session_id.clone(), lanes).await;
tokio::time::sleep(QUEUED_RETRY_INTERVAL).await;
let still_queued = queued_sessions()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&session_id);
if !still_queued {
return;
}
let Ok(todos) = crate::todo::load_todos(&session_id) else {
return;
};
let lanes = dispatchable_lanes(&todos);
if lanes.is_empty() {
return;
}
// dispatch re-inserts the session when the cap still holds; that
// insert starts a fresh retry, so this one ends.
dispatch(session_id.clone(), lanes).await;
});
}

Expand Down
18 changes: 16 additions & 2 deletions crates/jcode-app-core/src/server/reload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,25 @@ mod inherited_serve_flags_tests {
#[test]
fn keeps_provider_and_model_before_serve_only() {
assert_eq!(
inherited_serve_flags(args(&["jcode", "--provider", "jcode", "serve", "--socket", "/x"])),
inherited_serve_flags(args(&[
"jcode",
"--provider",
"jcode",
"serve",
"--socket",
"/x"
])),
args(&["--provider", "jcode"])
);
assert_eq!(
inherited_serve_flags(args(&["jcode", "--no-update", "--model=x", "serve", "--provider", "y"])),
inherited_serve_flags(args(&[
"jcode",
"--no-update",
"--model=x",
"serve",
"--provider",
"y"
])),
args(&["--model=x"])
);
assert!(inherited_serve_flags(args(&["jcode", "serve"])).is_empty());
Expand Down
15 changes: 12 additions & 3 deletions crates/jcode-app-core/src/server/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ pub(crate) const MANAGED_CLOUD_LAUNCHER: &str = "/opt/jcode-cloud/jcode";
const MANAGED_CLOUD_RELEASES: &str = "/opt/jcode-cloud/releases/";

fn managed_cloud_reload_target(running: Option<&Path>) -> Option<(PathBuf, &'static str)> {
managed_cloud_reload_target_in(running, Path::new(MANAGED_CLOUD_LAUNCHER), MANAGED_CLOUD_RELEASES)
managed_cloud_reload_target_in(
running,
Path::new(MANAGED_CLOUD_LAUNCHER),
MANAGED_CLOUD_RELEASES,
)
}

fn managed_cloud_reload_target_in(
Expand Down Expand Up @@ -384,8 +388,13 @@ mod managed_cloud_target_tests {
let launcher = dir.path().join("jcode");
std::fs::write(&launcher, b"bin").unwrap();
let local = dir.path().join("builds/versions/x/jcode");
assert!(managed_cloud_reload_target_in(Some(&local), &launcher, "/opt/jcode-cloud/releases/").is_none());
assert!(managed_cloud_reload_target_in(None, &launcher, "/opt/jcode-cloud/releases/").is_none());
assert!(
managed_cloud_reload_target_in(Some(&local), &launcher, "/opt/jcode-cloud/releases/")
.is_none()
);
assert!(
managed_cloud_reload_target_in(None, &launcher, "/opt/jcode-cloud/releases/").is_none()
);
}
}

Expand Down
1 change: 0 additions & 1 deletion crates/jcode-app-core/src/tool/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,6 @@ impl BashTool {
}
let mut child = command.spawn()?;

let child_pid = child.id().unwrap_or(0);
let stdin_handle = child.stdin.take();
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
Expand Down
3 changes: 2 additions & 1 deletion crates/jcode-app-core/src/tool/bash_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ async fn test_stdin_reader_gets_eof_immediately() {

// Commands are non-interactive: a stdin reader sees EOF right away, and no
// input request is ever raised.
let input = json!({"command": "if read -r line; then echo got; else echo eof_ok; fi", "timeout": 5000});
let input =
json!({"command": "if read -r line; then echo got; else echo eof_ok; fi", "timeout": 5000});
let ctx = make_ctx(Some(tx));
let started = std::time::Instant::now();
let result = tool.execute(input, ctx).await.unwrap();
Expand Down
2 changes: 1 addition & 1 deletion crates/jcode-app-core/src/tool/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ impl Tool for BrowserTool {

fn parameters_schema(&self) -> Value {
let mut properties = Map::new();
properties.insert("intent".into(), json!({"type": "string"}));
properties.insert("intent".into(), crate::tool::intent_schema_property());
properties.insert(
"action".into(),
json!({
Expand Down
8 changes: 6 additions & 2 deletions crates/jcode-app-core/src/tool/browser_session_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ async fn browser_session_stalled_startup_keeps_healthy_calls_and_workers_respons
let _ = release_rx.recv_timeout(Duration::from_secs(3));
std::fs::write(release_path, "ready").unwrap();
});
let started = Instant::now();
let _startup_spawn = Instant::now();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(5));
let mut browser_calls = Vec::new();
let mut bash_calls = Vec::new();
Expand All @@ -234,10 +234,14 @@ async fn browser_session_stalled_startup_keeps_healthy_calls_and_workers_respons
.starts()
.iter()
.any(|(name, _)| name.contains("blocked"))
&& started.elapsed() < Duration::from_secs(2)
&& _startup_spawn.elapsed() < Duration::from_secs(2)
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
// Responsiveness is measured from the moment a stalled startup is
// confirmed: daemon process spawn cost on a loaded host must not count
// against the healthy-call budget.
let started = Instant::now();
let healthy = tokio::spawn(async move {
firefox_run_bridge_command("getActiveTab", json!({}), Some("chrome"), &healthy_ctx).await
});
Expand Down
5 changes: 5 additions & 0 deletions crates/jcode-app-core/src/tool/communicate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,17 @@ async fn fetch_swarm_members(session_id: &str) -> Result<Vec<AgentInfo>> {
}
}

// Test-only coordination predicates: the auto-dispatch rework (92ca1819a)
// inlined their production use, the unit tests keep them pinned.
#[cfg_attr(not(test), allow(dead_code))]
fn swarm_member_is_in_flight(member: &AgentInfo) -> bool {
matches!(
member.status.as_deref(),
Some("queued" | "running" | "running_stale")
)
}

#[cfg_attr(not(test), allow(dead_code))]
fn coordination_in_flight_count(
summary: &PlanGraphStatus,
members: &[AgentInfo],
Expand All @@ -136,6 +140,7 @@ fn coordination_in_flight_count(
/// assignment (and therefore one it is safe to await): a spawned headless worker,
/// or one owned by the coordinator that issued the run. Foreign client-attached
/// sessions are not drivable and must not count toward its in-flight workers.
#[cfg_attr(not(test), allow(dead_code))]
fn swarm_member_is_drivable_worker(member: &AgentInfo, coordinator_session_id: &str) -> bool {
member.is_headless.unwrap_or(false)
|| member.report_back_to_session_id.as_deref() == Some(coordinator_session_id)
Expand Down
7 changes: 6 additions & 1 deletion crates/jcode-app-core/src/tool/compile_remote/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,12 @@ mod tests {
use std::os::unix::ffi::OsStrExt;
let repo = repo().await;
let name = std::ffi::OsStr::from_bytes(b"non-utf8-\xff");
std::fs::write(repo.path().join(name), b"contents").unwrap();
// APFS and other UTF-8 filesystems refuse to create invalid-UTF-8
// names at all, so the upload path never sees one. The rejection
// under test is only observable on byte-transparent filesystems.
if std::fs::write(repo.path().join(name), b"contents").is_err() {
return;
}
assert!(
snapshot(repo.path())
.await
Expand Down
Loading
Loading