fix: preserve tool classification and accurate Workdir failure stages

This commit is contained in:
2026-10-09 22:55:53 +09:00
parent f9d70e2ae2
commit 10c55874e3
5 changed files with 259 additions and 17 deletions
+91 -10
View File
@@ -38,17 +38,28 @@ pub enum ToolsError {
impl From<ToolsError> for ToolError {
fn from(err: ToolsError) -> Self {
match &err {
ToolsError::WorkdirSession(
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_),
) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::WorkdirSession(error) => {
// Diagnostic context is transparent to the established normal-tool
// classification. Preserve the original display, including unknown
// outcome guidance, while classifying through nested wrappers.
let mut classification = error;
while let workdir::WorkdirError::DenialContext { source, .. } = classification {
classification = source.as_ref();
}
match classification {
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_) => {
ToolError::ExecutionFailed(err.to_string())
}
_ => ToolError::InvalidArgument(err.to_string()),
}
}
ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_)
| ToolsError::NotRead(_)
| ToolsError::ExternallyModified(_)
| ToolsError::StringNotFound { .. }
@@ -134,6 +145,76 @@ mod tests {
}
}
#[test]
fn transport_diagnostic_reasons_preserve_normal_tool_classification_and_display() {
use WorkdirTransportErrorCode as Code;
use workdir::{WorkdirDenialReason as Reason, WorkdirError};
for (code, execution_failure) in [
(Code::NotFound, true),
(Code::Conflict, true),
(Code::OutcomeUnknown, true),
(Code::Unsupported, false),
(Code::InvalidRequest, false),
(Code::Denied, false),
(Code::OutOfScope, false),
(Code::SymlinkOutOfScope, false),
(Code::BrokenSymlink, false),
(Code::SymlinkTargetIsDirectory, false),
(Code::ReadOnly, false),
(Code::IsDirectory, false),
(Code::SymlinkDirectoryNotTraversed, false),
(Code::UnknownCommand, false),
(Code::Unavailable, true),
(Code::Io, true),
(Code::Transport, true),
(Code::Internal, true),
] {
// Wire data may carry a reason independently of its public code.
// Neither enrichment nor nested wrappers may change normal-tool behavior.
let baseline_message = serde_json::from_value::<WorkdirTransportError>(
serde_json::json!({"code": code, "message": "safe fixture message"}),
)
.unwrap()
.into_workdir_error()
.to_string();
for depth in [0, 1, 2] {
let wire = serde_json::json!({
"code": code,
"message": "safe fixture message",
"denial_reason": (depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
let expected_message = error.to_string();
assert_eq!(
expected_message, baseline_message,
"{code:?}, depth {depth}"
);
let tool_error = ToolError::from(ToolsError::from(error));
let (actual_execution_failure, actual_message) = match tool_error {
ToolError::ExecutionFailed(message) => (true, message),
ToolError::InvalidArgument(message) => (false, message),
other => panic!("unexpected classification for {code:?}: {other:?}"),
};
assert_eq!(
actual_execution_failure, execution_failure,
"{code:?}, depth {depth}"
);
assert_eq!(actual_message, expected_message, "{code:?}, depth {depth}");
if code == Code::OutcomeUnknown {
assert!(actual_message.contains("inspect before retrying"));
}
}
}
}
#[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error(