From d6288ecb8559bccd8d41b185278ebac3a855785c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:09:52 +0800 Subject: [PATCH] fix(agent): report Claude Code turns that fail instead of ending them as ready Prompts are sent with the origin `human` and their results echo it, but `belongs` treated any result with an origin as another turn's. The result was ignored and the turn ended on `command_lifecycle`, which dropped the failure the turn had recorded. A signed-out Claude Code showed an empty reply and a Ready task, with no error. Only origins other than `human` now mark a result as foreign, so failures, usage limits and the `max_tokens` and `refusal` stop reasons reach the task. A turn ended by `command_lifecycle` also keeps its recorded failure. --- crates/ide-agent/src/native/claude/mod.rs | 9 ++++- crates/ide-agent/src/native/claude/tests.rs | 45 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index 8fd87ad82..f4f27e1c6 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -915,7 +915,11 @@ impl Session { } else { "end_turn" }; - finished = st.turn.take().map(|t| (t, TurnEnd::Stop(stop))); + finished = st.turn.take().map(|mut t| { + // Claude Code reports a turn that failed as cancelled here. + let failure = t.failure.take().filter(|_| !t.cancelled); + (t, failure.unwrap_or(TurnEnd::Stop(stop))) + }); } } "assistant" if msg.get("parent_tool_use_id").is_none_or(Value::is_null) => { @@ -1139,7 +1143,8 @@ where /// Whether `result` ends `turn`: it answers the turn's last message, or (stopped) any of /// its messages, or names none (the CLI's own error before the turn began). fn belongs(turn: &Turn, result: &Value) -> bool { - if result.get("origin").is_some_and(|o| !o.is_null()) { + // Prompts are sent with the origin `human`, and their results echo it. + if result.get("origin").is_some_and(|o| !o.is_null() && o.get("kind").and_then(Value::as_str) != Some("human")) { return false; } let mut uuids = strings(result.get("user_message_uuids")); diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 5e52741f0..b187e848a 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -738,6 +738,50 @@ async fn a_stop_ahead_of_its_prompt_cancels_it() { assert!(cli.sent.lock()[0].iter().all(|m| m["type"] != "user"), "nothing was sent"); } +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_turn_reports_its_failure() { + let text = "Failed to authenticate: OAuth session expired and could not be refreshed"; + let lifecycle = |uuid: &str, state: &str| { + Frame::Cli(json!({ "type": "command_lifecycle", "command_uuid": uuid, "state": state })) + }; + let result = |uuid: &str| { + Frame::Cli(json!({ + "type": "result", "subtype": "success", "is_error": true, "result": text, "stop_reason": "stop_sequence", + "terminal_reason": "api_error", "origin": { "kind": "human" }, "user_message_uuids": [uuid], + })) + }; + // As Claude Code sends it, and with the turn's end ahead of its result. + for lifecycle_first in [false, true] { + let mut process = load_fixture("simple").remove(0); + process.truncate(6); + let Some(Frame::Shim(prompt)) = process.last() else { panic!("the fixture's prompt") }; + let uuid = prompt["uuid"].as_str().unwrap().to_owned(); + process.push(Frame::Cli(json!({ + "type": "assistant", "error": "authentication_failed", "parent_tool_use_id": null, + "message": { "id": "m", "model": "", "content": [{ "type": "text", "text": text }] }, + }))); + if lifecycle_first { + process.extend([lifecycle(&uuid, "cancelled"), result(&uuid)]); + } else { + process.extend([result(&uuid), lifecycle(&uuid, "cancelled")]); + } + let cli = Arc::new(Replay { + processes: Mutex::new(VecDeque::from([process])), + sent: Arc::default(), + launched: Mutex::default(), + errors: Arc::default(), + }); + let (dir, cwd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let h = + Harness::connect(cli.clone(), dir.path().into(), dir.path().join("p"), cwd.path().into(), Vec::new()).await; + let s = h.new_session().await; + let Err(RpcError::Server { message, .. }) = h.prompt(&s, "hi").await else { + panic!("the turn should fail (lifecycle first: {lifecycle_first})"); + }; + assert_eq!(message, format!("Claude Code authentication failed: {text}")); + } +} + /// Fork and undo against the real CLI, across connections, in a symlinked directory /// (macOS `/tmp`). #[tokio::test(flavor = "multi_thread")] @@ -829,6 +873,7 @@ fn which_result_ends_a_turn() { assert!(!belongs(&t, &json!({"user_message_uuids": ["a"]})), "a steer superseded it"); assert!(belongs(&t, &json!({"subtype": "error_during_execution"})), "names no message"); assert!(!belongs(&t, &json!({"origin": {"kind": "task-notification"}})), "Claude Code's own turn"); + assert!(belongs(&t, &json!({"origin": {"kind": "human"}, "user_message_uuids": ["b"]})), "the prompt's own result"); assert!(belongs(&turn(&["a", "b"], true), &json!({"user_message_uuids": ["a"]}))); }