e2e testing

This commit is contained in:
2026-08-10 07:22:56 -05:00
parent fa43f723a5
commit 88ad290c7e
29 changed files with 1695 additions and 99 deletions
+26 -3
View File
@@ -19,11 +19,16 @@ pub async fn generate_multi_agent_output(
cancellation_rx: futures::channel::oneshot::Receiver<()>,
) -> Result<ResponseStream, ConvertToAPITypeError> {
let supported_tools_override = params.supported_tools_override.take();
let supported_tools = supported_tools_override
let mut supported_tools = supported_tools_override
.clone()
.unwrap_or_else(|| get_supported_tools(&params));
let supported_cli_agent_tools =
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
let mut supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(&params));
remove_orchestration_tools_if_disabled(
&mut supported_cli_agent_tools,
params.orchestration_enabled,
);
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
@@ -254,6 +259,21 @@ pub async fn generate_multi_agent_output(
}
}
fn remove_orchestration_tools_if_disabled(
supported_tools: &mut Vec<api::ToolType>,
orchestration_enabled: bool,
) {
if orchestration_enabled {
return;
}
supported_tools.retain(|tool| {
!matches!(
tool,
api::ToolType::Subagent | api::ToolType::RunAgents | api::ToolType::StartAgentV2
)
});
}
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
let mut supported_tools = vec![
api::ToolType::Grep,
@@ -262,7 +282,6 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::ReadMcpResource,
api::ToolType::CallMcpTool,
api::ToolType::RunShellCommand,
api::ToolType::Subagent,
api::ToolType::WriteToLongRunningShellCommand,
api::ToolType::ReadShellCommandOutput,
api::ToolType::ReadDocuments,
@@ -270,6 +289,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
api::ToolType::EditDocuments,
];
if params.orchestration_enabled {
supported_tools.push(api::ToolType::Subagent);
}
if FeatureFlag::ConversationsAsContext.is_enabled() {
supported_tools.push(api::ToolType::FetchConversation);
}
+37 -1
View File
@@ -2,7 +2,9 @@ use galaxy_core::features::FeatureFlag;
use galaxy_core::HostId;
use warp_multi_agent_api as api;
use super::{get_supported_cli_agent_tools, get_supported_tools};
use super::{
get_supported_cli_agent_tools, get_supported_tools, remove_orchestration_tools_if_disabled,
};
use crate::ai::agent::api::RequestParams;
use crate::ai::blocklist::SessionContext;
use crate::ai::llms::LLMId;
@@ -77,6 +79,40 @@ fn supported_tools_expose_local_subagents_without_hosted_orchestration_tools() {
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
}
#[test]
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
let params = request_params_with_ask_user_question_enabled(false);
let supported_tools = get_supported_tools(&params);
assert!(!supported_tools.contains(&api::ToolType::Subagent));
}
#[test]
fn supported_tool_override_cannot_restore_leaf_orchestration_tools() {
let mut supported_tools = vec![
api::ToolType::Grep,
api::ToolType::Subagent,
api::ToolType::RunAgents,
api::ToolType::StartAgentV2,
];
remove_orchestration_tools_if_disabled(&mut supported_tools, false);
assert_eq!(supported_tools, vec![api::ToolType::Grep]);
}
#[test]
fn enabled_orchestration_preserves_supported_tool_override() {
let mut supported_tools = vec![api::ToolType::Grep, api::ToolType::Subagent];
remove_orchestration_tools_if_disabled(&mut supported_tools, true);
assert_eq!(
supported_tools,
vec![api::ToolType::Grep, api::ToolType::Subagent]
);
}
#[test]
fn supported_tools_omit_hosted_only_capabilities() {
let params = request_params_with_ask_user_question_enabled(false);
+54 -1
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use warp_multi_agent_api as api;
@@ -360,6 +360,59 @@ impl TaskStore {
append_refs_for_task(tasks, &mut refs, root_task);
}
let indexed_task_ids = refs
.iter()
.map(|exchange_ref| exchange_ref.task_id.clone())
.collect::<HashSet<_>>();
let mut direct_cli_tasks = tasks
.values()
.filter(|task| {
!indexed_task_ids.contains(task.id())
&& task.parent_id().as_ref() == Some(root_task_id)
&& task.is_cli_subagent()
&& task
.subagent_params()
.is_some_and(|params| params.tool_call_id.is_empty())
&& task.exchanges().next().is_some()
})
.collect::<Vec<_>>();
direct_cli_tasks.sort_by(|left, right| {
left.exchanges()
.next()
.map(|exchange| exchange.start_time)
.cmp(&right.exchanges().next().map(|exchange| exchange.start_time))
.then_with(|| left.id().to_string().cmp(&right.id().to_string()))
});
// Direct providers synthesize CLI monitor tasks without a parent Subagent message.
// Place each task as one chronological block so its exchanges remain reachable without
// changing the DFS order of server-linked subtasks.
for task in direct_cli_tasks {
let first_start_time = task
.exchanges()
.next()
.expect("direct CLI task was filtered to contain an exchange")
.start_time;
let insertion_index = refs
.iter()
.position(|exchange_ref| {
tasks
.get(&exchange_ref.task_id)
.and_then(|task| task.exchanges().nth(exchange_ref.exchange_index))
.is_some_and(|exchange| exchange.start_time > first_start_time)
})
.unwrap_or(refs.len());
let task_id = task.id().clone();
let task_refs = task
.exchanges()
.enumerate()
.map(|(exchange_index, _)| ExchangeRef {
task_id: task_id.clone(),
exchange_index,
})
.collect::<Vec<_>>();
refs.splice(insertion_index..insertion_index, task_refs);
}
refs
}
}
+40
View File
@@ -146,6 +146,46 @@ fn test_insert_subtask() {
assert!(store.contains(&subtask_id));
}
#[test]
fn test_unlinked_direct_cli_task_is_linearized_chronologically() {
let base_time = Local::now();
let mut root_task = Task::new_optimistic_root();
let root_task_id = root_task.id().clone();
let mut before_cli = create_test_exchange();
before_cli.start_time = base_time;
let before_cli_id = before_cli.id;
root_task.append_exchange(before_cli);
let mut after_cli = create_test_exchange();
after_cli.start_time = base_time + chrono::Duration::seconds(2);
let after_cli_id = after_cli.id;
root_task.append_exchange(after_cli);
let mut cli_task =
Task::new_optimistic_cli_agent_subtask(BlockId::new(), Some(root_task_id.to_string()));
let mut cli_exchange = create_test_exchange();
cli_exchange.start_time = base_time + chrono::Duration::seconds(1);
let cli_exchange_id = cli_exchange.id;
cli_task.append_exchange(cli_exchange);
let mut store = TaskStore::with_root_task(root_task);
store.insert(cli_task);
let exchange_ids = store
.all_exchanges()
.map(|exchange| exchange.id)
.collect::<Vec<_>>();
assert_eq!(
exchange_ids,
vec![before_cli_id, cli_exchange_id, after_cli_id]
);
assert_eq!(
store.latest_exchange().map(|exchange| exchange.id),
Some(after_cli_id)
);
}
#[test]
fn test_remove_task() {
let task = create_test_task_with_exchanges(3);
+49 -1
View File
@@ -1655,7 +1655,7 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
"ask_user_question" => has(ToolType::AskUserQuestion),
"read_skill" => has(ToolType::ReadSkill),
"fetch_conversation" => has(ToolType::FetchConversation),
@@ -1910,6 +1910,54 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
"required": ["diffs"]
}),
},
ToolDefinition {
name: "run_agents".to_string(),
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and inherit the parent model.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"summary": { "type": "string", "description": "Brief explanation of why child agents help with this task" },
"base_prompt": { "type": "string", "default": "", "description": "Instructions prepended to every child prompt" },
"skills": {
"type": "array",
"items": {
"type": "object",
"properties": {
"skill": { "type": "string" },
"reference_type": { "type": "string", "enum": ["path", "bundled"] }
},
"required": ["skill", "reference_type"]
}
},
"model_id": { "type": "string", "default": "", "description": "Optional child model override; empty inherits the parent model" },
"harness_type": { "type": "string", "default": "", "description": "Optional harness identifier; empty selects the embedded local child runtime" },
"execution_mode": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["local", "remote"], "default": "local" },
"environment_id": { "type": "string", "default": "" },
"worker_host": { "type": "string", "default": "" },
"computer_use_enabled": { "type": "boolean", "default": false }
}
},
"agent_run_configs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Unique child name" },
"prompt": { "type": "string", "default": "", "description": "Child-specific instructions" },
"title": { "type": "string", "default": "", "description": "Optional display title" }
},
"required": ["name", "prompt"]
}
},
"plan_id": { "type": "string", "default": "", "description": "Optional associated plan document ID" }
},
"required": ["summary", "agent_run_configs"]
}),
},
ToolDefinition {
name: "start_agent".to_string(),
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
@@ -99,6 +99,7 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
vec![
"run_shell_command",
"read_files",
"run_agents",
"start_agent",
"recall_tool_history"
]
@@ -106,6 +106,27 @@ use crate::util::image::{
use crate::util::openable_file_type::is_binary_file;
use crate::BlocklistAIHistoryModel;
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
- Complete the assigned task directly and stay within its stated scope.
- Do not launch, delegate to, or create additional agents.
- Report blockers and completion to the lead through the available coordination channel."#;
pub(super) fn child_agent_delegation_denial_reason(
conversation_id: AIConversationId,
ctx: &AppContext,
) -> Option<String> {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_child_agent_conversation())
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
}
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
}
/// Types of actions that can be executed in parallel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ParallelExecutionPolicy {
@@ -2,7 +2,7 @@
//!
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
//! and aggregates the outcomes into a single `RunAgentsResult`.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
@@ -20,7 +20,10 @@ use warp_cli::agent::Harness;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::{
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
PreprocessActionInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
@@ -413,6 +416,9 @@ impl RunAgentsExecutor {
let AIAgentActionType::RunAgents(request) = &input.action.action else {
return false;
};
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
return true;
}
if AppExecutionMode::as_ref(ctx).is_autonomous() {
return true;
}
@@ -476,9 +482,9 @@ fn resolve_request_from_approved_config(
/// Normalizes the request and returns a denial reason when launch is blocked.
///
/// Autonomous agents always run: their calls may still inherit approved plan
/// config fields and default auth secrets, but they bypass interactive policy
/// denials because they cannot present a confirmation card.
/// Root autonomous agents bypass interactive policy denials because they cannot
/// present a confirmation card. Child-agent delegation is rejected before that
/// bypass, while allowed root calls still inherit approved config and auth fields.
fn prepare_request_for_execution(
request: &mut RunAgentsRequest,
parent_conversation_id: AIConversationId,
@@ -486,6 +492,10 @@ fn prepare_request_for_execution(
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return Some(reason);
}
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
populate_default_auth_secret_for_execution(request, ctx);
if let Some(reason) =
@@ -544,8 +554,11 @@ fn duplicate_launched_agents_reason(
let duplicates = requested_agents
.iter()
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Option<Vec<_>>>()?;
.filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
.collect::<Vec<_>>();
if duplicates.is_empty() {
return None;
}
let duplicate_list = duplicates
.iter()
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
@@ -696,6 +709,20 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
if request.agent_run_configs.is_empty() {
return Err("orchestrate: empty agent_run_configs".to_string());
}
let mut normalized_names = HashSet::new();
for config in &request.agent_run_configs {
let Some(normalized_name) = normalize_agent_name(&config.name) else {
return Err("orchestrate: agent names must not be empty".to_string());
};
if !normalized_names.insert(normalized_name) {
return Err(format!(
"orchestrate: duplicate agent name '{}' in the same batch",
config.name.trim()
));
}
}
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
if let Some(message) = local_harness_product_disabled_message(harness) {
@@ -91,6 +91,15 @@ fn persist_plan_config_with_harness(
});
}
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
});
}
#[test]
fn should_autoexecute_duplicate_launched_agent_denial() {
App::test((), |mut app| async move {
@@ -162,6 +171,159 @@ fn execute_denies_duplicate_launched_agent() {
});
}
#[test]
fn execute_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
executor.should_autoexecute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
});
assert!(
should_autoexecute,
"the denial should not require user approval"
);
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
mark_conversation_as_child(&mut app, state.conversation_id);
let action = remote_run_agents_action("oz");
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("leaf workers")
));
});
}
#[test]
fn execute_denies_mixed_batch_containing_launched_agent() {
App::test((), |mut app| async move {
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
state.executor.update(&mut app, |executor, _ctx| {
executor.record_launched_agents(
state.conversation_id,
&[RunAgentsAgentOutcome {
name: "child".to_string(),
kind: RunAgentsAgentOutcomeKind::Launched {
agent_id: "agent-123".to_string(),
},
}],
);
});
let mut action = remote_run_agents_action("oz");
let AIAgentActionType::RunAgents(request) = &mut action.action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "new-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
let execution = state.executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: state.conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
RunAgentsResult::Denied { reason }
)) if reason.contains("child (agent-123)")
));
});
}
#[test]
fn validate_request_rejects_blank_and_duplicate_agent_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
request.agent_run_configs[0].name = " ".to_string();
assert_eq!(
validate_request(&request),
Err("orchestrate: agent names must not be empty".to_string())
);
request.agent_run_configs[0].name = "Child".to_string();
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: " child ".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(
validate_request(&request),
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
);
}
#[test]
fn validate_request_allows_unique_sibling_names() {
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
panic!("expected run_agents action");
};
request.agent_run_configs.push(RunAgentsAgentRunConfig {
name: "second-child".to_string(),
prompt: "Do separate work".to_string(),
title: String::new(),
});
assert_eq!(validate_request(&request), Ok(()));
}
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
initialize_settings_for_tests_with_mode(app, mode, false);
let global_resource_handles = GlobalResourceHandles::mock(app);
@@ -6,7 +6,10 @@ use galaxy_cli::agent::Harness;
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use shell_words::split as split_shell_words;
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
use super::{
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::{
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
@@ -400,12 +403,19 @@ impl StartAgentExecutor {
return ActionExecution::InvalidAction;
};
let prompt = prompt.clone();
let version = *version;
let action_id = input.action.id.clone();
let parent_conversation_id = input.conversation_id;
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, version },
));
}
let prompt = prompt.clone();
let action_id = input.action.id.clone();
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
let prompt = compose_leaf_agent_prompt(&prompt);
let (execution_mode, parent_run_id) = match execution_mode {
StartAgentExecutionMode::Local {
harness_type: None,
@@ -597,9 +607,15 @@ impl StartAgentExecutor {
parent_run_id: Option<String>,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<StartAgentOutcome> {
let (sender, receiver) = async_channel::bounded(1);
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
let _ = sender.try_send(StartAgentOutcome::Error(error));
return receiver;
}
let (prompt, execution_mode) =
normalize_legacy_local_child_harness_command(prompt, execution_mode);
let (sender, receiver) = async_channel::bounded(1);
let prompt = compose_leaf_agent_prompt(&prompt);
let request_id = self.next_request_id();
self.pending.insert(
request_id,
@@ -28,6 +28,28 @@ impl Entity for CapturedDirectProviderChildLinks {
type Event = ();
}
#[derive(Default)]
struct CapturedStartAgentPrompts(Vec<String>);
impl Entity for CapturedStartAgentPrompts {
type Event = ();
}
fn capture_start_agent_prompts(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
) -> ModelHandle<CapturedStartAgentPrompts> {
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
captured.update(app, |_, ctx| {
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
if let StartAgentExecutorEvent::CreateAgent(request) = event {
captured.0.push(request.prompt.clone());
}
});
});
captured
}
fn capture_direct_provider_child_links(
app: &mut App,
executor: &ModelHandle<StartAgentExecutor>,
@@ -79,6 +101,126 @@ fn build_start_agent_action_with_prompt(
}
}
#[test]
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let captured = capture_start_agent_prompts(&mut app, &executor);
let root_conversation_id = history_model.update(&mut app, |history, ctx| {
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: root_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(execution, AnyActionExecution::Async { .. }));
captured.read(&app, |captured, _ctx| {
assert_eq!(captured.0.len(), 1);
assert!(captured.0[0].contains("You are a leaf worker"));
assert!(
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
);
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
});
});
}
#[test]
fn execute_denies_start_agent_from_child_conversation() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let action = build_start_agent_action(
StartAgentVersion::V1,
StartAgentExecutionMode::local_with_defaults(),
);
let execution = executor.update(&mut app, |executor, ctx| {
executor
.execute(
ExecuteActionInput {
action: &action,
conversation_id: child_conversation_id,
},
ctx,
)
.into()
});
assert!(matches!(
execution,
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
StartAgentResult::Error { error, .. }
)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn dispatch_denies_child_conversation_defense_in_depth() {
App::test((), |mut app| async move {
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let executor = app.add_model(StartAgentExecutor::new);
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
history
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.set_parent_agent_id("parent-agent".to_string());
conversation_id
});
let receiver = executor.update(&mut app, |executor, ctx| {
executor.dispatch(
"grandchild".to_string(),
"Do more work".to_string(),
StartAgentExecutionMode::local_with_defaults(),
None,
child_conversation_id,
None,
ctx,
)
});
assert!(matches!(
receiver.try_recv(),
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
));
executor.read(&app, |executor, _ctx| {
assert!(executor.pending.is_empty());
});
});
}
#[test]
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
+7
View File
@@ -3044,6 +3044,13 @@ impl BlocklistAIController {
});
request_params.parent_agent_id = parent_agent_id;
request_params.agent_name = agent_name;
if history_model
.as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.is_child_agent_conversation())
{
request_params.orchestration_enabled = false;
}
request_params.message_history = bedrock_history;
request_params.tool_result_archive = bedrock_tool_result_archive;
request_params.progressive_summary = bedrock_progressive_summary;
+53 -9
View File
@@ -560,9 +560,31 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() {
}
#[test]
fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() {
fn chatgpt_reasoning_modes_route_with_catalog_context_metadata() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let provider = crate::settings::ai::default_chatgpt_provider();
let configured_model = |model_id: &str| {
provider
.models
.iter()
.find(|model| model.model_id == model_id)
.expect("ChatGPT model should be configured")
};
let gpt_54 = configured_model("gpt-5.4");
assert_eq!(gpt_54.context_size, 1_000_000);
assert_eq!(gpt_54.max_input_tokens, Some(950_000));
let gpt_56_sol = configured_model("gpt-5.6-sol");
assert_eq!(gpt_56_sol.context_size, 272_000);
assert_eq!(gpt_56_sol.max_input_tokens, Some(258_400));
let codex_spark = configured_model("gpt-5.3-codex-spark");
assert_eq!(codex_spark.context_size, 128_000);
assert_eq!(codex_spark.max_input_tokens, Some(121_600));
let uncached_model = configured_model("gpt-5.4-pro");
assert_eq!(uncached_model.context_size, 200_000);
assert_eq!(uncached_model.max_input_tokens, None);
AISettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.bedrock_enabled
@@ -582,34 +604,56 @@ fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() {
.expect("OpenAI model setting should update");
settings
.openai_providers
.set_value(vec![crate::settings::ai::default_chatgpt_provider()], ctx)
.set_value(vec![provider], ctx)
.expect("OpenAI provider setting should update");
});
let mut preferences = empty_preferences();
app.read(|ctx| preferences.inject_openai_models(ctx));
let model_info = |model_id: &str| {
preferences
.models_by_feature
.agent_mode
.choices
.iter()
.find(|model| model.id.as_str() == model_id)
.expect("ChatGPT model should be available")
};
let assert_fixed_context = |model_id: &str, expected: u32| {
let context_window = &model_info(model_id).context_window;
assert!(!context_window.is_configurable);
assert_eq!(context_window.min, expected);
assert_eq!(context_window.max, expected);
assert_eq!(context_window.default_max, expected);
};
let mode_id = "gpt-5.4::reasoning::high";
let mode = preferences
.models_by_feature
.agent_mode
.choices
.iter()
.find(|model| model.id.as_str() == mode_id)
.expect("GPT-5.4 high mode should be available");
let mode = model_info(mode_id);
assert_eq!(mode.reasoning_level.as_deref(), Some("high"));
assert_fixed_context(mode_id, 950_000);
let routing = preferences
.openai_client_config_for_model(mode_id)
.expect("reasoning mode should have a routing entry");
assert_eq!(routing.model.as_deref(), Some("gpt-5.4"));
assert_eq!(routing.reasoning_effort.as_deref(), Some("high"));
assert_eq!(routing.max_input_tokens, Some(950_000));
let ultra_id = "gpt-5.6-sol::reasoning::ultra";
assert_fixed_context(ultra_id, 258_400);
let ultra_routing = preferences
.openai_client_config_for_model(ultra_id)
.expect("GPT-5.6 Sol ultra mode should have a routing entry");
assert_eq!(ultra_routing.model.as_deref(), Some("gpt-5.6-sol"));
assert_eq!(ultra_routing.reasoning_effort.as_deref(), Some("ultra"));
assert_eq!(ultra_routing.max_input_tokens, Some(258_400));
let spark_id = "gpt-5.3-codex-spark";
assert_fixed_context(spark_id, 121_600);
let spark_routing = preferences
.openai_client_config_for_model(spark_id)
.expect("GPT-5.3 Codex Spark should have a routing entry");
assert_eq!(spark_routing.max_input_tokens, Some(121_600));
});
}
+19
View File
@@ -283,6 +283,25 @@ fn bedrock_rig_turn_uses_bedrock_history_invariants_without_a_proto_round_trip()
assert_eq!(prepared.request.messages, prepared.persistent_messages);
}
#[test]
fn modern_and_legacy_orchestration_tools_follow_subagent_capabilities() {
for capability in [ToolType::Subagent, ToolType::StartAgentV2] {
let (tools, _) = tool_definitions(&[capability], None);
let names = tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"run_agents"));
assert!(names.contains(&"start_agent"));
}
let (leaf_tools, _) = tool_definitions(&[ToolType::Grep], None);
assert!(!leaf_tools
.iter()
.any(|tool| matches!(tool.name.as_str(), "run_agents" | "start_agent")));
}
#[test]
#[allow(deprecated)]
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
+70 -2
View File
@@ -12,8 +12,9 @@ use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentPtyWriteMode, AskUserQuestionItem,
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
ReadFilesRequest, ReadSkillRequest, SearchCodebaseRequest, ShellCommandDelay,
StartAgentExecutionMode, StartAgentVersion,
ReadFilesRequest, ReadSkillRequest, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay, StartAgentExecutionMode,
StartAgentVersion,
};
use crate::ai::document::ai_document_model::AIDocumentId;
@@ -140,6 +141,27 @@ pub(super) fn action_from_tool_call(
.collect(),
})
}
"run_agents" => AIAgentActionType::RunAgents(RunAgentsRequest {
summary: string(input, "summary"),
base_prompt: string(input, "base_prompt"),
skills: skill_references(input, skill_path_origin),
model_id: string(input, "model_id"),
harness_type: string(input, "harness_type"),
execution_mode: run_agents_execution_mode(input),
agent_run_configs: input
.get("agent_run_configs")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|config| RunAgentsAgentRunConfig {
name: string(config, "name"),
prompt: string(config, "prompt"),
title: string(config, "title"),
})
.collect(),
plan_id: string(input, "plan_id"),
harness_auth_secret_name: None,
}),
"start_agent" => AIAgentActionType::StartAgent {
version: StartAgentVersion::V1,
name: string(input, "name"),
@@ -263,6 +285,52 @@ fn uuid(input: &serde_json::Value, key: &str) -> Option<Uuid> {
.and_then(|value| Uuid::parse_str(value).ok())
}
fn skill_references(
input: &serde_json::Value,
skill_path_origin: &SkillPathOrigin,
) -> Vec<SkillReference> {
input
.get("skills")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(|skill| {
let reference = string(skill, "skill");
if reference.is_empty() {
return None;
}
match skill
.get("reference_type")
.and_then(serde_json::Value::as_str)
{
Some("bundled") => Some(SkillReference::BundledSkillId(reference)),
Some("path") | Some(_) | None => skill_path_origin
.location_for_path(reference)
.ok()
.map(SkillReference::Path),
}
})
.collect()
}
fn run_agents_execution_mode(input: &serde_json::Value) -> RunAgentsExecutionMode {
let Some(execution_mode) = input.get("execution_mode") else {
return RunAgentsExecutionMode::Local;
};
let mode_type = execution_mode
.get("type")
.and_then(serde_json::Value::as_str)
.or_else(|| execution_mode.as_str());
match mode_type {
Some("remote") => RunAgentsExecutionMode::Remote {
environment_id: string(execution_mode, "environment_id"),
worker_host: string(execution_mode, "worker_host"),
computer_use_enabled: boolean(execution_mode, "computer_use_enabled"),
},
Some("local") | Some(_) | None => RunAgentsExecutionMode::Local,
}
}
fn file_location(file: &serde_json::Value) -> Option<FileLocations> {
if let Some(name) = file.as_str() {
return Some(FileLocations {
+105 -1
View File
@@ -6,7 +6,7 @@ use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::ToolCall;
use super::{action_from_tool_call, MCPToolTarget};
use crate::ai::agent::{AIAgentActionType, FileEdit};
use crate::ai::agent::{AIAgentActionType, FileEdit, RunAgentsExecutionMode};
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
ToolCall {
@@ -164,6 +164,110 @@ fn local_skill_paths_preserve_the_session_origin() {
));
}
#[test]
fn run_agents_calls_decode_to_local_domain_requests_with_safe_defaults() {
let action = action_from_tool_call(
"task-1",
&call(
"run_agents",
serde_json::json!({
"summary": "Parallel investigation",
"base_prompt": "Inspect before changing files.",
"agent_run_configs": [
{
"name": "runtime",
"prompt": "Inspect runtime behavior",
"title": "Runtime investigator"
},
{
"name": "tests",
"prompt": "Design focused tests"
}
]
}),
),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
let AIAgentActionType::RunAgents(request) = action.action else {
panic!("expected run-agents action");
};
assert_eq!(request.summary, "Parallel investigation");
assert_eq!(request.base_prompt, "Inspect before changing files.");
assert!(request.skills.is_empty());
assert!(request.model_id.is_empty());
assert!(request.harness_type.is_empty());
assert_eq!(request.execution_mode, RunAgentsExecutionMode::Local);
assert!(request.plan_id.is_empty());
assert!(request.harness_auth_secret_name.is_none());
assert_eq!(request.agent_run_configs.len(), 2);
assert_eq!(request.agent_run_configs[0].name, "runtime");
assert_eq!(
request.agent_run_configs[0].prompt,
"Inspect runtime behavior"
);
assert_eq!(request.agent_run_configs[0].title, "Runtime investigator");
assert_eq!(request.agent_run_configs[1].name, "tests");
assert_eq!(request.agent_run_configs[1].prompt, "Design focused tests");
assert!(request.agent_run_configs[1].title.is_empty());
}
#[test]
fn run_agents_calls_preserve_remote_config_and_skills() {
let action = action_from_tool_call(
"task-1",
&call(
"run_agents",
serde_json::json!({
"summary": "Remote investigation",
"model_id": "remote-model",
"harness_type": "codex",
"execution_mode": {
"type": "remote",
"environment_id": "env-1",
"worker_host": "worker.example",
"computer_use_enabled": true
},
"skills": [
{"skill": "galaxyctrl", "reference_type": "bundled"},
{"skill": "/repo/SKILL.md", "reference_type": "path"}
],
"agent_run_configs": [{"name": "remote", "prompt": "Inspect"}],
"plan_id": "plan-1"
}),
),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
let AIAgentActionType::RunAgents(request) = action.action else {
panic!("expected run-agents action");
};
assert_eq!(request.model_id, "remote-model");
assert_eq!(request.harness_type, "codex");
assert_eq!(request.plan_id, "plan-1");
assert_eq!(
request.skills,
vec![
SkillReference::BundledSkillId("galaxyctrl".to_string()),
SkillReference::Path(galaxy_util::local_or_remote_path::LocalOrRemotePath::Local(
PathBuf::from("/repo/SKILL.md")
)),
]
);
assert_eq!(
request.execution_mode,
RunAgentsExecutionMode::Remote {
environment_id: "env-1".to_string(),
worker_host: "worker.example".to_string(),
computer_use_enabled: true,
}
);
}
#[test]
fn unknown_tools_are_rejected_before_the_permission_boundary() {
let error = action_from_tool_call(
@@ -92,6 +92,99 @@ pub fn assert_latest_exchange_text(
})
}
/// Asserts that the active conversation created exactly one hidden leaf child
/// and that the child completed with the expected identity and output.
pub fn assert_single_hidden_child_agent_succeeds(
expected_agent_name: &'static str,
expected_output: &'static str,
) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
let Some(parent) = history_model.active_conversation(terminal_view.id()) else {
return AssertionOutcome::failure("No active parent conversation".to_owned());
};
let parent_id = parent.id();
let children = history_model.child_conversations_of(parent_id);
let child = match children.as_slice() {
[] => {
return AssertionOutcome::failure(
"Waiting for the hidden child conversation".to_owned(),
);
}
[child] => *child,
_ => {
return AssertionOutcome::immediate_failure(format!(
"Expected exactly one child conversation, found {}",
children.len()
));
}
};
match child.status() {
ConversationStatus::Success => {}
ConversationStatus::InProgress
| ConversationStatus::TransientError
| ConversationStatus::WaitingForEvents => {
return AssertionOutcome::failure(format!(
"Waiting for child agent to succeed; current status: {:?}",
child.status()
));
}
ConversationStatus::Blocked { .. }
| ConversationStatus::Error
| ConversationStatus::Cancelled => {
return AssertionOutcome::immediate_failure(format!(
"Child agent finished unsuccessfully: {:?}",
child.status()
));
}
}
if child.agent_name() != Some(expected_agent_name) {
return AssertionOutcome::immediate_failure(format!(
"Expected child name {expected_agent_name:?}, found {:?}",
child.agent_name()
));
}
if child.parent_conversation_id() != Some(parent_id) {
return AssertionOutcome::immediate_failure(format!(
"Child {:?} was not linked to active parent {parent_id:?}",
child.id()
));
}
if !child.is_child_agent_conversation() || !child.should_exclude_from_navigation() {
return AssertionOutcome::immediate_failure(
"Child conversation was not hidden from normal navigation".to_owned(),
);
}
let grandchildren = history_model.child_conversations_of(child.id());
if !grandchildren.is_empty() {
return AssertionOutcome::immediate_failure(format!(
"Leaf child unexpectedly created {} grandchildren",
grandchildren.len()
));
}
let output = child
.all_exchanges()
.into_iter()
.filter_map(|exchange| exchange.output_status.output())
.map(|output| output.get().format_for_copy(None))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !output.contains(expected_output) {
return AssertionOutcome::immediate_failure(format!(
"Child output did not contain {expected_output:?}: {output}"
));
}
AssertionOutcome::Success
})
})
}
// Make an assertion on the action requested in the exchange at exchange_index.
/// This is private because `AIAgentActionType` is not public outside the warp app crate
/// for use within agent mode evals, so they can't write the `ActionAssertion` directly.
@@ -180,22 +273,41 @@ pub fn assert_any_exchange_text(
Box::new(move |app, window_id| {
let terminal_view = terminal_view(app, window_id, 0, 0);
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
let exchange_count = get_exchange_count(terminal_view.id(), history_model);
(0..exchange_count)
.map(|exchange_index| {
exchange_succeeds_with_expected_output(
Some(Box::new(assertion.clone())),
None,
ConversationTarget::Active,
terminal_view.id(),
exchange_index,
history_model,
)
})
.find(|outcome| matches!(outcome, AssertionOutcome::Success))
.unwrap_or(AssertionOutcome::failure(
"No exchanges match assertion".to_owned(),
))
let Some(conversation) = history_model.active_conversation(terminal_view.id()) else {
return AssertionOutcome::failure("No active conversation".to_owned());
};
let mut output_texts = Vec::with_capacity(conversation.exchange_count());
for exchange in conversation.all_exchanges() {
let AIAgentOutputStatus::Finished { finished_output } = &exchange.output_status
else {
return AssertionOutcome::failure(format!(
"Exchange {:?} is not finished",
exchange.id
));
};
match finished_output {
FinishedAIAgentOutput::Success { output } => {
let text = output.get().format_for_copy(None);
if assertion(&text) {
return AssertionOutcome::Success;
}
output_texts.push(text);
}
FinishedAIAgentOutput::Error { error, .. } => {
return AssertionOutcome::immediate_failure(format!(
"Exchange failed with error: {error:?}"
));
}
FinishedAIAgentOutput::Cancelled { .. } => {
return AssertionOutcome::immediate_failure(
"Exchange was cancelled".to_owned(),
);
}
}
}
AssertionOutcome::failure(format!(
"No exchanges match assertion. Exchange outputs: {output_texts:?}"
))
})
})
}
+15 -1
View File
@@ -9,7 +9,7 @@ use galaxyui::{async_assert, SingletonEntity};
use prost::Message;
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::ActionPermission;
use crate::ai::execution_profiles::{ActionPermission, RunAgentsPermission};
use crate::ai::llms::{LLMId, LLMPreferences};
use crate::ai::mcp::{
JsonTemplate, TemplatableMCPServer, TemplatableMCPServerInstallation,
@@ -284,6 +284,20 @@ pub fn set_execution_profile_auto_execute() -> TestStep {
)
}
/// Sets the execution profile to auto-run child agents.
pub fn set_execution_profile_auto_run_agents() -> TestStep {
TestStep::new("Set execution profile to auto-run child agents").add_named_assertion(
"Update execution profile",
|app, _window_id| {
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
let default_profile_id = *profiles.default_profile(ctx).id();
profiles.set_run_agents(default_profile_id, RunAgentsPermission::AlwaysAllow, ctx);
});
async_assert!(true, "Successfully updated execution profile")
},
)
}
/// Sets the execution profile to auto-apply code diffs.
pub fn set_execution_profile_auto_apply_code_diffs() -> TestStep {
TestStep::new("Set execution profile to auto-apply code diffs").add_named_assertion(
@@ -807,8 +807,12 @@ pub fn assert_active_session_local_path(expected_path: &'static str) -> Assertio
}
pub fn assert_input_is_focused() -> AssertionCallback {
Box::new(|app, window_id| {
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
assert_input_is_focused_for_pane(0, 0)
}
pub fn assert_input_is_focused_for_pane(tab_index: usize, pane_index: usize) -> AssertionCallback {
Box::new(move |app, window_id| {
let terminal_view = terminal_view(app, window_id, tab_index, pane_index);
terminal_view.read(app, |view, ctx| {
let is_input_focused = view.input().as_ref(ctx).editor().as_ref(ctx).is_focused();
async_assert!(is_input_focused)
@@ -167,6 +167,15 @@ fn build_local_codex_child_command_quotes_the_prompt() {
);
}
#[test]
fn local_harness_commands_preserve_leaf_worker_contract() {
let prompt = "You are a leaf worker. Do not launch additional agents.";
assert!(local_claude_child_prompt(prompt).contains(prompt));
assert!(build_local_codex_child_command(prompt).contains(prompt));
assert!(build_local_opencode_child_command(prompt).contains(prompt));
}
#[test]
fn local_child_task_config_records_supported_third_party_harnesses() {
for harness in [Harness::Claude, Harness::OpenCode, Harness::Codex] {
+59 -23
View File
@@ -1033,58 +1033,94 @@ const INITIAL_RIG_MODEL_ID: &str = "codex-gpt-5.6-sol-xhigh";
fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
// The ChatGPT OAuth backend does not expose a model-listing capability through Rig,
// so keep this catalog small and explicit. Reasoning variants are expanded into
// selectable LLM entries when the provider is injected into the runtime inventory.
// so keep this catalog small and explicit. Context limits come from Codex model
// metadata; models absent from that catalog retain the generic fallback.
[
(
"gpt-5.6-sol",
"GPT-5.6 Sol",
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
272_000,
Some(258_400),
),
(
"gpt-5.6-terra",
"GPT-5.6 Terra",
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
272_000,
Some(258_400),
),
(
"gpt-5.6-luna",
"GPT-5.6 Luna",
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
272_000,
Some(258_400),
),
(
"gpt-5.4",
"GPT-5.4",
vec!["low", "medium", "high", "xhigh"],
1_000_000,
Some(950_000),
),
("gpt-5.4", "GPT-5.4", vec!["low", "medium", "high", "xhigh"]),
(
"gpt-5.4-pro",
"GPT-5.4 Pro",
vec!["medium", "high", "xhigh"],
default_context_size(),
None,
),
(
"gpt-5.3-codex",
"GPT-5.3 Codex",
vec!["low", "medium", "high", "xhigh"],
default_context_size(),
None,
),
(
"gpt-5.3-codex-spark",
"GPT-5.3 Codex Spark",
vec![],
128_000,
Some(121_600),
),
(
"gpt-5.3-instant",
"GPT-5.3 Instant",
vec![],
default_context_size(),
None,
),
(
"gpt-5.3-chat-latest",
"GPT-5.3 Chat Latest",
vec![],
default_context_size(),
None,
),
("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", vec![]),
("gpt-5.3-instant", "GPT-5.3 Instant", vec![]),
("gpt-5.3-chat-latest", "GPT-5.3 Chat Latest", vec![]),
]
.into_iter()
.map(
|(model_id, display_name, reasoning_efforts)| OpenAIModelConfig {
model_id: model_id.to_string(),
display_name: display_name.to_string(),
// ChatGPT's subscription backend accepts image input for its chat
// models, but it does not expose a public capability discovery
// endpoint. Keep this explicit catalog in sync with that contract
// so the model picker does not hide vision context.
vision_supported: true,
context_size: default_context_size(),
max_input_tokens: None,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: HashMap::new(),
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
enabled: true,
|(model_id, display_name, reasoning_efforts, context_size, max_input_tokens)| {
OpenAIModelConfig {
model_id: model_id.to_string(),
display_name: display_name.to_string(),
// ChatGPT's subscription backend accepts image input for its chat
// models, but it does not expose a public capability discovery
// endpoint. Keep this explicit catalog in sync with that contract
// so the model picker does not hide vision context.
vision_supported: true,
context_size,
max_input_tokens,
max_output_tokens: None,
provider: Some("openai".to_string()),
use_rig: true,
supports_system_messages: Some(true),
capability_overrides: HashMap::new(),
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
enabled: true,
}
},
)
.collect()
@@ -1145,7 +1145,7 @@ impl ProviderSetupModalBody {
if let ChatGPTAuthState::Failed(error) = &state {
children.push(
Text::new(error.clone(), appearance.monospace_font_family(), 11.)
.with_color(appearance.theme().ui_error_color().into())
.with_color(appearance.theme().ui_error_color())
.soft_wrap(true)
.finish(),
);