bxcore, brokerd, loopd: an invalid-arguments denial says why

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent 89a50bc3c6
commit de60c9e13a
21 changed files with 520 additions and 33 deletions
+8 -1
View File
@@ -48,8 +48,15 @@ fn every_denial_and_a_failure_come_back_as_they_were_sent() {
DenyReason::StateUnreadable,
]
.into_iter()
.map(|reason| ToolResponse::Denied { reason })
.map(|reason| ToolResponse::Denied {
reason,
detail: None,
})
.collect();
answers.push(ToolResponse::Denied {
reason: DenyReason::InvalidArguments,
detail: Some("invalid path: n/a".to_string()),
});
answers.push(ToolResponse::Failed {
message: "the runner arrives in M3b".to_string(),
});
+30 -2
View File
@@ -5,7 +5,8 @@ mod support;
use bxcore::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse};
use loopd::tools::{
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch,
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_message, denial_text,
dispatch,
};
fn req(tool: &str, arguments: &str) -> ToolRequest {
@@ -252,9 +253,35 @@ fn every_deny_reason_has_its_sentence() {
];
for (reason, want) in table {
assert_eq!(denial_text(reason), want, "{reason:?}");
assert_eq!(
denial_message(reason, None),
want,
"{reason:?}: without a detail, the sentence alone"
);
}
}
/// The broker's detail goes after the sentence, before its full stop.
#[test]
fn a_denial_with_a_detail_appends_it_to_the_sentence() {
assert_eq!(
denial_message(
DenyReason::InvalidArguments,
Some("invalid argument shape: unknown field `file`, expected `text`")
),
"Denied: the arguments are not valid for this tool: invalid argument shape: unknown field `file`, expected `text`."
);
assert_eq!(
denial_message(DenyReason::InvalidArguments, Some("invalid path: n/a")),
"Denied: the arguments are not valid for this tool: invalid path: n/a."
);
assert_eq!(
denial_message(DenyReason::NoGrant, Some("x")),
"Denied: no grant allows this call: x.",
"any reason's detail is shown the same way"
);
}
#[test]
fn results_are_cut_on_a_character_boundary_and_marked() {
assert_eq!(cap_result("short", 100), ("short".to_string(), false));
@@ -296,7 +323,8 @@ fn fake_tools_answer_echo_deny_the_rest_and_record_calls() {
assert_eq!(
fake.call(&req(tool, "{}"), &mut on_pending),
ToolResponse::Denied {
reason: DenyReason::NoGrant
reason: DenyReason::NoGrant,
detail: None,
},
"{tool}: the clock is loopd's own now, not the port's"
);
+47
View File
@@ -31,6 +31,7 @@ fn tool_events(events: &[TurnEvent]) -> Vec<String> {
fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::TaintTooHigh,
detail: None,
}]);
s.server.route(
CHAT,
@@ -78,10 +79,55 @@ fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() {
);
}
/// The broker's detail reaches the model, in the log and in the next request; the owner's event
/// is the same as for any denial.
#[test]
fn an_invalid_call_tells_the_model_why() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::InvalidArguments,
detail: Some("invalid path: n/a".to_string()),
}]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok(), "{result:?}");
let want = "Denied: the arguments are not valid for this tool: invalid path: n/a.";
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
truncated,
..
} => {
assert_eq!(content, want);
assert_eq!(
(*class, *untrusted, *truncated),
(DataClass::Public, false, false)
);
}
other => panic!("{other:?}"),
}
assert_eq!(
tool_events(&events),
[
"started read_file",
"denied read_file InvalidArguments",
"result read_file"
]
);
let m2 = s.server.requests_to(CHAT)[1].json();
assert_eq!(m2["messages"][3]["content"], want);
}
#[test]
fn a_denied_call_tool_names_the_target_tool_in_the_denial() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::NoGrant,
detail: None,
}]);
s.server.route(
CHAT,
@@ -158,6 +204,7 @@ fn a_pending_approval_that_ends_in_a_refusal() {
pending(7, "2026-09-18T12:15:00.000Z"),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused,
detail: None,
},
]);
s.server.route(