Compare commits

..
Author SHA1 Message Date
ZakiandClaude b50f80cbda fix(agent): clarify hydration return type and pruned-thread error message (#1487)
Introduce HydrationResult enum (Ready/Skipped/NotFound) to disambiguate
the return contract of maybe_hydrate_thread — callers can now distinguish
between a fully hydrated thread and one where hydration was skipped.

Update the error message when a thread disappears during approval to
acknowledge that actions may have partially executed, rather than
suggesting a simple retry.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 02:11:34 +00:00
Claude fd6e1d8b1f fix(agent): match on Result return type from maybe_hydrate_thread
The maybe_hydrate_thread signature changed from Option<String> to
Result<Option<Uuid>, String> but the caller still pattern-matched
with Some(), causing a type mismatch clippy/compile error. Switch
to Err() to match the new error-variant semantics.

[skip-regression-check]

https://claude.ai/code/session_013ZCQWoFHv2hASgHEGHzptg
2026-03-23 02:11:34 +00:00
ZakiandClaude f7fbbc229b fix(agent): surface errors when approval thread disappears (#1487)
Replace silent `if let Some` fallbacks with explicit `match` arms that
log and return errors when a thread is missing from the session during
approval storage or rejection persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 02:11:34 +00:00
4 changed files with 184 additions and 49 deletions
+7 -2
View File
@@ -1010,8 +1010,13 @@ impl Agent {
thread_id = %external_thread_id,
"Hydrating thread from DB"
);
if let Some(rejection) = self.maybe_hydrate_thread(message, external_thread_id).await {
return Ok(Some(format!("Error: {}", rejection)));
match self.maybe_hydrate_thread(message, external_thread_id).await {
Err(rejection) => {
return Ok(Some(format!("Error: {}", rejection)));
}
Ok(_) => {
// Ready, Skipped, or NotFound — all proceed to resolve_thread
}
}
}
+163 -23
View File
@@ -25,6 +25,25 @@ use crate::tools::redact_params;
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
/// Result of attempting to hydrate a thread from the database.
///
/// Distinguishes between a thread that was fully hydrated into the session
/// (messages loaded, thread registered) and one where we recognised the UUID
/// but skipped hydration (e.g. already present in memory, or ownership could
/// not be verified on a non-gateway channel).
#[derive(Debug)]
#[allow(dead_code)] // Inner UUIDs are part of the API contract for future callers
pub(super) enum HydrationResult {
/// Thread hydrated and available in `sess.threads` / `thread_map`.
Ready(Uuid),
/// UUID is known but hydration was intentionally skipped. The thread may
/// already be in memory, or the caller is on a channel that does not
/// require pre-existing threads so we fall through to `resolve_thread`.
Skipped(Uuid),
/// The external thread ID was not a valid UUID — nothing to hydrate.
NotFound,
}
fn requires_preexisting_uuid_thread(channel: &str) -> bool {
// Gateway-style channels send server-issued conversation UUIDs.
// Unknown UUIDs should be rejected instead of silently creating a new thread.
@@ -41,15 +60,24 @@ impl Agent {
/// even when the conversation has zero messages (e.g. a brand-new
/// assistant thread). Without this, `resolve_thread` would mint a
/// fresh UUID and all messages would land in the wrong conversation.
///
/// Returns [`HydrationResult::Ready`] when the thread was fully loaded
/// into the session, [`HydrationResult::Skipped`] when the UUID was
/// recognised but hydration was not performed (already in memory, or
/// ownership unverifiable on a non-gateway channel), and
/// [`HydrationResult::NotFound`] when the external ID is not a UUID.
///
/// Returns `Err` only for hard rejections (forged / unauthorised thread
/// ID on a gateway channel).
pub(super) async fn maybe_hydrate_thread(
&self,
message: &IncomingMessage,
external_thread_id: &str,
) -> Option<String> {
) -> Result<HydrationResult, String> {
// Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs)
let thread_uuid = match Uuid::parse_str(external_thread_id) {
Ok(id) => id,
Err(_) => return None,
Err(_) => return Ok(HydrationResult::NotFound),
};
// Check if already in memory
@@ -60,7 +88,7 @@ impl Agent {
{
let sess = session.lock().await;
if sess.threads.contains_key(&thread_uuid) {
return None;
return Ok(HydrationResult::Skipped(thread_uuid));
}
}
@@ -83,9 +111,9 @@ impl Agent {
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
return Err(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
return Ok(HydrationResult::Skipped(thread_uuid));
}
};
if !owned {
@@ -99,9 +127,9 @@ impl Agent {
e
);
if requires_preexisting_uuid_thread(&message.channel) {
return Some(FORGED_THREAD_ID_ERROR.to_string());
return Err(FORGED_THREAD_ID_ERROR.to_string());
}
return None;
return Ok(HydrationResult::Skipped(thread_uuid));
}
};
@@ -113,7 +141,7 @@ impl Agent {
exists,
"Rejected message for unavailable thread id"
);
return Some(FORGED_THREAD_ID_ERROR.to_string());
return Err(FORGED_THREAD_ID_ERROR.to_string());
}
tracing::warn!(
@@ -122,7 +150,7 @@ impl Agent {
exists,
"Skipped hydration for thread id not owned by sender"
);
return None;
return Ok(HydrationResult::Skipped(thread_uuid));
}
let db_messages = store
@@ -169,7 +197,7 @@ impl Agent {
msg_count
);
None
Ok(HydrationResult::Ready(thread_uuid))
}
pub(super) async fn process_user_input(
@@ -1413,8 +1441,20 @@ impl Agent {
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.await_approval(new_pending);
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.await_approval(new_pending);
}
None => {
tracing::error!(
%thread_id,
tool = %tool_name,
"Thread disappeared while preparing approval request"
);
return Ok(SubmissionResult::error(
"The conversation thread was pruned during processing. Some actions may have already been executed. Please check results before retrying.",
));
}
}
}
@@ -1546,17 +1586,25 @@ impl Agent {
);
{
let mut sess = session.lock().await;
if let Some(thread) = sess.threads.get_mut(&thread_id) {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
match sess.threads.get_mut(&thread_id) {
Some(thread) => {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
// User message already persisted at turn start; save rejection response
self.persist_assistant_response(
thread_id,
&message.channel,
&message.user_id,
&rejection,
)
.await;
}
None => {
tracing::warn!(
%thread_id,
"Thread disappeared during approval rejection — rejection not persisted"
);
}
}
}
@@ -2204,6 +2252,98 @@ mod tests {
assert!(t.pending_messages.is_empty());
}
/// Regression test for #1487: when a thread disappears from the session during
/// approval storage, the code should return an error instead of silently losing
/// the approval.
#[test]
fn test_missing_thread_during_approval_storage_returns_error() {
use crate::agent::session::{PendingApproval, Session};
use uuid::Uuid;
let thread_id = Uuid::new_v4();
let session = Session::new("test-user");
// Thread does NOT exist in the session
assert!(!session.threads.contains_key(&thread_id));
// Simulate the match logic from process_approval when storing a new pending approval
let _new_pending = PendingApproval {
request_id: Uuid::new_v4(),
tool_name: "shell".to_string(),
parameters: serde_json::json!({"command": "echo test"}),
display_parameters: serde_json::json!({"command": "[REDACTED]"}),
description: "Execute command".to_string(),
tool_call_id: "call_0".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: false,
};
let tool_name = "shell";
// The fixed code uses match instead of if-let, returning an error for None
let result: Result<&str, String> = match session.threads.get(&thread_id) {
Some(_thread) => {
// Would call thread.await_approval(new_pending)
Ok("stored")
}
None => Err(format!(
"The conversation thread was pruned during processing. Some actions may have already been executed. Tool: {}",
tool_name,
)),
};
assert!(result.is_err(), "Missing thread should produce an error");
let err = result.unwrap_err();
assert!(
err.contains("pruned during processing"),
"Error should mention thread was pruned. Got: {}",
err
);
}
/// Regression test for #1487: when a thread disappears during rejection,
/// the rejection is not persisted but the code degrades gracefully (no panic,
/// no silent success pretending state was updated).
#[test]
fn test_missing_thread_during_rejection_degrades_gracefully() {
use crate::agent::session::Session;
use uuid::Uuid;
let thread_id = Uuid::new_v4();
let mut session = Session::new("test-user");
// Thread does NOT exist in the session
assert!(!session.threads.contains_key(&thread_id));
let rejection = format!(
"Tool '{}' was rejected. The agent will not execute this tool.",
"shell"
);
// The fixed code uses match instead of if-let, logging a warning for None
let mut persisted = false;
match session.threads.get_mut(&thread_id) {
Some(thread) => {
thread.clear_pending_approval();
thread.complete_turn(&rejection);
persisted = true;
}
None => {
// In production this logs a warning -- we just verify it takes
// the None branch without panicking.
}
}
assert!(
!persisted,
"Rejection should NOT be persisted when thread is missing"
);
// Session should remain unchanged
assert!(session.threads.is_empty());
}
// Helper function to extract the approval message without needing a full Agent instance
fn extract_approval_message(
session: &crate::agent::session::Session,
+13 -23
View File
@@ -1,6 +1,6 @@
//! Custom tunnel via an arbitrary shell command.
use anyhow::{Context, Result, bail};
use anyhow::{Result, bail};
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;
@@ -27,7 +27,6 @@ pub struct CustomTunnel {
url_pattern: Option<String>,
proc: SharedProcess,
url: SharedUrl,
http_client: reqwest::Client,
}
impl CustomTunnel {
@@ -35,19 +34,14 @@ impl CustomTunnel {
start_command: String,
health_url: Option<String>,
url_pattern: Option<String>,
) -> Result<Self> {
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.context("failed to create HTTP client for tunnel health checks")?;
Ok(Self {
) -> Self {
Self {
start_command,
health_url,
url_pattern,
proc: new_shared_process(),
url: new_shared_url(),
http_client,
})
}
}
}
@@ -146,9 +140,9 @@ impl Tunnel for CustomTunnel {
async fn health_check(&self) -> bool {
if let Some(ref url) = self.health_url {
return self
.http_client
return reqwest::Client::new()
.get(url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.is_ok();
@@ -179,7 +173,7 @@ mod tests {
#[tokio::test]
async fn empty_command_returns_error() {
let tunnel = CustomTunnel::new(" ".into(), None, None).unwrap();
let tunnel = CustomTunnel::new(" ".into(), None, None);
let result = tunnel.start("127.0.0.1", 8080).await;
assert!(result.is_err());
assert!(
@@ -192,7 +186,7 @@ mod tests {
#[tokio::test]
async fn start_without_pattern_returns_local() {
let tunnel = CustomTunnel::new("sleep 1".into(), None, None).unwrap();
let tunnel = CustomTunnel::new("sleep 1".into(), None, None);
let url = tunnel.start("127.0.0.1", 4455).await.unwrap();
assert_eq!(url, "http://127.0.0.1:4455");
tunnel.stop().await.unwrap();
@@ -204,8 +198,7 @@ mod tests {
"echo https://public.example".into(),
None,
Some("public.example".into()),
)
.unwrap();
);
let url = tunnel.start("localhost", 9999).await.unwrap();
assert_eq!(url, "https://public.example");
tunnel.stop().await.unwrap();
@@ -220,8 +213,7 @@ mod tests {
r"printf http://internal:1234\nhttps://real.tunnel.io/abc\n".into(),
None,
Some("tunnel.io".into()),
)
.unwrap();
);
let url = tunnel.start("localhost", 9999).await.unwrap();
assert_eq!(url, "https://real.tunnel.io/abc");
tunnel.stop().await.unwrap();
@@ -233,8 +225,7 @@ mod tests {
"echo http://{host}:{port}".into(),
None,
Some("http://".into()),
)
.unwrap();
);
let url = tunnel.start("10.1.2.3", 4321).await.unwrap();
assert_eq!(url, "http://10.1.2.3:4321");
tunnel.stop().await.unwrap();
@@ -247,8 +238,7 @@ mod tests {
"sleep 1".into(),
Some("http://192.0.2.1:9999/healthz".into()),
None,
)
.unwrap();
);
assert!(
!tunnel.health_check().await,
"Health check should fail for unreachable URL"
@@ -281,7 +271,7 @@ mod tests {
// `yes` floods stdout indefinitely; without the drain task the pipe
// buffer fills (64 KB) and the child blocks on write(), becoming a
// zombie. With draining the child stays alive and stop() can kill it.
let tunnel = CustomTunnel::new("yes".into(), None, None).unwrap();
let tunnel = CustomTunnel::new("yes".into(), None, None);
let url = tunnel.start("127.0.0.1", 19999).await.unwrap();
assert_eq!(url, "http://127.0.0.1:19999");
+1 -1
View File
@@ -171,7 +171,7 @@ pub fn create_tunnel(config: &TunnelProviderConfig) -> Result<Option<Box<dyn Tun
cu.start_command.clone(),
cu.health_url.clone(),
cu.url_pattern.clone(),
)?)))
))))
}
other => bail!(