Skip to content
5 changes: 5 additions & 0 deletions crates/tinyagents-harness/src/providers/claude_code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ transcripts. `cwd` is `project_dir` — the caller's
project root, not this provider's own `workspace_dir` — so CC's file tools
act on the user's code.

If a resumed session fails with Claude's explicit `No conversation found with
session ID` diagnostic, the driver clears that thread mapping and retries once
as a new session with the full conversation history. Other CLI failures do not
invalidate the mapping or retry.

The thread key that selects a session comes from `thread_key_from_request`
in `mod.rs`: it reads `metadata.thread_id` / `conversation_id` / `session_id`
off the `ModelRequest`, then `continuation_id`, and falls back to a fresh
Expand Down
47 changes: 47 additions & 0 deletions crates/tinyagents-harness/src/providers/claude_code/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ fn parse_error_line_shape(line: &str) -> &'static str {
}
}

/// True only when `message` is, in its entirety, the CLI's missing-session
/// diagnostic for `session_id` (the id passed to `--resume`). A message that
/// merely quotes the phrase, names another session, or carries extra text is
/// not a confirmation that the saved session is gone.
fn is_missing_session_error(message: &str, session_id: &str) -> bool {
let message = message.trim();
let message = message.strip_prefix("Error: ").unwrap_or(message);
message == format!("No conversation found with session ID: {session_id}")
}

fn parse_error_log_line(ev: &ClaudeCodeEvent) -> Option<String> {
let ClaudeCodeEvent::ParseError { line, reason } = ev else {
return None;
Expand Down Expand Up @@ -676,6 +686,14 @@ pub(crate) async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatRespons
let stderr_text = stderr_task.await.unwrap_or_default();

if !status.success() {
let missing = is_missing_session_error(&stderr_text, &cc_session_id)
|| mapper
.error
.as_deref()
.is_some_and(|e| is_missing_session_error(e, &cc_session_id));
if !is_new && missing && clear_missing_session(&ctx, &cc_session_id) {
Comment thread
senamakel marked this conversation as resolved.
return Box::pin(run_turn(ctx)).await;
}
// The CLI reports most failures (bad auth, unknown model, invalid
// session) as a structured `result`/`error` event on stdout and often
// leaves stderr empty. Prefer that message; fall back to stderr only
Expand All @@ -691,6 +709,12 @@ pub(crate) async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatRespons
);
}
if let Some(err) = mapper.error.clone() {
if !is_new
&& is_missing_session_error(&err, &cc_session_id)
&& clear_missing_session(&ctx, &cc_session_id)
{
return Box::pin(run_turn(ctx)).await;
}
anyhow::bail!(
"[claude-code][driver] {}",
sanitize_cli_message(&err, ctx.anthropic_api_key.as_deref())
Expand All @@ -715,6 +739,29 @@ pub(crate) async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatRespons
Ok(mapper.into_response())
}

/// Drop the saved mapping that `--resume` just proved missing, but only if it
/// still points at `failed_session_id`. Returns true when the caller may retry
/// as a new session with the full history; false means this call did not remove
/// the mapping (write failed, or a concurrent turn already replaced it)
/// and the original failure must be surfaced.
fn clear_missing_session(ctx: &TurnContext<'_>, failed_session_id: &str) -> bool {
tracing::warn!(
"[claude-code][driver] saved session is missing; clearing mapping and retrying with full history"
);
match ctx
.session_store
.remove_if(&ctx.thread_id, failed_session_id)
{
Ok(removed) => removed,
Err(error) => {
tracing::warn!(
"[claude-code][driver] failed to clear missing session mapping: {error}"
);
false
}
}
}

/// Select durable Claude session arguments only for requests that have an
/// explicit conversation identity. Background one-shot inference calls use
/// the CLI's persistence opt-out and cannot be discovered as source sessions.
Expand Down
132 changes: 132 additions & 0 deletions crates/tinyagents-harness/src/providers/claude_code/driver_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,100 @@

use super::*;

#[test]
fn missing_session_error_is_recognized_precisely() {
Comment thread
senamakel marked this conversation as resolved.
let msg = "No conversation found with session ID: 1234";
assert!(is_missing_session_error(msg, "1234"));
assert!(is_missing_session_error(&format!("Error: {msg}\n"), "1234"));
// Different session, quoted phrase, or extra text must not match.
assert!(!is_missing_session_error(msg, "9999"));
assert!(!is_missing_session_error(
&format!("tool stderr: {msg}"),
"1234"
));
assert!(!is_missing_session_error(&format!("{msg}\nboom"), "1234"));
assert!(!is_missing_session_error("authentication_failed", "1234"));
}

#[cfg(unix)]
mod retry {
use super::*;
use std::os::unix::fs::PermissionsExt;

/// Fake `claude`: logs its args, fails `--resume` with `$FAIL_MSG` on
/// stderr, and answers `--session-id` runs with a result event.
fn fake_cli(dir: &std::path::Path, fail_msg: &str) -> PathBuf {
let bin = dir.join("claude");
let script = format!(
"#!/bin/sh\ncat >/dev/null\necho \"$@\" >> '{log}'\n\
case \"$*\" in *--resume*) echo '{fail_msg}' >&2; exit 1;; esac\n\
echo '{{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"ok\"}}'\n",
log = dir.join("calls.log").display(),
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}

async fn drive(
dir: &std::path::Path,
store: Arc<SessionStore>,
bin: PathBuf,
) -> anyhow::Result<ChatResponse> {
let messages = vec![ChatMessage::user("hi")];
run_turn(TurnContext {
bin_path: bin,
workspace_dir: dir.to_path_buf(),
project_dir: dir.join("project"),
thread_id: "t1".into(),
persist_session: true,
model: "m".into(),
append_system_prompt: None,
messages: &messages,
session_store: store,
stream: None,
anthropic_api_key: None,
mcp_provider: None,
})
.await
}

const SAVED: &str = "11111111-1111-4111-8111-111111111111";

#[tokio::test]
async fn missing_resumed_session_clears_mapping_and_retries_as_new() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(SessionStore::open(dir.path()));
store.set("t1", SAVED).unwrap();
let bin = fake_cli(
dir.path(),
&format!("No conversation found with session ID: {SAVED}"),
);
drive(dir.path(), store.clone(), bin)
.await
.expect("retry succeeds");
let log = std::fs::read_to_string(dir.path().join("calls.log")).unwrap();
let lines: Vec<&str> = log.lines().collect();
assert_eq!(lines.len(), 2, "one resume then one retry: {log}");
assert!(lines[0].contains("--resume"));
assert!(lines[1].contains("--session-id") && !lines[1].contains("--resume"));
let new_id = store.get("t1").expect("new mapping persisted");
assert_ne!(new_id, SAVED);
}

#[tokio::test]
async fn unrelated_failure_keeps_mapping_and_does_not_retry() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(SessionStore::open(dir.path()));
store.set("t1", SAVED).unwrap();
let bin = fake_cli(dir.path(), "authentication_failed");
assert!(drive(dir.path(), store.clone(), bin).await.is_err());
let log = std::fs::read_to_string(dir.path().join("calls.log")).unwrap();
assert_eq!(log.lines().count(), 1);
assert_eq!(store.get("t1").as_deref(), Some(SAVED));
}
}

#[test]
fn write_mcp_http_config_emits_http_url_with_bearer_header() {
let dir = tempfile::tempdir().expect("tempdir");
Expand Down Expand Up @@ -513,3 +607,41 @@ async fn nonzero_exit_surfaces_object_error_message_sanitized() {
"{text}"
);
}

#[cfg(unix)]
mod structured_exit {
use super::*;
use std::os::unix::fs::PermissionsExt;

#[tokio::test]
async fn nonzero_exit_with_structured_missing_session_error_retries() {
let dir = tempfile::tempdir().unwrap();
let saved = "11111111-1111-4111-8111-111111111111";
let store = Arc::new(SessionStore::open(dir.path()));
store.set("t1", saved).unwrap();
let bin = dir.path().join("claude");
let script = format!(
"#!/bin/sh\ncat >/dev/null\ncase \"$*\" in *--resume*) echo '{{\"type\":\"result\",\"subtype\":\"error_during_execution\",\"errors\":[\"No conversation found with session ID: {saved}\"]}}'; exit 1;; esac\necho '{{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"ok\"}}'\n"
);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let messages = vec![ChatMessage::user("hi")];
run_turn(TurnContext {
bin_path: bin,
workspace_dir: dir.path().to_path_buf(),
project_dir: dir.path().join("project"),
thread_id: "t1".into(),
persist_session: true,
model: "m".into(),
append_system_prompt: None,
messages: &messages,
session_store: store.clone(),
stream: None,
anthropic_api_key: None,
mcp_provider: None,
})
.await
.expect("retry succeeds");
assert_ne!(store.get("t1").as_deref(), Some(saved));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,33 @@ impl EventMapper {
usage.charged_amount_usd = cost;
}
self.usage = parsed;
// A failed turn is reported as `subtype=error` or as
// `is_error=true` with the human-readable reason in `result`.
// A failed turn is reported as `subtype=error`, as
// `is_error=true` with the reason in `result`, or (newer CLIs)
// as `subtype: "error_*"` with diagnostics in `errors`.
// Surface the structured diagnostics so the driver can
// classify them (e.g. a missing resumed session).
let structured: Vec<&str> = raw
.get("errors")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let failed = subtype.as_deref() == Some("error")
|| raw.get("is_error").and_then(Value::as_bool) == Some(true);
|| raw.get("is_error").and_then(Value::as_bool) == Some(true)
|| (subtype.as_deref().is_some_and(|s| s.starts_with("error_"))
&& !structured.is_empty());
Comment thread
senamakel marked this conversation as resolved.
if failed && self.error.is_none() {
let reason = raw
.get("result")
.and_then(Value::as_str)
.map(str::trim)
.filter(|reason| !reason.is_empty());
self.error = Some(match reason {
Some(reason) => reason.to_string(),
None => "claude reported `result.subtype=error`".into(),
self.error = Some(if !structured.is_empty() {
structured.join("\n")
} else {
match reason {
Some(reason) => reason.to_string(),
None => "claude reported `result.subtype=error`".into(),
}
});
}
self.finished = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,33 @@ fn final_assistant_message_is_skipped() {
assert!(deltas.is_empty());
}

#[test]
fn structured_result_errors_surface_as_mapper_error() {
let mut m = EventMapper::new();
m.handle(ClaudeCodeEvent::Result {
subtype: Some("error_during_execution".into()),
usage: None,
total_cost_usd: None,
raw: json!({"errors": ["No conversation found with session ID: abc"]}),
});
assert_eq!(
m.error.as_deref(),
Some("No conversation found with session ID: abc")
);
}

#[test]
fn error_subtype_without_errors_array_is_not_a_failure_unless_plain_error() {
let mut m = EventMapper::new();
m.handle(ClaudeCodeEvent::Result {
subtype: Some("error_max_turns".into()),
usage: None,
total_cost_usd: None,
raw: Value::Null,
});
assert!(m.error.is_none());
}

#[test]
fn is_error_result_carries_its_reason_as_the_error() {
let mut m = EventMapper::new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ impl SessionStore {
}
std::fs::write(&self.path, serialized)
}

/// Forget a thread's session mapping, but only while it still maps to
/// `expected_uuid` (compare-and-remove under the store lock), so a newer
/// mapping written by a concurrent turn is never clobbered. The removal is
/// persisted first; if that fails the in-memory mapping is left intact.
/// Returns whether a mapping was removed.
pub fn remove_if(&self, thread_id: &str, expected_uuid: &str) -> std::io::Result<bool> {
let mut guard = self.inner.lock().expect("session store mutex poisoned");
Comment thread
senamakel marked this conversation as resolved.
if guard.sessions.get(thread_id).map(String::as_str) != Some(expected_uuid) {
return Ok(false);
}
let mut staged = StoreFile {
sessions: guard.sessions.clone(),
};
staged.sessions.remove(thread_id);
let serialized = serde_json::to_string_pretty(&staged).map_err(std::io::Error::other)?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, serialized)?;
*guard = staged;
Ok(true)
}
}

/// Random RFC-4122 v4 UUID, formatted lower-case with hyphens.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,39 @@ fn roundtrip_set_and_get() {
let reopened = SessionStore::open(dir.path());
assert_eq!(reopened.get("thread_a").as_deref(), Some("abc"));
}

#[test]
fn remove_if_forgets_matching_mapping_across_reopen() {
let dir = tempdir().unwrap();
let store = SessionStore::open(dir.path());
store.set("thread_a", "session-a").unwrap();

assert!(store.remove_if("thread_a", "session-a").unwrap());

assert!(store.get("thread_a").is_none());
assert!(SessionStore::open(dir.path()).get("thread_a").is_none());
}

#[test]
fn remove_if_keeps_a_newer_mapping() {
Comment thread
senamakel marked this conversation as resolved.
let dir = tempdir().unwrap();
let store = SessionStore::open(dir.path());
store.set("thread_a", "session-new").unwrap();

assert!(!store.remove_if("thread_a", "session-old").unwrap());
assert_eq!(store.get("thread_a").as_deref(), Some("session-new"));
}

#[test]
fn remove_if_keeps_mapping_when_persistence_fails() {
let dir = tempdir().unwrap();
let store = SessionStore::open(dir.path());
store.set("thread_a", "session-a").unwrap();
// Make the store file unwritable by replacing it with a directory.
let path = dir.path().join("claude-code-sessions.json");
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();

assert!(store.remove_if("thread_a", "session-a").is_err());
assert_eq!(store.get("thread_a").as_deref(), Some("session-a"));
}
Loading
Loading