use agent_client_protocol_schema::v1::{PromptResponse, SessionId, StopReason};
use aion_integrations::jsonrpc::{JsonRpcError, JsonRpcResponse};
use aion_integrations::{AgentOutcome, HarnessError, Payload};
use crate::protocol;
use crate::translate;
const PARTIAL_TEXT_MESSAGE_BYTES: usize = 4000;
#[derive(Debug)]
pub(crate) enum PumpEnd {
Answered(Box<PromptOutcome>),
StreamEnded,
TransportFailed {
detail: String,
},
}
#[derive(Debug)]
pub struct PromptOutcome {
pub response: JsonRpcResponse,
pub text: String,
pub final_message: String,
}
impl PromptOutcome {
pub fn into_payload(self, session_id: &SessionId) -> Result<Payload, HarnessError> {
outcome_into_payload(self, session_id)
}
}
impl PumpEnd {
pub(crate) fn into_payload(self, session_id: &SessionId) -> Result<Payload, HarnessError> {
outcome_into_payload(self.into_outcome()?, session_id)
}
pub(crate) fn into_outcome(self) -> Result<PromptOutcome, HarnessError> {
match self {
Self::Answered(outcome) => Ok(*outcome),
Self::StreamEnded => Err(HarnessError::transport(
"the ACP agent closed its stdout while session/prompt was still outstanding — it \
exited without answering, so the turn has no terminal result (the agent's own \
diagnostics are on this process's stderr)",
)),
Self::TransportFailed { detail } => Err(HarnessError::transport(format!(
"the ACP agent's stdout failed while session/prompt was still outstanding, so the \
turn has no terminal result: {detail}"
))),
}
}
}
pub(crate) fn outcome_into_payload(
outcome: PromptOutcome,
session_id: &SessionId,
) -> Result<Payload, HarnessError> {
let PromptOutcome {
response,
text,
final_message,
} = outcome;
if let Some(error) = response.error {
return Err(prompt_error(&error, &text));
}
let Some(result) = response.result else {
return Err(HarnessError::protocol(
"the session/prompt response carried neither result nor error",
));
};
let prompt: PromptResponse = serde_json::from_value(result.clone()).map_err(|source| {
HarnessError::protocol(format!(
"the session/prompt result is not an ACP PromptResponse ({source}): {result}"
))
})?;
let label = translate::stop_reason_label(prompt.stop_reason);
if !translate::is_success(prompt.stop_reason) {
if matches!(prompt.stop_reason, StopReason::Refusal) {
return Err(policy_refusal(label, &text));
}
return Err(non_completion(prompt.stop_reason, label, &text));
}
AgentOutcome::new(
text,
final_message,
&translate::stop_kind(prompt.stop_reason),
session_id.to_string(),
)
.into_payload()
.map_err(|source| {
HarnessError::protocol(format!("the terminal payload is not encodable: {source}"))
})
}
fn policy_refusal(label: &str, text: &str) -> HarnessError {
let mut message = format!("the prompt turn was refused: stopReason: {label}");
append_partial(&mut message, text);
HarnessError::policy_refused(
message,
aion_integrations::PolicyRefusalDetection::Structured,
)
}
fn non_completion(reason: StopReason, label: &str, text: &str) -> HarnessError {
let mut message = if matches!(reason, StopReason::Cancelled) {
format!("{}: stopReason: {label}", protocol::CANCELLED_DETAIL_PREFIX)
} else {
format!("the prompt turn did not complete: stopReason: {label}")
};
append_partial(&mut message, text);
HarnessError::harness(message)
}
fn prompt_error(error: &JsonRpcError, text: &str) -> HarnessError {
let mut message = if error.code == protocol::auth_required_code() {
format!(
"the agent requires authentication before it will run (code {}): {} — an unattended \
activity cannot complete an interactive `authenticate` flow; configure the agent's \
credentials in the harness environment",
error.code, error.message
)
} else if error.code == protocol::request_cancelled_code() {
format!(
"{}: the agent answered session/prompt with code {} ({}) instead of \
stopReason `cancelled`",
protocol::CANCELLED_DETAIL_PREFIX,
error.code,
error.message
)
} else {
format!(
"session/prompt failed (code {}): {}",
error.code, error.message
)
};
append_partial(&mut message, text);
HarnessError::harness(message)
}
fn append_partial(message: &mut String, text: &str) {
if text.is_empty() {
return;
}
message.push_str("; partial output: ");
message.push_str(&bounded_for_message(text));
}
fn bounded_for_message(rendered: &str) -> String {
if rendered.len() <= PARTIAL_TEXT_MESSAGE_BYTES {
return rendered.to_owned();
}
let mut end = PARTIAL_TEXT_MESSAGE_BYTES;
while !rendered.is_char_boundary(end) {
end -= 1;
}
format!(
"{}… [partial output truncated for this message: {} of {} bytes shown]",
&rendered[..end],
end,
rendered.len()
)
}
#[cfg(test)]
mod tests {
use agent_client_protocol_schema::v1::SessionId;
use aion_integrations::jsonrpc::{JsonRpcError, JsonRpcId, JsonRpcResponse};
use serde_json::json;
use super::{PARTIAL_TEXT_MESSAGE_BYTES, PromptOutcome, outcome_into_payload};
use crate::protocol;
use aion_integrations::{ContentType, HarnessError, Payload};
const TEST_SESSION: &str = "sess-outcome-test";
fn payload_of(result: serde_json::Value, text: &str) -> Result<Payload, HarnessError> {
outcome_into_payload(
PromptOutcome {
response: JsonRpcResponse::success(JsonRpcId::number(1), result),
text: text.to_owned(),
final_message: text.to_owned(),
},
&SessionId::from(TEST_SESSION.to_owned()),
)
}
fn error_of(code: i64, message: &str, text: &str) -> Result<Payload, HarnessError> {
outcome_into_payload(
PromptOutcome {
response: JsonRpcResponse::failure(
JsonRpcId::number(1),
JsonRpcError::new(code, message),
),
text: text.to_owned(),
final_message: text.to_owned(),
},
&SessionId::from(TEST_SESSION.to_owned()),
)
}
#[test]
fn end_turn_yields_the_canonical_agent_outcome_record() -> Result<(), Box<dyn std::error::Error>>
{
let payload = payload_of(json!({ "stopReason": "end_turn" }), "the answer")?;
assert_eq!(payload.content_type(), &ContentType::Json);
assert_eq!(
payload.to_json()?,
json!({
"text": "the answer",
"final_message": "the answer",
"stop_reason": "end_turn",
"session_id": TEST_SESSION,
}),
"the completed payload is the canonical AgentOutcome record — ACP's response \
carries only a stop reason, so the text must come from the stream, and the \
session the turn ran in is the record's conversation handle"
);
Ok(())
}
#[test]
fn refusal_is_never_reported_as_success() -> Result<(), String> {
let error = payload_of(json!({ "stopReason": "refusal" }), "I will not")
.err()
.ok_or("a refusal must not be a completed result")?;
assert!(
matches!(error, HarnessError::PolicyRefused { .. }),
"a refusal has its own policy class, got {error:?}"
);
let message = error.to_string();
assert!(
message.contains("refusal"),
"the error names the stop reason: {message}"
);
assert!(
message.contains("partial output: I will not"),
"the partial answer rides the message, labelled: {message}"
);
Ok(())
}
#[test]
fn each_non_completing_stop_reason_is_a_failure_naming_itself() -> Result<(), String> {
for label in ["max_tokens", "max_turn_requests"] {
let error = payload_of(json!({ "stopReason": label }), "")
.err()
.ok_or_else(|| format!("{label} must not be a completed result"))?;
assert!(matches!(error, HarnessError::Harness { .. }));
let message = error.to_string();
assert!(
message.contains(label),
"the error names `{label}`: {message}"
);
assert!(
!message.starts_with(protocol::CANCELLED_DETAIL_PREFIX),
"{label} is not a cancellation: {message}"
);
assert!(
!message.contains("partial output"),
"an empty partial must not be fabricated: {message}"
);
}
Ok(())
}
#[test]
fn cancelled_is_distinguishable_from_any_other_failure() -> Result<(), String> {
let error = payload_of(json!({ "stopReason": "cancelled" }), "half an ans")
.err()
.ok_or("a cancellation is not a completed result")?;
let message = error.to_string();
assert!(
message.contains(protocol::CANCELLED_DETAIL_PREFIX),
"a cancellation carries the documented prefix so callers can match on it: {message}"
);
assert!(message.contains("partial output: half an ans"));
Ok(())
}
#[test]
fn an_auth_required_error_names_the_authentication_it_needs() -> Result<(), String> {
let error = error_of(protocol::auth_required_code(), "log in first", "")
.err()
.ok_or("an error response is not a result")?;
let message = error.to_string();
assert!(
message.contains("requires authentication"),
"the -32000 code is translated into an actionable message: {message}"
);
assert!(message.contains("log in first"));
Ok(())
}
#[test]
fn a_request_cancelled_error_is_reported_as_a_cancellation() -> Result<(), String> {
let error = error_of(protocol::request_cancelled_code(), "aborted", "")
.err()
.ok_or("an error response is not a result")?;
assert!(
error.to_string().starts_with(&format!(
"harness reported failure: {}",
protocol::CANCELLED_DETAIL_PREFIX
)),
"got: {error}"
);
Ok(())
}
#[test]
fn an_ordinary_error_response_surfaces_its_code_and_message() -> Result<(), String> {
let error = error_of(-32603, "the model exploded", "")
.err()
.ok_or("an error response is not a result")?;
let message = error.to_string();
assert!(message.contains("-32603") && message.contains("the model exploded"));
Ok(())
}
#[test]
fn a_response_with_neither_result_nor_error_is_a_protocol_error() -> Result<(), String> {
let response: JsonRpcResponse =
serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1 }))
.map_err(|source| format!("the bare frame must decode: {source}"))?;
let error = outcome_into_payload(
PromptOutcome {
response,
text: String::new(),
final_message: String::new(),
},
&SessionId::from(TEST_SESSION.to_owned()),
)
.err()
.ok_or("a broken frame is not a result")?;
assert!(
matches!(error, HarnessError::Protocol { .. }),
"got {error:?}"
);
assert!(
error.to_string().contains("neither result nor error"),
"the error names the broken frame: {error}"
);
Ok(())
}
#[test]
fn a_result_that_is_not_a_prompt_response_is_a_protocol_error() -> Result<(), String> {
let error = payload_of(json!({ "stopReason": "teleported" }), "")
.err()
.ok_or("an unknown stop reason must not decode")?;
assert!(
matches!(error, HarnessError::Protocol { .. }),
"an off-contract result is a protocol error, got {error:?}"
);
Ok(())
}
#[test]
fn an_enormous_partial_answer_is_bounded_with_an_explicit_marker() -> Result<(), String> {
let huge = "x".repeat(PARTIAL_TEXT_MESSAGE_BYTES * 3);
let error = payload_of(json!({ "stopReason": "max_tokens" }), &huge)
.err()
.ok_or("max_tokens is not a completed result")?;
let message = error.to_string();
assert!(message.contains("partial output: "));
assert!(
message.contains("truncated for this message"),
"the cut is explicit"
);
assert!(
message.len() < PARTIAL_TEXT_MESSAGE_BYTES + 500,
"the message stays bounded, got {} bytes",
message.len()
);
Ok(())
}
}