Merge remote-tracking branch 'ryserve/master'
# Conflicts: # app/src/ai/llms.rs
This commit is contained in:
@@ -661,10 +661,14 @@ impl AIExecutionProfile {
|
||||
PermissionMode::GalaxyDecides => RunAgentsPermission::GalaxyDecides,
|
||||
PermissionMode::AllowAll => RunAgentsPermission::AlwaysAllow,
|
||||
};
|
||||
self.ask_user_question = if self.permission_mode == PermissionMode::AskFirst {
|
||||
AskUserQuestionPermission::AlwaysAsk
|
||||
} else {
|
||||
AskUserQuestionPermission::Never
|
||||
// Allow All bypasses tool approval, while clarification can still need user input.
|
||||
self.ask_user_question = match self.permission_mode {
|
||||
PermissionMode::AskFirst | PermissionMode::AllowAll => {
|
||||
AskUserQuestionPermission::AlwaysAsk
|
||||
}
|
||||
PermissionMode::ReadOnly | PermissionMode::GalaxyDecides => {
|
||||
AskUserQuestionPermission::Never
|
||||
}
|
||||
};
|
||||
self.directory_allowlist.clear();
|
||||
self.mcp_allowlist.clear();
|
||||
|
||||
@@ -61,6 +61,7 @@ fn serialized_profiles_only_store_one_permission_mode_and_bounded_approvals() {
|
||||
"write_to_pty",
|
||||
"run_agents",
|
||||
"computer_use",
|
||||
"ask_user_question",
|
||||
"directory_allowlist",
|
||||
"mcp_allowlist",
|
||||
] {
|
||||
@@ -127,3 +128,25 @@ fn allow_all_projects_every_tool_to_always_allow() {
|
||||
assert_eq!(profile.computer_use, ComputerUsePermission::AlwaysAllow);
|
||||
assert_eq!(profile.run_agents, RunAgentsPermission::AlwaysAllow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clarification_availability_follows_permission_mode_after_projection_and_restore() {
|
||||
for (mode, expected) in [
|
||||
(PermissionMode::AskFirst, AskUserQuestionPermission::AlwaysAsk),
|
||||
(PermissionMode::AllowAll, AskUserQuestionPermission::AlwaysAsk),
|
||||
(PermissionMode::GalaxyDecides, AskUserQuestionPermission::Never),
|
||||
(PermissionMode::ReadOnly, AskUserQuestionPermission::Never),
|
||||
] {
|
||||
let mut profile = AIExecutionProfile {
|
||||
permission_mode: mode,
|
||||
..Default::default()
|
||||
};
|
||||
profile.project_permission_mode();
|
||||
assert_eq!(profile.ask_user_question, expected, "{mode:?}");
|
||||
|
||||
let saved = serde_json::to_value(&profile).unwrap();
|
||||
assert!(saved.get("ask_user_question").is_none());
|
||||
let restored: AIExecutionProfile = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.ask_user_question, expected, "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
mod provider_run;
|
||||
mod runtime;
|
||||
mod task_list;
|
||||
mod tool_policy;
|
||||
mod types;
|
||||
|
||||
pub use provider_run::*;
|
||||
pub use runtime::*;
|
||||
pub use task_list::*;
|
||||
pub use tool_policy::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -4,10 +4,11 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::tool_image_group_call_id;
|
||||
use crate::{
|
||||
AgentError, ContentPart, ConversationMessage, MessageContent, MessageRole, PermissionDecision,
|
||||
PermissionRequest, StopReason, ToolCall, ToolCallDecision, ToolPolicy, ToolResult,
|
||||
ToolResultStatus, Usage,
|
||||
PermissionRequest, StopReason, TaskListSnapshot, ToolCall, ToolCallDecision, ToolPolicy,
|
||||
ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME, Usage, task_list_from_tool_result,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -357,6 +358,9 @@ pub enum ProviderRunProtocolError {
|
||||
InvalidTranscriptCompaction {
|
||||
message: String,
|
||||
},
|
||||
InvalidTaskList {
|
||||
message: String,
|
||||
},
|
||||
InvalidRestoredState {
|
||||
message: String,
|
||||
},
|
||||
@@ -422,6 +426,7 @@ impl fmt::Display for ProviderRunProtocolError {
|
||||
Self::InvalidTranscriptCompaction { message } => {
|
||||
write!(f, "invalid transcript compaction: {message}")
|
||||
}
|
||||
Self::InvalidTaskList { message } => write!(f, "invalid task list: {message}"),
|
||||
Self::InvalidRestoredState { message } => {
|
||||
write!(f, "invalid restored provider run: {message}")
|
||||
}
|
||||
@@ -439,6 +444,8 @@ pub struct ProviderRun {
|
||||
epoch: RunEpoch,
|
||||
transcript: Vec<ConversationMessage>,
|
||||
tool_result_archive: Vec<ConversationMessage>,
|
||||
#[serde(default)]
|
||||
task_list: Option<TaskListSnapshot>,
|
||||
usage: Usage,
|
||||
model_turns: u32,
|
||||
model_retries: u32,
|
||||
@@ -460,6 +467,7 @@ impl ProviderRun {
|
||||
epoch: RunEpoch::default(),
|
||||
transcript,
|
||||
tool_result_archive: Vec::new(),
|
||||
task_list: None,
|
||||
usage: Usage::default(),
|
||||
model_turns: 0,
|
||||
model_retries: 0,
|
||||
@@ -489,6 +497,23 @@ impl ProviderRun {
|
||||
&self.transcript
|
||||
}
|
||||
|
||||
pub fn task_list(&self) -> Option<&TaskListSnapshot> {
|
||||
self.task_list.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_task_list(
|
||||
&mut self,
|
||||
task_list: Option<TaskListSnapshot>,
|
||||
) -> Result<(), ProviderRunProtocolError> {
|
||||
if let Some(snapshot) = &task_list {
|
||||
snapshot
|
||||
.validate()
|
||||
.map_err(|message| ProviderRunProtocolError::InvalidTaskList { message })?;
|
||||
}
|
||||
self.task_list = task_list;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn tool_result_archive(&self) -> &[ConversationMessage] {
|
||||
&self.tool_result_archive
|
||||
}
|
||||
@@ -564,6 +589,11 @@ impl ProviderRun {
|
||||
if self.profile.as_str().is_empty() {
|
||||
return Err(invalid("request profile must not be empty".to_string()));
|
||||
}
|
||||
if let Some(snapshot) = &self.task_list {
|
||||
snapshot
|
||||
.validate()
|
||||
.map_err(|error| invalid(format!("invalid task list: {error}")))?;
|
||||
}
|
||||
if self.limits.max_model_turns == Some(0) {
|
||||
return Err(invalid("model-turn limit must be at least one".to_string()));
|
||||
}
|
||||
@@ -831,6 +861,7 @@ impl ProviderRun {
|
||||
.push(pending.call.id.clone());
|
||||
pending.state = PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: pending.call.id.clone(),
|
||||
content: "Tool execution was interrupted by application restart and was not replayed."
|
||||
.to_string(),
|
||||
@@ -1132,6 +1163,7 @@ impl ProviderRun {
|
||||
let content = reason.unwrap_or_else(|| "Tool permission was denied.".to_string());
|
||||
call.state = PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call_id.to_string(),
|
||||
content,
|
||||
status: ToolResultStatus::Denied,
|
||||
@@ -1215,6 +1247,7 @@ impl ProviderRun {
|
||||
| PendingToolCallState::RecoveryPending => {
|
||||
call.state = PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call_id.to_string(),
|
||||
content: reason.into(),
|
||||
status: ToolResultStatus::Cancelled,
|
||||
@@ -1273,6 +1306,20 @@ impl ProviderRun {
|
||||
});
|
||||
};
|
||||
let next_epoch = self.next_epoch()?;
|
||||
let mut task_list = self.task_list.clone();
|
||||
for pending in &batch.calls {
|
||||
if pending.call.name != UPDATE_TASK_LIST_NAME {
|
||||
continue;
|
||||
}
|
||||
let result = pending.state.result().expect("validated complete tool batch");
|
||||
if result.status == ToolResultStatus::Success {
|
||||
task_list = Some(task_list_from_tool_result(&result.content).ok_or_else(|| {
|
||||
ProviderRunProtocolError::InvalidTaskList {
|
||||
message: "successful task update has no valid snapshot result".to_string(),
|
||||
}
|
||||
})?);
|
||||
}
|
||||
}
|
||||
let yielded_to_children = batch.calls.iter().any(|pending| {
|
||||
is_local_agent_launch(&pending.call)
|
||||
&& pending
|
||||
@@ -1281,6 +1328,7 @@ impl ProviderRun {
|
||||
.is_some_and(|result| result.status == ToolResultStatus::Success)
|
||||
});
|
||||
self.transcript.push(tool_result_message(results));
|
||||
self.task_list = task_list;
|
||||
self.epoch = next_epoch;
|
||||
// Launch acknowledgement is a yield, not an invitation to issue more tools.
|
||||
// The client persists child membership and supplies a new input once the group finishes.
|
||||
@@ -1424,6 +1472,7 @@ impl ProviderRun {
|
||||
let state = if has_launch && !is_local_agent_launch(&call) {
|
||||
PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.id.clone(),
|
||||
content: "Not executed: launching a child group yields this turn. Wait for the group's completion before scheduling dependent work.".to_string(),
|
||||
status: ToolResultStatus::Error,
|
||||
@@ -1432,6 +1481,7 @@ impl ProviderRun {
|
||||
} else if !call.arguments.is_object() {
|
||||
PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.id.clone(),
|
||||
content: format!(
|
||||
"Error: '{}' received malformed arguments; expected a JSON object.",
|
||||
@@ -1543,6 +1593,7 @@ impl ProviderRun {
|
||||
if call.state.result().is_none() {
|
||||
call.state = PendingToolCallState::Resolved {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.call.id.clone(),
|
||||
content: result_content.clone(),
|
||||
status,
|
||||
@@ -1634,6 +1685,18 @@ fn validate_pending_tool_state(pending: &PendingToolCall) -> Result<(), String>
|
||||
| PendingToolCallState::RecoveryPending
|
||||
| PendingToolCallState::Resolved { .. } => {}
|
||||
}
|
||||
if pending.call.name == UPDATE_TASK_LIST_NAME {
|
||||
if let Some(result) = pending.state.result() {
|
||||
if result.status == ToolResultStatus::Success {
|
||||
let snapshot = task_list_from_tool_result(&result.content)
|
||||
.ok_or_else(|| "successful task update has no valid snapshot result".to_string())?;
|
||||
let expected = TaskListSnapshot::from_arguments(&pending.call.arguments)?;
|
||||
if snapshot != expected {
|
||||
return Err("task update result does not match its arguments".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1726,16 +1789,7 @@ fn assistant_message(turn: &CompletedModelTurn) -> ConversationMessage {
|
||||
fn tool_result_message(results: Vec<ToolResult>) -> ConversationMessage {
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(
|
||||
results
|
||||
.into_iter()
|
||||
.map(|result| ContentPart::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error: !matches!(result.status, ToolResultStatus::Success),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
content: MessageContent::MultiPart(ToolResult::into_batch_content_parts(results)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1806,17 +1860,42 @@ pub fn archive_tool_results(
|
||||
seen_results.insert(tool_use_id.clone())
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
parts.retain(|part| match part {
|
||||
ContentPart::ToolUse { tool_use_id, .. } => {
|
||||
seen_uses.insert(tool_use_id.clone())
|
||||
let mut retained_results = HashSet::new();
|
||||
let mut keep_parts: Vec<_> = parts
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::ToolUse { tool_use_id, .. } => {
|
||||
seen_uses.insert(tool_use_id.clone())
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, .. } => {
|
||||
let keep = seen_results.insert(tool_use_id.clone());
|
||||
if keep {
|
||||
retained_results.insert(tool_use_id.clone());
|
||||
}
|
||||
keep
|
||||
}
|
||||
ContentPart::Text(_)
|
||||
| ContentPart::Reasoning { .. }
|
||||
| ContentPart::Image { .. } => false,
|
||||
})
|
||||
.collect();
|
||||
let mut keep_image_group = false;
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
match part {
|
||||
ContentPart::Text(_) => {
|
||||
keep_image_group = tool_image_group_call_id(part)
|
||||
.is_some_and(|call_id| retained_results.contains(call_id))
|
||||
&& matches!(parts.get(index + 1), Some(ContentPart::Image { .. }));
|
||||
keep_parts[index] = keep_image_group;
|
||||
}
|
||||
ContentPart::Image { .. } => keep_parts[index] = keep_image_group,
|
||||
ContentPart::Reasoning { .. }
|
||||
| ContentPart::ToolUse { .. }
|
||||
| ContentPart::ToolResult { .. } => keep_image_group = false,
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, .. } => {
|
||||
seen_results.insert(tool_use_id.clone())
|
||||
}
|
||||
ContentPart::Text(_)
|
||||
| ContentPart::Reasoning { .. }
|
||||
| ContentPart::Image { .. } => false,
|
||||
});
|
||||
}
|
||||
let mut retained = keep_parts.into_iter();
|
||||
parts.retain(|_| retained.next().unwrap_or(false));
|
||||
!parts.is_empty()
|
||||
}
|
||||
MessageContent::Text(_) => false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{AgentErrorKind, PermissionKind};
|
||||
use crate::{AgentErrorKind, PermissionKind, TaskListItem, TaskListStatus, ToolResultImage};
|
||||
|
||||
fn initial_messages() -> Vec<ConversationMessage> {
|
||||
vec![ConversationMessage {
|
||||
@@ -88,6 +88,7 @@ fn accept_tool_turn(run: &mut ProviderRun, turn: CompletedModelTurn) -> PendingT
|
||||
|
||||
fn successful_result(call_id: &str, content: &str) -> ToolResult {
|
||||
ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call_id.to_string(),
|
||||
content: content.to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
@@ -354,6 +355,201 @@ fn unknown_malformed_and_inline_tools_are_pre_resolved_in_the_same_batch() {
|
||||
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
|
||||
}
|
||||
|
||||
fn task_snapshot(status: TaskListStatus) -> TaskListSnapshot {
|
||||
TaskListSnapshot {
|
||||
tasks: vec![TaskListItem {
|
||||
id: "fix".to_string(),
|
||||
title: "Fix the failure".to_string(),
|
||||
status,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn update_task_call(id: &str, snapshot: &TaskListSnapshot) -> ToolCall {
|
||||
ToolCall {
|
||||
id: id.to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: serde_json::to_value(snapshot).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_task_updates_commit_atomically_in_call_order_with_other_tools() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::Pending);
|
||||
let working = task_snapshot(TaskListStatus::InProgress);
|
||||
let completed = task_snapshot(TaskListStatus::Completed);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![
|
||||
update_task_call("tasks-start", &working),
|
||||
tool_call("external", "read_files"),
|
||||
update_task_call("tasks-finish", &completed),
|
||||
],
|
||||
&[UPDATE_TASK_LIST_NAME, "read_files"],
|
||||
),
|
||||
);
|
||||
|
||||
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
assert!(matches!(
|
||||
run.commit_tool_batch(&batch.work_id),
|
||||
Err(ProviderRunProtocolError::IncompleteToolBatch { .. }),
|
||||
));
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
run.complete_tool(&batch.work_id, successful_result("external", "contents"))
|
||||
.unwrap();
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
|
||||
assert_eq!(run.task_list(), Some(&completed));
|
||||
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
||||
assert_serialization_round_trip(&run);
|
||||
let committed = run.clone();
|
||||
assert!(run.commit_tool_batch(&batch.work_id).is_err());
|
||||
assert_eq!(run, committed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_task_updates_do_not_replace_durable_progress() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::InProgress);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![ToolCall {
|
||||
id: "invalid-task-update".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: json!({"tasks": []}),
|
||||
}],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
batch.calls[0].state.result().unwrap().status,
|
||||
ToolResultStatus::Error,
|
||||
);
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call(
|
||||
"unadvertised-update",
|
||||
&task_snapshot(TaskListStatus::Completed),
|
||||
)],
|
||||
&[],
|
||||
),
|
||||
);
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_progress_survives_compaction_archive_eviction_and_restore() {
|
||||
let mut run = run();
|
||||
let snapshot = task_snapshot(TaskListStatus::InProgress);
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call("tasks", &snapshot)],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
assert!(batch.is_complete());
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
run.compact_transcript_at_model_boundary(
|
||||
run.transcript().len(),
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Earlier conversation summary".to_string()),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut archive = run.tool_result_archive().to_vec();
|
||||
for index in 0..450 {
|
||||
archive.extend([
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: format!("read-{index}"),
|
||||
name: "read_files".to_string(),
|
||||
input: json!({}),
|
||||
},
|
||||
},
|
||||
ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: format!("read-{index}"),
|
||||
content: "contents".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
run.replace_tool_result_archive(archive);
|
||||
assert!(!serde_json::to_string(run.tool_result_archive())
|
||||
.unwrap()
|
||||
.contains("task_list_snapshot"));
|
||||
assert_eq!(run.task_list(), Some(&snapshot));
|
||||
assert_serialization_round_trip(&run);
|
||||
run.validate_restored_state().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_list_setter_and_restored_snapshots_validate_before_mutation() {
|
||||
let mut run = run();
|
||||
let initial = task_snapshot(TaskListStatus::InProgress);
|
||||
run.set_task_list(Some(initial.clone())).unwrap();
|
||||
assert!(matches!(
|
||||
run.set_task_list(Some(TaskListSnapshot { tasks: Vec::new() })),
|
||||
Err(ProviderRunProtocolError::InvalidTaskList { .. }),
|
||||
));
|
||||
assert_eq!(run.task_list(), Some(&initial));
|
||||
|
||||
let invalid = mutate_run_json(&run, |value| value["task_list"] = json!({"tasks": []}));
|
||||
assert!(restored_state_error(&invalid).contains("invalid task list"));
|
||||
|
||||
let legacy = mutate_run_json(&run, |value| {
|
||||
value.as_object_mut().unwrap().remove("task_list");
|
||||
});
|
||||
assert_eq!(legacy.task_list(), None);
|
||||
legacy.validate_restored_state().unwrap();
|
||||
run.set_task_list(None).unwrap();
|
||||
assert_eq!(run.task_list(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_a_completed_inline_task_batch_commits_once_without_external_work() {
|
||||
let mut run = run();
|
||||
let snapshot = task_snapshot(TaskListStatus::InProgress);
|
||||
accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![update_task_call("tasks", &snapshot)],
|
||||
&[UPDATE_TASK_LIST_NAME],
|
||||
),
|
||||
);
|
||||
let mut restored: ProviderRun = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
|
||||
restored.validate_restored_state().unwrap();
|
||||
assert_eq!(restored.task_list(), None);
|
||||
assert!(restored.normalize_after_restore().unwrap().committed_tool_batch);
|
||||
assert_eq!(restored.task_list(), Some(&snapshot));
|
||||
assert!(!restored.normalize_after_restore().unwrap().committed_tool_batch);
|
||||
assert_eq!(restored.task_list(), Some(&snapshot));
|
||||
|
||||
let corrupted = mutate_run_json(&run, |value| {
|
||||
value["state"]["AwaitingTools"]["batch"]["calls"][0]["state"]["Resolved"]["result"]["content"] =
|
||||
json!(task_snapshot(TaskListStatus::Completed).to_tool_result_content());
|
||||
});
|
||||
assert!(restored_state_error(&corrupted).contains("does not match its arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_tool_results_commit_atomically_in_original_call_order() {
|
||||
let mut run = run();
|
||||
@@ -1478,6 +1674,127 @@ fn multipart_archive_is_not_multiplied_and_legacy_duplicates_are_repaired() {
|
||||
assert_eq!(archive, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_results_survive_checkpoint_compaction_and_archive_deduplication() {
|
||||
let mut run = ProviderRun::new(
|
||||
"screenshots",
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(vec![ContentPart::Image {
|
||||
data: vec![99],
|
||||
mime_type: "image/png".to_string(),
|
||||
}]),
|
||||
}],
|
||||
"base",
|
||||
ProviderRunLimits::default(),
|
||||
);
|
||||
let batch = accept_tool_turn(
|
||||
&mut run,
|
||||
tool_turn(
|
||||
vec![
|
||||
tool_call("capture", "use_computer"),
|
||||
tool_call("capture-more", "use_computer"),
|
||||
],
|
||||
&["use_computer"],
|
||||
),
|
||||
);
|
||||
for (call_id, bytes) in [("capture", vec![1, 2]), ("capture-more", vec![3, 4])] {
|
||||
let mut result = successful_result(call_id, "screenshot captured");
|
||||
result.images.push(ToolResultImage {
|
||||
data: bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
});
|
||||
run.complete_tool(&batch.work_id, result).unwrap();
|
||||
}
|
||||
// Pending typed results and their image payloads must survive a checkpoint.
|
||||
assert_serialization_round_trip(&run);
|
||||
run = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
|
||||
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||
let transcript = run.transcript().to_vec();
|
||||
let summary = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Screenshots inspected.".to_string()),
|
||||
}];
|
||||
run.compact_transcript_at_model_boundary(transcript.len(), summary.clone())
|
||||
.unwrap();
|
||||
assert_eq!(run.transcript(), summary);
|
||||
assert_eq!(run.tool_result_archive().len(), 2);
|
||||
let MessageContent::MultiPart(parts) = &run.tool_result_archive()[1].content else {
|
||||
panic!("expected archived screenshot result batch");
|
||||
};
|
||||
assert_eq!(parts.len(), 6);
|
||||
assert!(matches!(
|
||||
&parts[2], ContentPart::Text(text) if text == "Images returned by tool call capture:"
|
||||
));
|
||||
assert!(matches!(
|
||||
&parts[3], ContentPart::Image { data, .. } if data == &[1, 2]
|
||||
));
|
||||
assert!(matches!(
|
||||
&parts[4], ContentPart::Text(text) if text == "Images returned by tool call capture-more:"
|
||||
));
|
||||
assert!(matches!(
|
||||
&parts[5], ContentPart::Image { data, .. } if data == &[3, 4]
|
||||
));
|
||||
let expected = run.tool_result_archive().to_vec();
|
||||
let duplicated = expected.iter().cloned().chain(transcript).collect();
|
||||
run.replace_tool_result_archive(duplicated);
|
||||
assert_eq!(run.tool_result_archive(), expected);
|
||||
assert_serialization_round_trip(&run);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archives_only_images_labelled_for_new_results_in_the_same_message() {
|
||||
let result = successful_result("capture", "captured");
|
||||
let mut parts = ToolResult::into_batch_content_parts(vec![result]);
|
||||
parts.extend([
|
||||
ContentPart::Image {
|
||||
data: vec![99],
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
ContentPart::Text("Images returned by tool call missing:".to_string()),
|
||||
ContentPart::Image {
|
||||
data: vec![88],
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
ContentPart::Text("Images returned by tool call capture:".to_string()),
|
||||
ContentPart::Image {
|
||||
data: vec![1],
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
ContentPart::Text("Unrelated user upload".to_string()),
|
||||
ContentPart::Image {
|
||||
data: vec![77],
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
]);
|
||||
let mut archive = Vec::new();
|
||||
let message = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(parts),
|
||||
};
|
||||
archive_tool_results(&mut archive, vec![message.clone()]);
|
||||
let MessageContent::MultiPart(parts) = &archive[0].content else {
|
||||
panic!("expected correlated tool result with one image");
|
||||
};
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert!(matches!(&parts[2], ContentPart::Image { data, .. } if data == &[1]));
|
||||
let expected = archive.clone();
|
||||
archive_tool_results(&mut archive, vec![message]);
|
||||
assert_eq!(archive, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tool_results_without_images_still_restore() {
|
||||
let result: ToolResult = serde_json::from_value(json!({
|
||||
"call_id": "legacy",
|
||||
"content": "old text result",
|
||||
"status": "Success"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(result.images.is_empty());
|
||||
assert_eq!(result.content, "old text result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_launch_yields_and_does_not_execute_batched_follow_up_tools() {
|
||||
let mut run = run();
|
||||
@@ -1523,6 +1840,7 @@ fn failed_child_launch_allows_a_corrective_model_turn() {
|
||||
run.complete_tool(
|
||||
&batch.work_id,
|
||||
ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "children".to_string(),
|
||||
content: "Launch failed".to_string(),
|
||||
status: ToolResultStatus::Error,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub const UPDATE_TASK_LIST_NAME: &str = "update_task_list";
|
||||
pub const MAX_TASK_LIST_ITEMS: usize = 50;
|
||||
pub const MAX_TASK_ID_CHARS: usize = 200;
|
||||
pub const MAX_TASK_TITLE_CHARS: usize = 1_000;
|
||||
|
||||
/// A complete replacement of the task list. Callers retain task IDs across updates.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskListSnapshot {
|
||||
pub tasks: Vec<TaskListItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TaskListItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: TaskListStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskListStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Completed,
|
||||
}
|
||||
|
||||
impl TaskListSnapshot {
|
||||
pub fn from_arguments(arguments: &JsonValue) -> Result<Self, String> {
|
||||
let mut arguments = arguments.clone();
|
||||
if let Some(fields) = arguments.as_object_mut() {
|
||||
if let Some(title) = fields.remove("_galaxy_tool_title") {
|
||||
if !title.is_string() {
|
||||
return Err("The reserved tool title must be a string.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let snapshot: Self = serde_json::from_value(arguments)
|
||||
.map_err(|error| format!("Invalid task list arguments: {error}"))?;
|
||||
snapshot.validate()?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.tasks.is_empty() || self.tasks.len() > MAX_TASK_LIST_ITEMS {
|
||||
return Err(format!(
|
||||
"Task lists must contain between 1 and {MAX_TASK_LIST_ITEMS} tasks."
|
||||
));
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
let mut has_in_progress = false;
|
||||
let mut has_pending = false;
|
||||
for task in &self.tasks {
|
||||
if task.id.trim().is_empty() || task.id.chars().count() > MAX_TASK_ID_CHARS {
|
||||
return Err(format!(
|
||||
"Task IDs must be nonempty and at most {MAX_TASK_ID_CHARS} characters."
|
||||
));
|
||||
}
|
||||
if !ids.insert(task.id.as_str()) {
|
||||
return Err(format!("Duplicate task ID: '{}'.", task.id));
|
||||
}
|
||||
if task.title.trim().is_empty() || task.title.chars().count() > MAX_TASK_TITLE_CHARS
|
||||
{
|
||||
return Err(format!(
|
||||
"Task titles must be nonempty and at most {MAX_TASK_TITLE_CHARS} characters."
|
||||
));
|
||||
}
|
||||
match task.status {
|
||||
TaskListStatus::Pending => has_pending = true,
|
||||
TaskListStatus::InProgress => {
|
||||
if has_in_progress {
|
||||
return Err("Only one task may be in progress at a time.".to_string());
|
||||
}
|
||||
if has_pending {
|
||||
return Err(
|
||||
"The task in progress must precede all pending tasks; reorder the task list to reflect the current work."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
has_in_progress = true;
|
||||
}
|
||||
TaskListStatus::Completed => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_tool_result_content(&self) -> String {
|
||||
serde_json::to_string(&TaskListToolResult {
|
||||
kind: TaskListResultKind::Snapshot,
|
||||
tasks: self.tasks.clone(),
|
||||
})
|
||||
.expect("task list snapshots contain only serializable strings and statuses")
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes a canonical snapshot result. The caller must first verify the owning
|
||||
/// tool name and successful result status when reading conversation history.
|
||||
pub fn task_list_from_tool_result(content: &str) -> Option<TaskListSnapshot> {
|
||||
let result: TaskListToolResult = serde_json::from_str(content).ok()?;
|
||||
let snapshot = TaskListSnapshot { tasks: result.tasks };
|
||||
snapshot.validate().ok()?;
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TaskListToolResult {
|
||||
#[serde(rename = "type")]
|
||||
kind: TaskListResultKind,
|
||||
tasks: Vec<TaskListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
enum TaskListResultKind {
|
||||
#[serde(rename = "task_list_snapshot")]
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "task_list_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn task(id: &str, status: TaskListStatus) -> TaskListItem {
|
||||
TaskListItem {
|
||||
id: id.to_string(),
|
||||
title: format!("Task {id}"),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trips_through_a_recognizable_canonical_result() {
|
||||
let arguments = json!({"tasks": [
|
||||
{"id": "inspect", "title": "Inspect the failure", "status": "completed"},
|
||||
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
|
||||
{"id": "review", "title": "Review the fix", "status": "pending"},
|
||||
]});
|
||||
let snapshot = TaskListSnapshot::from_arguments(&arguments).unwrap();
|
||||
let content = snapshot.to_tool_result_content();
|
||||
|
||||
assert_eq!(task_list_from_tool_result(&content), Some(snapshot.clone()));
|
||||
assert_eq!(snapshot.to_tool_result_content(), content);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<JsonValue>(&content).unwrap(),
|
||||
json!({"type": "task_list_snapshot", "tasks": arguments["tasks"]}),
|
||||
);
|
||||
assert_eq!(snapshot.tasks[1].id, "fix");
|
||||
assert_eq!(snapshot.tasks[1].status, TaskListStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rejects_invented_statuses_partial_arguments_and_unknown_fields() {
|
||||
for arguments in [
|
||||
json!({"tasks": []}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "started"}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect"}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending", "done": false}]}),
|
||||
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending"}], "append": true}),
|
||||
json!([{"id": "one", "title": "Inspect", "status": "pending"}]),
|
||||
] {
|
||||
assert!(TaskListSnapshot::from_arguments(&arguments).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_parser_accepts_only_the_reserved_tool_title_outside_the_snapshot() {
|
||||
let tasks = json!([{"id": "one", "title": "Inspect", "status": "in_progress"}]);
|
||||
let plain = TaskListSnapshot::from_arguments(&json!({"tasks": tasks})).unwrap();
|
||||
assert_eq!(
|
||||
TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
})),
|
||||
Ok(plain),
|
||||
);
|
||||
assert!(TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": false,
|
||||
})).is_err());
|
||||
assert!(TaskListSnapshot::from_arguments(&json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
"other": "unsupported",
|
||||
})).is_err());
|
||||
assert!(serde_json::from_value::<TaskListSnapshot>(json!({
|
||||
"tasks": tasks,
|
||||
"_galaxy_tool_title": "Update progress",
|
||||
})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_enforces_stable_unique_nonempty_ids_and_nonempty_titles() {
|
||||
for tasks in [
|
||||
vec![task(" ", TaskListStatus::Pending)],
|
||||
vec![task("", TaskListStatus::Pending)],
|
||||
vec![
|
||||
task("one", TaskListStatus::Completed),
|
||||
task("one", TaskListStatus::Pending),
|
||||
],
|
||||
vec![TaskListItem {
|
||||
id: "one".to_string(),
|
||||
title: "\t\n".to_string(),
|
||||
status: TaskListStatus::Pending,
|
||||
}],
|
||||
] {
|
||||
assert!(TaskListSnapshot { tasks }.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_bounds_count_and_unicode_character_lengths() {
|
||||
let mut snapshot = TaskListSnapshot {
|
||||
tasks: (0..MAX_TASK_LIST_ITEMS)
|
||||
.map(|index| task(&index.to_string(), TaskListStatus::Pending))
|
||||
.collect(),
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
snapshot.tasks.push(task("extra", TaskListStatus::Pending));
|
||||
assert!(snapshot.validate().is_err());
|
||||
|
||||
let mut snapshot = TaskListSnapshot {
|
||||
tasks: vec![TaskListItem {
|
||||
id: "🦀".repeat(MAX_TASK_ID_CHARS),
|
||||
title: "界".repeat(MAX_TASK_TITLE_CHARS),
|
||||
status: TaskListStatus::Pending,
|
||||
}],
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
snapshot.tasks[0].id.push('🦀');
|
||||
assert!(snapshot.validate().is_err());
|
||||
snapshot.tasks[0].id.pop();
|
||||
snapshot.tasks[0].title.push('界');
|
||||
assert!(snapshot.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_work_precedes_pending_tasks_while_completed_tasks_may_stay_in_place() {
|
||||
let snapshot = TaskListSnapshot {
|
||||
tasks: vec![
|
||||
task("done", TaskListStatus::Completed),
|
||||
task("working", TaskListStatus::InProgress),
|
||||
task("also-done", TaskListStatus::Completed),
|
||||
task("next", TaskListStatus::Pending),
|
||||
],
|
||||
};
|
||||
assert!(snapshot.validate().is_ok());
|
||||
|
||||
for tasks in [
|
||||
vec![
|
||||
task("later", TaskListStatus::Pending),
|
||||
task("done", TaskListStatus::Completed),
|
||||
task("working", TaskListStatus::InProgress),
|
||||
],
|
||||
vec![
|
||||
task("one", TaskListStatus::InProgress),
|
||||
task("two", TaskListStatus::InProgress),
|
||||
],
|
||||
] {
|
||||
assert!(TaskListSnapshot { tasks }.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parser_requires_marker_and_validates_snapshot_instead_of_trusting_content() {
|
||||
let tasks = json!([{"id": "one", "title": "Inspect", "status": "pending"}]);
|
||||
for content in [
|
||||
json!({"tasks": tasks}).to_string(),
|
||||
json!({"type": "something_else", "tasks": tasks}).to_string(),
|
||||
json!({"type": "task_list_snapshot", "tasks": []}).to_string(),
|
||||
json!({"type": "task_list_snapshot", "tasks": tasks, "unrelated": true}).to_string(),
|
||||
"not JSON".to_string(),
|
||||
] {
|
||||
assert_eq!(task_list_from_tool_result(&content), None);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::types::tool_image_group_call_id;
|
||||
use crate::{
|
||||
ContentPart, ConversationMessage, MessageContent, ToolCall, ToolDefinition, ToolResult,
|
||||
ToolResultStatus,
|
||||
ContentPart, ConversationMessage, MessageContent, TaskListSnapshot, ToolCall, ToolDefinition,
|
||||
ToolResult, ToolResultImage, ToolResultStatus, UPDATE_TASK_LIST_NAME,
|
||||
};
|
||||
|
||||
pub const RECALL_TOOL_HISTORY_NAME: &str = "recall_tool_history";
|
||||
@@ -124,6 +125,7 @@ impl ToolPolicy {
|
||||
)
|
||||
};
|
||||
return ToolCallDecision::Reject(ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.id.clone(),
|
||||
content: format!(
|
||||
"Error: '{}' is not a valid tool for this request; {available}. Do not invent tool names.",
|
||||
@@ -134,17 +136,36 @@ impl ToolPolicy {
|
||||
}
|
||||
|
||||
if call.name == RECALL_TOOL_HISTORY_NAME {
|
||||
let (content, images) = recall_tool_history_with_images(
|
||||
messages,
|
||||
archive,
|
||||
ToolHistoryQuery::from_arguments(&call.arguments),
|
||||
);
|
||||
return ToolCallDecision::Inline(ToolResult {
|
||||
images,
|
||||
call_id: call.id.clone(),
|
||||
content: recall_tool_history(
|
||||
messages,
|
||||
archive,
|
||||
ToolHistoryQuery::from_arguments(&call.arguments),
|
||||
),
|
||||
content,
|
||||
status: ToolResultStatus::Success,
|
||||
});
|
||||
}
|
||||
|
||||
if call.name == UPDATE_TASK_LIST_NAME {
|
||||
return match TaskListSnapshot::from_arguments(&call.arguments) {
|
||||
Ok(snapshot) => ToolCallDecision::Inline(ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.id.clone(),
|
||||
content: snapshot.to_tool_result_content(),
|
||||
status: ToolResultStatus::Success,
|
||||
}),
|
||||
Err(error) => ToolCallDecision::Reject(ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: call.id.clone(),
|
||||
content: format!("Error: {error}"),
|
||||
status: ToolResultStatus::Error,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
ToolCallDecision::Execute
|
||||
}
|
||||
}
|
||||
@@ -186,6 +207,14 @@ pub fn recall_tool_history(
|
||||
archive: &[ConversationMessage],
|
||||
query: ToolHistoryQuery<'_>,
|
||||
) -> String {
|
||||
recall_tool_history_with_images(messages, archive, query).0
|
||||
}
|
||||
|
||||
fn recall_tool_history_with_images(
|
||||
messages: &[ConversationMessage],
|
||||
archive: &[ConversationMessage],
|
||||
query: ToolHistoryQuery<'_>,
|
||||
) -> (String, Vec<ToolResultImage>) {
|
||||
let mut entries = Vec::new();
|
||||
collect_tool_entries(archive, &mut entries);
|
||||
collect_tool_entries(messages, &mut entries);
|
||||
@@ -207,26 +236,43 @@ pub fn recall_tool_history(
|
||||
.len()
|
||||
.checked_sub(1usize.saturating_add(query.offset_from_end))
|
||||
else {
|
||||
return "No matching tool calls found in conversation history.".to_string();
|
||||
return (
|
||||
"No matching tool calls found in conversation history.".to_string(),
|
||||
Vec::new(),
|
||||
);
|
||||
};
|
||||
let entry = filtered[index];
|
||||
let result = truncate_recalled_result(&entry.result);
|
||||
|
||||
format!(
|
||||
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{result}",
|
||||
entry.name, entry.tool_use_id, entry.input
|
||||
(
|
||||
format!(
|
||||
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{result}",
|
||||
entry.name, entry.tool_use_id, entry.input
|
||||
),
|
||||
entry
|
||||
.images
|
||||
.iter()
|
||||
.map(|(data, mime_type)| ToolResultImage {
|
||||
data: data.to_vec(),
|
||||
mime_type: (*mime_type).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ToolHistoryEntry {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct ToolHistoryEntry<'a> {
|
||||
tool_use_id: String,
|
||||
name: String,
|
||||
input: String,
|
||||
result: String,
|
||||
images: Vec<(&'a [u8], &'a str)>,
|
||||
}
|
||||
|
||||
fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<ToolHistoryEntry>) {
|
||||
fn collect_tool_entries<'a>(
|
||||
messages: &'a [ConversationMessage],
|
||||
entries: &mut Vec<ToolHistoryEntry<'a>>,
|
||||
) {
|
||||
let mut pending = Vec::new();
|
||||
for message in messages {
|
||||
match &message.content {
|
||||
@@ -241,6 +287,7 @@ fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<Tool
|
||||
..
|
||||
} => pair_result(tool_use_id, content, &mut pending, entries),
|
||||
MessageContent::MultiPart(parts) => {
|
||||
let entries_start = entries.len();
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::ToolUse {
|
||||
@@ -260,6 +307,41 @@ fn collect_tool_entries(messages: &[ConversationMessage], entries: &mut Vec<Tool
|
||||
| ContentPart::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
// ToolResult::into_batch_content_parts places labelled image groups
|
||||
// after every correlated result. Only attach images to results from
|
||||
// this same message, never a nearby user upload or another call.
|
||||
let result_ids: BTreeSet<_> = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
ContentPart::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
|
||||
ContentPart::Text(_)
|
||||
| ContentPart::Reasoning { .. }
|
||||
| ContentPart::Image { .. }
|
||||
| ContentPart::ToolUse { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let mut image_call_id = None;
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::Text(_) => {
|
||||
image_call_id = tool_image_group_call_id(part)
|
||||
.filter(|call_id| result_ids.contains(call_id));
|
||||
}
|
||||
ContentPart::Image { data, mime_type } => {
|
||||
if let Some(entry) = image_call_id.and_then(|call_id| {
|
||||
entries[entries_start..]
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|entry| entry.tool_use_id == call_id)
|
||||
}) {
|
||||
entry.images.push((data, mime_type));
|
||||
}
|
||||
}
|
||||
ContentPart::Reasoning { .. }
|
||||
| ContentPart::ToolUse { .. }
|
||||
| ContentPart::ToolResult { .. } => image_call_id = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::Text(_) => {}
|
||||
}
|
||||
@@ -270,7 +352,7 @@ fn pair_result(
|
||||
tool_use_id: &str,
|
||||
content: &str,
|
||||
pending: &mut Vec<(String, String, String)>,
|
||||
entries: &mut Vec<ToolHistoryEntry>,
|
||||
entries: &mut Vec<ToolHistoryEntry<'_>>,
|
||||
) {
|
||||
let Some(index) = pending.iter().position(|(id, _, _)| id == tool_use_id) else {
|
||||
return;
|
||||
@@ -281,6 +363,7 @@ fn pair_result(
|
||||
name,
|
||||
input,
|
||||
result: content.to_string(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,62 @@ fn executes_only_tools_advertised_for_this_turn() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_updates_are_inline_only_when_advertised_and_keep_the_call_identity() {
|
||||
let call = ToolCall {
|
||||
id: "task-update-1".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments: serde_json::json!({"tasks": [
|
||||
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
|
||||
{"id": "review", "title": "Review the fix", "status": "pending"},
|
||||
]}),
|
||||
};
|
||||
let unadvertised = ToolPolicy::new(&[definition("read_files")]);
|
||||
let ToolCallDecision::Reject(result) = unadvertised.decide(&call, &[], &[]) else {
|
||||
panic!("task updates cannot bypass advertised capabilities");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Error);
|
||||
|
||||
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
|
||||
let ToolCallDecision::Inline(result) = policy.decide(&call, &[], &[]) else {
|
||||
panic!("valid task updates must not dispatch external actions");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Success);
|
||||
let snapshot = crate::task_list_from_tool_result(&result.content).unwrap();
|
||||
assert_eq!(snapshot.tasks[0].id, "fix");
|
||||
assert_eq!(snapshot.tasks[1].id, "review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_task_updates_return_one_correlated_error_instead_of_executing() {
|
||||
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
|
||||
for arguments in [
|
||||
serde_json::json!({"tasks": []}),
|
||||
serde_json::json!({"tasks": [
|
||||
{"id": "same", "title": "First", "status": "completed"},
|
||||
{"id": "same", "title": "Second", "status": "pending"},
|
||||
]}),
|
||||
serde_json::json!({"tasks": [
|
||||
{"id": "one", "title": "First", "status": "invented"},
|
||||
]}),
|
||||
] {
|
||||
let call = ToolCall {
|
||||
id: "invalid-task-update".to_string(),
|
||||
name: UPDATE_TASK_LIST_NAME.to_string(),
|
||||
arguments,
|
||||
};
|
||||
let ToolCallDecision::Reject(result) = policy.decide(&call, &[], &[]) else {
|
||||
panic!("invalid task updates must not execute or succeed");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert_eq!(result.status, ToolResultStatus::Error);
|
||||
assert!(result.content.starts_with("Error: "));
|
||||
assert_eq!(crate::task_list_from_tool_result(&result.content), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_searches_archived_and_live_results_with_live_results_most_recent() {
|
||||
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
|
||||
@@ -156,6 +212,120 @@ fn recall_never_returns_a_prior_recall_call() {
|
||||
);
|
||||
}
|
||||
|
||||
fn screenshot_exchange(id: &str, bytes: Vec<u8>) -> Vec<ConversationMessage> {
|
||||
let mut messages = tool_exchange(id, "use_computer", serde_json::json!({}), "captured");
|
||||
messages[1].content = MessageContent::MultiPart(ToolResult::into_batch_content_parts(vec![
|
||||
ToolResult {
|
||||
call_id: id.to_string(),
|
||||
content: "captured".to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
images: vec![ToolResultImage {
|
||||
data: bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
}],
|
||||
},
|
||||
]));
|
||||
messages
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_attaches_only_the_exact_selected_screenshots_from_archived_or_live_results() {
|
||||
let archive = screenshot_exchange("capture", vec![1, 2]);
|
||||
let mut messages = screenshot_exchange("capture-more", vec![3, 4]);
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(vec![
|
||||
ContentPart::Text("Images returned by tool call capture:".to_string()),
|
||||
ContentPart::Image {
|
||||
data: vec![99],
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
]),
|
||||
});
|
||||
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
|
||||
for (arguments, original_id, bytes) in [
|
||||
(
|
||||
serde_json::json!({"tool_use_id": "capture"}),
|
||||
"capture",
|
||||
vec![1, 2],
|
||||
),
|
||||
(
|
||||
serde_json::json!({"tool_name": "use_computer"}),
|
||||
"capture-more",
|
||||
vec![3, 4],
|
||||
),
|
||||
(
|
||||
serde_json::json!({"tool_name": "use_computer", "offset_from_end": 1}),
|
||||
"capture",
|
||||
vec![1, 2],
|
||||
),
|
||||
] {
|
||||
let call = ToolCall {
|
||||
id: "recall-image".to_string(),
|
||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
arguments,
|
||||
};
|
||||
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &archive) else {
|
||||
panic!("recall should execute inline");
|
||||
};
|
||||
assert_eq!(result.call_id, call.id);
|
||||
assert!(result
|
||||
.content
|
||||
.contains(&format!("Tool Use ID: {original_id}\n")));
|
||||
assert_eq!(
|
||||
result.images,
|
||||
vec![ToolResultImage {
|
||||
data: bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
}]
|
||||
);
|
||||
let parts = ToolResult::into_batch_content_parts(vec![result]);
|
||||
assert!(matches!(
|
||||
&parts[0], ContentPart::ToolResult { tool_use_id, .. } if tool_use_id == "recall-image"
|
||||
));
|
||||
assert!(matches!(
|
||||
&parts[1], ContentPart::Text(text) if text == "Images returned by tool call recall-image:"
|
||||
));
|
||||
assert!(matches!(&parts[2], ContentPart::Image { .. }));
|
||||
}
|
||||
let call = ToolCall {
|
||||
id: "missing".to_string(),
|
||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
arguments: serde_json::json!({"tool_use_id": "capture-missing"}),
|
||||
};
|
||||
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &archive) else {
|
||||
panic!("missing recall should execute inline");
|
||||
};
|
||||
assert!(result.images.is_empty());
|
||||
assert_eq!(
|
||||
result.content,
|
||||
"No matching tool calls found in conversation history."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_does_not_reattach_images_from_an_earlier_recall_result() {
|
||||
let mut messages = screenshot_exchange("recall-previous", vec![99]);
|
||||
let MessageContent::ToolUse { name, .. } = &mut messages[0].content else {
|
||||
panic!("expected tool call");
|
||||
};
|
||||
*name = RECALL_TOOL_HISTORY_NAME.to_string();
|
||||
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);
|
||||
let call = ToolCall {
|
||||
id: "recall-now".to_string(),
|
||||
name: RECALL_TOOL_HISTORY_NAME.to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
};
|
||||
let ToolCallDecision::Inline(result) = policy.decide(&call, &messages, &[]) else {
|
||||
panic!("recall should execute inline");
|
||||
};
|
||||
assert!(result.images.is_empty());
|
||||
assert_eq!(
|
||||
result.content,
|
||||
"No matching tool calls found in conversation history."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_guard_detects_repeated_failures_and_resets_after_detection() {
|
||||
let mut guard = ToolLoopGuard::new(5, 3);
|
||||
|
||||
@@ -190,9 +190,53 @@ pub struct ToolResult {
|
||||
pub call_id: String,
|
||||
pub content: String,
|
||||
pub status: ToolResultStatus,
|
||||
/// Images returned by a tool, kept outside the text so provider adapters receive real images.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub images: Vec<ToolResultImage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolResultImage {
|
||||
pub data: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
pub(crate) fn tool_image_group_call_id(part: &ContentPart) -> Option<&str> {
|
||||
let ContentPart::Text(text) = part else {
|
||||
return None;
|
||||
};
|
||||
text.strip_prefix("Images returned by tool call ")?
|
||||
.strip_suffix(':')
|
||||
.filter(|call_id| !call_id.is_empty())
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn into_batch_content_parts(results: Vec<Self>) -> Vec<ContentPart> {
|
||||
let mut parts = Vec::new();
|
||||
let mut image_parts = Vec::new();
|
||||
for result in results {
|
||||
let is_error = !matches!(result.status, ToolResultStatus::Success);
|
||||
if !result.images.is_empty() {
|
||||
image_parts.push(ContentPart::Text(format!(
|
||||
"Images returned by tool call {}:", result.call_id
|
||||
)));
|
||||
image_parts.extend(result.images.into_iter().map(|image| ContentPart::Image {
|
||||
data: image.data,
|
||||
mime_type: image.mime_type,
|
||||
}));
|
||||
}
|
||||
parts.push(ContentPart::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error,
|
||||
});
|
||||
}
|
||||
// OpenAI requires every tool result before the next user message. Its adapter splits
|
||||
// adjacent images into a user message, so never interleave them with a result batch.
|
||||
parts.extend(image_parts);
|
||||
parts
|
||||
}
|
||||
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
|
||||
@@ -169,6 +169,7 @@ fn tool_events_keep_one_call_id_across_permission_and_execution() {
|
||||
},
|
||||
ToolEvent::Completed {
|
||||
result: ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "call-1".to_string(),
|
||||
content: "ok".to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
@@ -182,11 +183,13 @@ fn tool_events_keep_one_call_id_across_permission_and_execution() {
|
||||
#[test]
|
||||
fn denied_results_are_errors_but_cancelled_results_are_distinct() {
|
||||
let denied = ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "denied".to_string(),
|
||||
content: "permission denied".to_string(),
|
||||
status: ToolResultStatus::Denied,
|
||||
};
|
||||
let cancelled = ToolResult {
|
||||
images: Vec::new(),
|
||||
call_id: "cancelled".to_string(),
|
||||
content: "cancelled".to_string(),
|
||||
status: ToolResultStatus::Cancelled,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::StreamExt;
|
||||
use galaxy_agent_core::{
|
||||
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
|
||||
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
|
||||
TurnRequest, turn_control,
|
||||
};
|
||||
use rig_core::client::CompletionClient;
|
||||
use rig_core::providers::chatgpt;
|
||||
use rig_core::providers::chatgpt::{self, ChatGPTAuth};
|
||||
|
||||
use crate::request::build_completion_request;
|
||||
use crate::stream::start_model_turn;
|
||||
|
||||
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
|
||||
/// Rig to fall back to its interactive device-code flow.
|
||||
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
|
||||
let client = chatgpt::Client::builder()
|
||||
.oauth()
|
||||
.allow_device_flow(false)
|
||||
.originator("galaxy")
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
client.authorize().await.map_err(|error| error.to_string())
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ChatGPTSubscriptionCredentials {
|
||||
pub access_token: String,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChatGPTSubscriptionCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChatGPTSubscriptionCredentials")
|
||||
.field("access_token", &"<redacted>")
|
||||
.field("account_id", &self.account_id.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
pub type ChatGPTCredentialResolver = Arc<
|
||||
dyn Fn() -> BoxFuture<'static, Result<ChatGPTSubscriptionCredentials, String>> + Send + Sync,
|
||||
>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ChatGPTSubscriptionRuntimeConfig {
|
||||
pub model: String,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub auth_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub struct ChatGPTSubscriptionRuntime {
|
||||
config: ChatGPTSubscriptionRuntimeConfig,
|
||||
descriptor: RuntimeDescriptor,
|
||||
credential_resolver: Option<ChatGPTCredentialResolver>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChatGPTSubscriptionRuntime {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChatGPTSubscriptionRuntime")
|
||||
.field("config", &self.config)
|
||||
.field("has_credential_resolver", &self.credential_resolver.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
|
||||
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
|
||||
kind: RuntimeKind::Provider,
|
||||
capabilities: RuntimeCapabilities::provider(),
|
||||
};
|
||||
Self { config, descriptor }
|
||||
Self { config, descriptor, credential_resolver: None }
|
||||
}
|
||||
|
||||
pub fn with_credential_resolver(mut self, resolver: ChatGPTCredentialResolver) -> Self {
|
||||
self.credential_resolver = Some(resolver);
|
||||
self
|
||||
}
|
||||
|
||||
async fn resolve_auth(&self) -> Result<ChatGPTAuth, AgentError> {
|
||||
let resolver = self.credential_resolver.as_ref().ok_or_else(|| {
|
||||
AgentError::new(AgentErrorKind::Authentication, "ChatGPT sign-in required. Reconnect ChatGPT in Providers.")
|
||||
})?;
|
||||
let credentials = resolver().await
|
||||
.map_err(|message| AgentError::new(AgentErrorKind::Authentication, message))?;
|
||||
Ok(ChatGPTAuth::AccessToken {
|
||||
access_token: credentials.access_token,
|
||||
account_id: credentials.account_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Completes a text-only request for callers such as crosscheck reviewers.
|
||||
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
||||
request: TurnRequest,
|
||||
control: TurnControl,
|
||||
) -> Result<AgentEventStream, AgentError> {
|
||||
let mut builder = chatgpt::Client::builder()
|
||||
.oauth()
|
||||
let auth = self.resolve_auth().await?;
|
||||
let client = chatgpt::Client::builder()
|
||||
.api_key(auth)
|
||||
.allow_device_flow(false)
|
||||
.originator("galaxy");
|
||||
if let Some(auth_file) = &self.config.auth_file {
|
||||
builder = builder.auth_file(auth_file);
|
||||
}
|
||||
let client = builder
|
||||
.originator("galaxy")
|
||||
.build()
|
||||
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||
let model = client.completion_model(&self.config.model);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use galaxy_agent_core::StopReason;
|
||||
use rig_core::client::CompletionClient;
|
||||
use rig_core::completion::{AssistantContent, CompletionModel, Message};
|
||||
use rig_core::message::{ToolResultContent, UserContent};
|
||||
use rig_core::providers::chatgpt::ChatGPTAuth;
|
||||
use rig_core::test_utils::RecordingHttpClient;
|
||||
use rig_core::test_utils::{RecordingHttpClient, SequencedStreamingHttpClient};
|
||||
|
||||
use super::*;
|
||||
|
||||
const COMPLETED_RESPONSE: &str = r#"data: {"type":"response.output_text.delta","delta":"ok"}
|
||||
data: {"type":"response.completed","response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
|
||||
data: {"type":"response.completed","sequence_number":54,"response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
|
||||
data: [DONE]"#;
|
||||
|
||||
#[test]
|
||||
@@ -16,7 +17,6 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
max_output_tokens: None,
|
||||
auth_file: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
@@ -25,6 +25,43 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_resolves_current_credentials_without_rig_oauth() {
|
||||
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||
model: "test-model".to_owned(),
|
||||
reasoning_effort: None,
|
||||
max_output_tokens: None,
|
||||
}).with_credential_resolver(std::sync::Arc::new(|| {
|
||||
Box::pin(async {
|
||||
Ok(ChatGPTSubscriptionCredentials {
|
||||
access_token: "resolved-token".to_owned(),
|
||||
account_id: Some("resolved-account".to_owned()),
|
||||
})
|
||||
})
|
||||
}));
|
||||
match runtime.resolve_auth().await.unwrap() {
|
||||
ChatGPTAuth::AccessToken { access_token, account_id } => {
|
||||
assert_eq!(access_token, "resolved-token");
|
||||
assert_eq!(account_id.as_deref(), Some("resolved-account"));
|
||||
}
|
||||
ChatGPTAuth::OAuth => panic!("The runtime must not own OAuth refresh"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_or_failed_resolver_requires_sign_in_without_device_flow() {
|
||||
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
|
||||
model: "test-model".to_owned(),
|
||||
reasoning_effort: None,
|
||||
max_output_tokens: None,
|
||||
});
|
||||
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
|
||||
let runtime = runtime.with_credential_resolver(std::sync::Arc::new(|| {
|
||||
Box::pin(async { Err("Sign in again".to_owned()) })
|
||||
}));
|
||||
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_is_encoded_for_the_responses_request() {
|
||||
let request = TurnRequest::new(
|
||||
@@ -115,3 +152,71 @@ async fn chatgpt_follow_up_request_preserves_responses_call_ids() {
|
||||
assert_eq!(input[0]["call_id"], "call_native_1");
|
||||
assert_eq!(input[1]["call_id"], "call_native_1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chatgpt_metadata_does_not_interrupt_text_or_completion() {
|
||||
let metadata = r#"data: {"type":"response.metadata","response_id":"resp_test","sequence_number":51,"metadata":{"openai_chatgpt_moderation_metadata":{"prompt":{},"generation":{},"tool_call":{},"tool_response":{}}}}"#;
|
||||
let delta = serde_json::json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_test",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"sequence_number": 52,
|
||||
"delta": "ok",
|
||||
});
|
||||
let completed = COMPLETED_RESPONSE
|
||||
.lines()
|
||||
.find(|line| line.starts_with("data: {\"type\":\"response.completed\""))
|
||||
.expect("fixture should contain a completed response");
|
||||
let response =
|
||||
format!("{metadata}\n\ndata: {delta}\n\n{metadata}\n\n{completed}\n\ndata: [DONE]\n\n");
|
||||
let client = chatgpt::Client::builder()
|
||||
.api_key(ChatGPTAuth::AccessToken {
|
||||
access_token: "test-token".to_string(),
|
||||
account_id: None,
|
||||
})
|
||||
.http_client(SequencedStreamingHttpClient::new(vec![Ok(response.into())]))
|
||||
.build()
|
||||
.expect("client should build");
|
||||
let request = build_completion_request(
|
||||
TurnRequest::new(
|
||||
"gpt-5.3-codex".to_string(),
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("hello".to_string()),
|
||||
}],
|
||||
),
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.expect("request should convert");
|
||||
let (_sender, control) = turn_control();
|
||||
let mut stream = start_model_turn(
|
||||
client.completion_model("gpt-5.3-codex"),
|
||||
request,
|
||||
control,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should reach the mocked provider");
|
||||
let mut text = String::new();
|
||||
let mut usage = None;
|
||||
let mut stop_reason = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event.expect("metadata should not fail the turn") {
|
||||
AgentEvent::TextDelta { text: delta } => text.push_str(&delta),
|
||||
AgentEvent::UsageUpdated { usage: updated } => usage = Some(updated),
|
||||
AgentEvent::TurnStopped { reason } => stop_reason = Some(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(text, "ok");
|
||||
assert_eq!(
|
||||
usage.expect("completion should report usage").output_tokens,
|
||||
1
|
||||
);
|
||||
assert_eq!(stop_reason, Some(StopReason::Completed));
|
||||
}
|
||||
|
||||
@@ -160,6 +160,13 @@ where
|
||||
});
|
||||
}
|
||||
Ok(StreamedAssistantContent::Unknown(value)) => {
|
||||
// ChatGPT subscription streams include informational moderation
|
||||
// metadata that Rig does not map to assistant content.
|
||||
if value.get("type").and_then(serde_json::Value::as_str)
|
||||
== Some("response.metadata")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if is_keepalive_event(&value) {
|
||||
yield Ok(AgentEvent::KeepAlive);
|
||||
} else {
|
||||
@@ -512,6 +519,11 @@ fn text_indicates_authentication_failure(text: &str) -> bool {
|
||||
"unrecognized client",
|
||||
"security token included in the request is invalid",
|
||||
"unable to locate credentials",
|
||||
"unauthorizedexception",
|
||||
"session token not found or invalid",
|
||||
"sso session has expired",
|
||||
"failed to load aws credentials",
|
||||
"bedrock credentials not configured",
|
||||
"credential should be scoped to a valid region",
|
||||
]
|
||||
.iter()
|
||||
|
||||
@@ -105,6 +105,10 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
|
||||
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
|
||||
"UnrecognizedClientException: The security token included in the request is invalid",
|
||||
"AccessDeniedException: not authorized to perform bedrock:ConverseStream",
|
||||
"UnauthorizedException: Session token not found or invalid",
|
||||
"SSO session has expired or is otherwise invalid",
|
||||
"Failed to load AWS credentials: credentials were not loaded",
|
||||
"Bedrock credentials not configured",
|
||||
] {
|
||||
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
|
||||
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
|
||||
@@ -112,3 +116,15 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
|
||||
assert!(mapped.user_message.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_provider_failures_do_not_become_authentication_failures() {
|
||||
for message in [
|
||||
"HTTP client error: connection reset",
|
||||
"ProviderError: temporarily unavailable",
|
||||
"ValidationException: invalid model",
|
||||
] {
|
||||
let mapped = map_completion_error(CompletionError::ProviderError(message.to_owned()));
|
||||
assert_ne!(mapped.kind, AgentErrorKind::Authentication);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ impl Harness {
|
||||
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Oz => "Oz",
|
||||
Self::Oz => "Galaxy",
|
||||
Self::Claude => "Claude Code",
|
||||
Self::OpenCode => "OpenCode",
|
||||
Self::Gemini => "Gemini CLI",
|
||||
|
||||
Reference in New Issue
Block a user