Lots of changes... not done yet.
This commit is contained in:
@@ -94,6 +94,25 @@ impl TryFrom<RequestCommandOutputResult> for api::request::input::tool_call_resu
|
||||
},
|
||||
),
|
||||
),
|
||||
RequestCommandOutputResult::ExecutionError { command, message } => Ok(
|
||||
api::request::input::tool_call_result::Result::RunShellCommand(
|
||||
#[allow(deprecated)]
|
||||
api::RunShellCommandResult {
|
||||
command,
|
||||
output: Default::default(),
|
||||
exit_code: Default::default(),
|
||||
result: Some(api::run_shell_command_result::Result::CommandFinished(
|
||||
api::ShellCommandFinished {
|
||||
command_id: String::new(),
|
||||
output: format!("Command was not executed: {message}"),
|
||||
exit_code: 1,
|
||||
start_ts: None,
|
||||
finish_ts: None,
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
),
|
||||
RequestCommandOutputResult::Denylisted { command } =>
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
@@ -1551,6 +1570,14 @@ impl From<RunAgentsAgentOutcome> for api::run_agents_result::AgentOutcome {
|
||||
api::run_agents_result::LaunchedAgent { agent_id },
|
||||
)
|
||||
}
|
||||
// The legacy wire schema has no completed-child shape. Preserve the child identity;
|
||||
// direct-provider history retains the richer local result and output.
|
||||
RunAgentsAgentOutcomeKind::Completed {
|
||||
agent_id,
|
||||
output: _,
|
||||
} => api::run_agents_result::agent_outcome::Result::Launched(
|
||||
api::run_agents_result::LaunchedAgent { agent_id },
|
||||
),
|
||||
RunAgentsAgentOutcomeKind::Failed { error } => {
|
||||
api::run_agents_result::agent_outcome::Result::Failed(
|
||||
api::run_agents_result::FailedAgent { error },
|
||||
|
||||
@@ -28,3 +28,22 @@ fn ask_user_question_skipped_by_auto_approve_converts_to_skipped_answers() {
|
||||
Some(AskUserQuestionAnswer::Skipped(()))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_run_agents_child_converts_to_legacy_launched_wire_outcome() {
|
||||
let outcome = RunAgentsAgentOutcome {
|
||||
name: "research".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Completed {
|
||||
agent_id: "child-1".to_string(),
|
||||
output: "local output".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let converted = api::run_agents_result::AgentOutcome::from(outcome);
|
||||
assert!(matches!(
|
||||
converted.result,
|
||||
Some(api::run_agents_result::agent_outcome::Result::Launched(
|
||||
api::run_agents_result::LaunchedAgent { agent_id }
|
||||
)) if agent_id == "child-1"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ impl AIAgentActionResultType {
|
||||
None,
|
||||
),
|
||||
RequestCommandOutputResult::CancelledBeforeExecution
|
||||
| RequestCommandOutputResult::ExecutionError { .. }
|
||||
| RequestCommandOutputResult::Denylisted { .. } => result.to_string(),
|
||||
},
|
||||
Self::WriteToLongRunningShellCommand(result) => match result {
|
||||
@@ -418,6 +419,8 @@ pub enum RequestCommandOutputResult {
|
||||
/// A running command canceled via ctrl-c
|
||||
/// would have Completed result with exit code 130.
|
||||
CancelledBeforeExecution,
|
||||
/// The command could not start because the terminal was unavailable for execution.
|
||||
ExecutionError { command: String, message: String },
|
||||
/// The command was denied because it was present on the denylist.
|
||||
Denylisted { command: String },
|
||||
}
|
||||
@@ -427,14 +430,16 @@ impl RequestCommandOutputResult {
|
||||
match self {
|
||||
Self::Completed { exit_code, .. } => exit_code.was_successful(),
|
||||
Self::LongRunningCommandSnapshot { .. } => true,
|
||||
Self::CancelledBeforeExecution | Self::Denylisted { .. } => false,
|
||||
Self::CancelledBeforeExecution
|
||||
| Self::ExecutionError { .. }
|
||||
| Self::Denylisted { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(&self) -> bool {
|
||||
match self {
|
||||
Self::Completed { exit_code, .. } => !exit_code.was_successful(),
|
||||
Self::Denylisted { .. } => true,
|
||||
Self::ExecutionError { .. } | Self::Denylisted { .. } => true,
|
||||
Self::CancelledBeforeExecution | Self::LongRunningCommandSnapshot { .. } => false,
|
||||
}
|
||||
}
|
||||
@@ -444,6 +449,7 @@ impl RequestCommandOutputResult {
|
||||
match self {
|
||||
Self::Completed { command, .. }
|
||||
| Self::LongRunningCommandSnapshot { command, .. }
|
||||
| Self::ExecutionError { command, .. }
|
||||
| Self::Denylisted { command } => command.clone(),
|
||||
Self::CancelledBeforeExecution => "cancelled".to_string(),
|
||||
}
|
||||
@@ -473,6 +479,9 @@ impl Display for RequestCommandOutputResult {
|
||||
RequestCommandOutputResult::CancelledBeforeExecution => {
|
||||
write!(f, "Command output cancelled")
|
||||
}
|
||||
RequestCommandOutputResult::ExecutionError { command, message } => {
|
||||
write!(f, "Command '{command}' could not be executed: {message}")
|
||||
}
|
||||
RequestCommandOutputResult::Denylisted { .. } => {
|
||||
write!(f, "Command output was on denylist")
|
||||
}
|
||||
@@ -1042,7 +1051,9 @@ impl AIAgentActionResultType {
|
||||
| TransferShellCommandControlToUserResult::CommandFinished { .. },
|
||||
) => true,
|
||||
Self::AskUserQuestion(AskUserQuestionResult::Success { .. }) => true,
|
||||
Self::RunAgents(RunAgentsResult::Launched { .. }) => true,
|
||||
Self::RunAgents(RunAgentsResult::Launched { agents, .. }) => agents
|
||||
.iter()
|
||||
.any(|agent| !matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })),
|
||||
Self::WaitForEvents(WaitForEventsResult::Completed) => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -1076,6 +1087,12 @@ impl AIAgentActionResultType {
|
||||
| Self::RunAgents(RunAgentsResult::Failure { .. } | RunAgentsResult::Denied { .. }) => {
|
||||
true
|
||||
}
|
||||
Self::RunAgents(RunAgentsResult::Launched { agents, .. }) => {
|
||||
!agents.is_empty()
|
||||
&& agents
|
||||
.iter()
|
||||
.all(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. }))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1627,6 +1644,7 @@ pub struct RunAgentsAgentOutcome {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RunAgentsAgentOutcomeKind {
|
||||
Launched { agent_id: String },
|
||||
Completed { agent_id: String, output: String },
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
@@ -1654,6 +1672,17 @@ impl RunAgentsResult {
|
||||
"computer_use_enabled": computer_use_enabled,
|
||||
}),
|
||||
};
|
||||
let children_completed = agents.iter().all(|agent| {
|
||||
matches!(
|
||||
agent.kind,
|
||||
RunAgentsAgentOutcomeKind::Completed { .. }
|
||||
| RunAgentsAgentOutcomeKind::Failed { .. }
|
||||
)
|
||||
});
|
||||
let all_failed = !agents.is_empty()
|
||||
&& agents.iter().all(|agent| {
|
||||
matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })
|
||||
});
|
||||
let agents = agents
|
||||
.iter()
|
||||
.map(|agent| match &agent.kind {
|
||||
@@ -1662,6 +1691,14 @@ impl RunAgentsResult {
|
||||
"status": "launched",
|
||||
"agent_id": agent_id,
|
||||
}),
|
||||
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => {
|
||||
serde_json::json!({
|
||||
"name": agent.name,
|
||||
"status": "completed",
|
||||
"agent_id": agent_id,
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
|
||||
"name": agent.name,
|
||||
"status": "failed",
|
||||
@@ -1670,9 +1707,13 @@ impl RunAgentsResult {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::json!({
|
||||
"status": "launched",
|
||||
"completion_state": "children_running",
|
||||
"instruction": "Child agents have only been launched, not completed. Wait for child-agent updates before repeating this work or reporting final results.",
|
||||
"status": if all_failed { "failure" } else { "launched" },
|
||||
"completion_state": if children_completed { "children_completed" } else { "children_running" },
|
||||
"instruction": if children_completed {
|
||||
"Child agents reached terminal states. Use their structured outputs and errors to complete the task."
|
||||
} else {
|
||||
"Child agents have only been launched, not completed. Wait for child-agent updates before repeating this work or reporting final results."
|
||||
},
|
||||
"model_id": model_id,
|
||||
"harness_type": harness_type,
|
||||
"execution_mode": execution_mode,
|
||||
@@ -1701,7 +1742,13 @@ impl Display for RunAgentsResult {
|
||||
RunAgentsResult::Launched { agents, .. } => {
|
||||
let launched = agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.kind,
|
||||
RunAgentsAgentOutcomeKind::Launched { .. }
|
||||
| RunAgentsAgentOutcomeKind::Completed { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
write!(
|
||||
f,
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
use super::{
|
||||
AIAgentActionResultType, RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind,
|
||||
RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult, StartAgentVersion,
|
||||
AIAgentActionResultType, RequestCommandOutputResult, RunAgentsAgentOutcome,
|
||||
RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult,
|
||||
StartAgentVersion,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn shell_execution_error_is_failed_but_not_cancelled() {
|
||||
let result =
|
||||
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
|
||||
command: "cargo test".to_string(),
|
||||
message: "terminal is busy".to_string(),
|
||||
});
|
||||
|
||||
assert!(result.is_failed());
|
||||
assert!(!result.is_cancelled());
|
||||
assert!(result.model_content().contains("terminal is busy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_start_agent_success_without_version_as_v1() {
|
||||
let result: StartAgentResult =
|
||||
@@ -134,3 +148,72 @@ fn run_agents_model_content_serializes_terminal_non_launch_outcomes() {
|
||||
assert_eq!(content, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_local_run_agents_preserves_outputs_and_terminal_state() {
|
||||
let result = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||
model_id: "model".to_string(),
|
||||
harness_type: "codex".to_string(),
|
||||
execution_mode: RunAgentsLaunchedExecutionMode::Local,
|
||||
agents: vec![RunAgentsAgentOutcome {
|
||||
name: "research".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Completed {
|
||||
agent_id: "child-1".to_string(),
|
||||
output: "Found the root cause".to_string(),
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
let content: serde_json::Value = serde_json::from_str(&result.model_content()).unwrap();
|
||||
assert_eq!(content["status"], "launched");
|
||||
assert_eq!(content["completion_state"], "children_completed");
|
||||
assert_eq!(content["agents"][0]["status"], "completed");
|
||||
assert_eq!(content["agents"][0]["output"], "Found the root cause");
|
||||
assert!(result.is_successful());
|
||||
assert!(!result.is_failed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_failed_run_agents_is_failure_but_mixed_batch_is_successful() {
|
||||
let failed = |name: &str, error: &str| RunAgentsAgentOutcome {
|
||||
name: name.to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Failed {
|
||||
error: error.to_string(),
|
||||
},
|
||||
};
|
||||
let result = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||
model_id: "model".to_string(),
|
||||
harness_type: "codex".to_string(),
|
||||
execution_mode: RunAgentsLaunchedExecutionMode::Local,
|
||||
agents: vec![failed("one", "first error"), failed("two", "second error")],
|
||||
});
|
||||
|
||||
let content: serde_json::Value = serde_json::from_str(&result.model_content()).unwrap();
|
||||
assert_eq!(content["status"], "failure");
|
||||
assert_eq!(content["agents"][0]["error"], "first error");
|
||||
assert_eq!(content["agents"][1]["error"], "second error");
|
||||
assert!(result.is_failed());
|
||||
assert!(!result.is_successful());
|
||||
|
||||
let mixed = AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||
model_id: "model".to_string(),
|
||||
harness_type: "codex".to_string(),
|
||||
execution_mode: RunAgentsLaunchedExecutionMode::Local,
|
||||
agents: vec![
|
||||
failed("one", "first error"),
|
||||
RunAgentsAgentOutcome {
|
||||
name: "two".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Completed {
|
||||
agent_id: "child-2".to_string(),
|
||||
output: "useful output".to_string(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
let mixed_content: serde_json::Value = serde_json::from_str(&mixed.model_content()).unwrap();
|
||||
assert_eq!(mixed_content["status"], "launched");
|
||||
assert_eq!(mixed_content["agents"][0]["error"], "first error");
|
||||
assert_eq!(mixed_content["agents"][1]["output"], "useful output");
|
||||
assert!(mixed.is_successful());
|
||||
assert!(!mixed.is_failed());
|
||||
}
|
||||
|
||||
@@ -820,7 +820,8 @@ impl ProviderRun {
|
||||
call_id: call.call.id.clone(),
|
||||
})
|
||||
}
|
||||
PendingToolCallState::PermissionPending { .. } | PendingToolCallState::Executing => {
|
||||
PendingToolCallState::Executing => Ok(()),
|
||||
PendingToolCallState::PermissionPending { .. } => {
|
||||
Err(invalid_tool_transition(call, "tool start"))
|
||||
}
|
||||
}
|
||||
@@ -840,6 +841,9 @@ impl ProviderRun {
|
||||
call.state = PendingToolCallState::Resolved { result };
|
||||
Ok(())
|
||||
}
|
||||
PendingToolCallState::Resolved {
|
||||
result: completed_result,
|
||||
} if completed_result == &result => Ok(()),
|
||||
PendingToolCallState::Resolved { .. } => {
|
||||
Err(ProviderRunProtocolError::DuplicateToolUpdate {
|
||||
call_id: call.call.id.clone(),
|
||||
|
||||
@@ -358,6 +358,89 @@ fn parallel_tool_results_commit_atomically_in_original_call_order() {
|
||||
assert_eq!(ids, vec!["first", "second"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tool_start_is_idempotent() {
|
||||
let mut run = run();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
|
||||
);
|
||||
|
||||
run.start_tool(&batch.work_id, "read").unwrap();
|
||||
let started = run.clone();
|
||||
run.start_tool(&batch.work_id, "read").unwrap();
|
||||
|
||||
assert_eq!(run, started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_tool_completion_is_idempotent() {
|
||||
let mut run = run();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
|
||||
);
|
||||
let result = successful_result("read", "contents");
|
||||
|
||||
run.complete_tool(&batch.work_id, result.clone()).unwrap();
|
||||
let completed = run.clone();
|
||||
run.complete_tool(&batch.work_id, result).unwrap();
|
||||
|
||||
assert_eq!(run, completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_tool_completion_is_rejected_without_mutation() {
|
||||
let mut run = run();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
|
||||
);
|
||||
run.complete_tool(&batch.work_id, successful_result("read", "contents"))
|
||||
.unwrap();
|
||||
let completed = run.clone();
|
||||
|
||||
assert_eq!(
|
||||
run.complete_tool(&batch.work_id, successful_result("read", "different"))
|
||||
.unwrap_err(),
|
||||
ProviderRunProtocolError::DuplicateToolUpdate {
|
||||
call_id: "read".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(run, completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tool_callbacks_do_not_prevent_eventual_batch_completion() {
|
||||
let mut run = run();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![
|
||||
tool_call("first", "read_files"),
|
||||
tool_call("second", "grep"),
|
||||
],
|
||||
&["read_files", "grep"],
|
||||
),
|
||||
);
|
||||
let first_result = successful_result("first", "one");
|
||||
|
||||
run.start_tool(&batch.work_id, "first").unwrap();
|
||||
run.start_tool(&batch.work_id, "first").unwrap();
|
||||
run.complete_tool(&batch.work_id, first_result.clone())
|
||||
.unwrap();
|
||||
run.complete_tool(&batch.work_id, first_result).unwrap();
|
||||
run.complete_tool(&batch.work_id, successful_result("second", "two"))
|
||||
.unwrap();
|
||||
|
||||
let ProviderRunState::AwaitingTools { batch: completed } = run.state() else {
|
||||
panic!("expected completed tool batch");
|
||||
};
|
||||
assert!(completed.is_complete());
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_batch_submission_rejects_missing_duplicate_and_unknown_results_without_mutation() {
|
||||
let mut run = run();
|
||||
|
||||
Reference in New Issue
Block a user