mirror of
https://github.com/openai/codex.git
synced 2026-06-02 19:31:59 +00:00
Compare commits
20 Commits
starr/read
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b0399034 | ||
|
|
3e666dd32a | ||
|
|
e29bbb5368 | ||
|
|
f4e9d2caac | ||
|
|
96f1347fa3 | ||
|
|
5577a9e148 | ||
|
|
251b2412b2 | ||
|
|
b40ad0d84d | ||
|
|
27e256bc40 | ||
|
|
1c55bb2702 | ||
|
|
3deda3116c | ||
|
|
191c39aa75 | ||
|
|
43fa4e5d25 | ||
|
|
5c1387846d | ||
|
|
e2b8ec616a | ||
|
|
3d3cc5a953 | ||
|
|
1197c7d654 | ||
|
|
a9a92cbb0a | ||
|
|
fc8c723553 | ||
|
|
8f6a945ec9 |
17
codex-rs/Cargo.lock
generated
17
codex-rs/Cargo.lock
generated
@@ -2719,18 +2719,6 @@ dependencies = [
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-debug-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-app-server-protocol",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-exec"
|
||||
version = "0.0.0"
|
||||
@@ -3045,7 +3033,6 @@ name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"codex-api",
|
||||
"codex-core",
|
||||
"codex-extension-api",
|
||||
@@ -3060,9 +3047,6 @@ dependencies = [
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4186,6 +4170,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde_json",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,7 +14,6 @@ members = [
|
||||
"app-server-client",
|
||||
"app-server-protocol",
|
||||
"app-server-test-client",
|
||||
"debug-client",
|
||||
"apply-patch",
|
||||
"arg0",
|
||||
"feedback",
|
||||
|
||||
@@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,19 @@ use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_app_server_protocol::WebSearchAction;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -84,10 +90,11 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
let thread_id = thread.id.clone();
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
thread_id: thread_id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Search the web".to_string(),
|
||||
@@ -103,6 +110,13 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
let started = timeout(DEFAULT_READ_TIMEOUT, wait_for_web_search_started(&mut mcp)).await??;
|
||||
let completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
wait_for_web_search_completed(&mut mcp),
|
||||
)
|
||||
.await??;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
@@ -159,10 +173,83 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
}],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
started.item,
|
||||
ThreadItem::WebSearch {
|
||||
id: call_id.to_string(),
|
||||
query: String::new(),
|
||||
action: Some(WebSearchAction::Other),
|
||||
}
|
||||
);
|
||||
let expected_completed_item = ThreadItem::WebSearch {
|
||||
id: call_id.to_string(),
|
||||
query: "standalone web search".to_string(),
|
||||
action: Some(WebSearchAction::Search {
|
||||
query: Some("standalone web search".to_string()),
|
||||
queries: None,
|
||||
}),
|
||||
};
|
||||
assert_eq!(completed.item, expected_completed_item);
|
||||
|
||||
drop(mcp);
|
||||
let mut reloaded_mcp =
|
||||
McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??;
|
||||
let read_req = reloaded_mcp
|
||||
.send_thread_read_request(ThreadReadParams {
|
||||
thread_id,
|
||||
include_turns: true,
|
||||
})
|
||||
.await?;
|
||||
let read_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
reloaded_mcp.read_stream_until_response_message(RequestId::Integer(read_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
|
||||
let persisted_web_searches: Vec<&ThreadItem> = thread
|
||||
.turns
|
||||
.iter()
|
||||
.flat_map(|turn| &turn.items)
|
||||
.filter(|item| matches!(item, ThreadItem::WebSearch { .. }))
|
||||
.collect();
|
||||
assert_eq!(persisted_web_searches, vec![&expected_completed_item]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_web_search_started(mcp: &mut McpProcess) -> Result<ItemStartedNotification> {
|
||||
loop {
|
||||
let notification = mcp
|
||||
.read_stream_until_notification_message("item/started")
|
||||
.await?;
|
||||
let started: ItemStartedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.context("item/started notification should include params")?,
|
||||
)?;
|
||||
if matches!(&started.item, ThreadItem::WebSearch { .. }) {
|
||||
return Ok(started);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_web_search_completed(mcp: &mut McpProcess) -> Result<ItemCompletedNotification> {
|
||||
loop {
|
||||
let notification = mcp
|
||||
.read_stream_until_notification_message("item/completed")
|
||||
.await?;
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.context("item/completed notification should include params")?,
|
||||
)?;
|
||||
if matches!(&completed.item, ThreadItem::WebSearch { .. }) {
|
||||
return Ok(completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mount_search_response(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/alpha/search"))
|
||||
|
||||
@@ -17,8 +17,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- Runs raw JavaScript -- no Node, no file system, no network access, no console.
|
||||
- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
|
||||
- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
|
||||
- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running.
|
||||
- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens.
|
||||
- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
|
||||
- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
|
||||
- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
|
||||
|
||||
- Global helpers:
|
||||
@@ -34,9 +34,9 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
|
||||
const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
|
||||
- `cell_id` identifies the running `exec` cell to resume.
|
||||
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
|
||||
- `max_tokens` limits how much new output this wait call returns.
|
||||
- `terminate: true` stops the running cell instead of waiting for more output.
|
||||
- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms.
|
||||
- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens.
|
||||
- `terminate: true` stops the running cell; false or omitted waits for output.
|
||||
- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
|
||||
- If the cell is still running, `wait` may yield again with the same `cell_id`.
|
||||
- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
|
||||
|
||||
@@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AdditionalContextEntry;
|
||||
@@ -265,20 +264,16 @@ impl CodexThread {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Injects hidden model-visible items into the currently active turn.
|
||||
/// Injects model-visible items into the currently active turn.
|
||||
///
|
||||
/// This is the runtime-owned counterpart to user-facing `steer_input`.
|
||||
/// This is the thread-level bridge to `Session::inject_if_running` for
|
||||
/// callers that only hold a `CodexThread`.
|
||||
/// It returns the unchanged items when this thread has no active turn.
|
||||
pub async fn inject_response_items_into_active_turn(
|
||||
pub async fn inject_if_running(
|
||||
&self,
|
||||
items: Vec<ResponseInputItem>,
|
||||
) -> Result<(), Vec<ResponseInputItem>> {
|
||||
let response_items = items.iter().cloned().map(ResponseItem::from).collect();
|
||||
self.codex
|
||||
.session
|
||||
.inject_if_running(response_items)
|
||||
.await
|
||||
.map_err(|_| items)
|
||||
items: Vec<ResponseItem>,
|
||||
) -> Result<(), Vec<ResponseItem>> {
|
||||
self.codex.session.inject_if_running(items).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_info(
|
||||
@@ -396,7 +391,7 @@ impl CodexThread {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Append raw Responses API items to the thread's model-visible history.
|
||||
/// Record raw Responses API items without starting a new turn.
|
||||
pub async fn inject_response_items(&self, items: Vec<ResponseItem>) -> CodexResult<()> {
|
||||
if items.is_empty() {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
|
||||
@@ -24,6 +24,8 @@ use toml_edit::value;
|
||||
|
||||
const NOTICE_TABLE_KEY: &str = "notice";
|
||||
|
||||
mod document_helpers;
|
||||
|
||||
/// Discrete config mutations supported by the persistence engine.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ConfigEdit {
|
||||
@@ -198,319 +200,6 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) ->
|
||||
edits
|
||||
}
|
||||
|
||||
// TODO(jif) move to a dedicated file
|
||||
mod document_helpers {
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::InlineTable;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::Value as TomlValue;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(table) => Some(table),
|
||||
TomlItem::Value(value) => {
|
||||
if let Some(inline) = value.as_inline_table() {
|
||||
*item = TomlItem::Table(table_from_inline(inline));
|
||||
item.as_table_mut()
|
||||
} else {
|
||||
*item = TomlItem::Table(new_implicit_table());
|
||||
item.as_table_mut()
|
||||
}
|
||||
}
|
||||
TomlItem::None => {
|
||||
*item = TomlItem::Table(new_implicit_table());
|
||||
item.as_table_mut()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(table) => Some(table),
|
||||
TomlItem::Value(value) => {
|
||||
let inline = value.as_inline_table()?;
|
||||
*item = TomlItem::Table(table_from_inline(inline));
|
||||
item.as_table_mut()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
|
||||
match &config.transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
entry["command"] = value(command.clone());
|
||||
if !args.is_empty() {
|
||||
entry["args"] = array_from_iter(args.iter().cloned());
|
||||
}
|
||||
if let Some(env) = env
|
||||
&& !env.is_empty()
|
||||
{
|
||||
entry["env"] = table_from_pairs(env.iter());
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
entry["env_vars"] = array_from_env_vars(env_vars);
|
||||
}
|
||||
if let Some(cwd) = cwd {
|
||||
entry["cwd"] = value(cwd.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
entry["url"] = value(url.clone());
|
||||
if let Some(env_var) = bearer_token_env_var {
|
||||
entry["bearer_token_env_var"] = value(env_var.clone());
|
||||
}
|
||||
if let Some(headers) = http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
entry["http_headers"] = table_from_pairs(headers.iter());
|
||||
}
|
||||
if let Some(headers) = env_http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
entry["env_http_headers"] = table_from_pairs(headers.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.enabled {
|
||||
entry["enabled"] = value(false);
|
||||
}
|
||||
if !config.is_local_environment() {
|
||||
entry["environment_id"] = value(config.environment_id.clone());
|
||||
}
|
||||
if config.required {
|
||||
entry["required"] = value(true);
|
||||
}
|
||||
if config.supports_parallel_tool_calls {
|
||||
entry["supports_parallel_tool_calls"] = value(true);
|
||||
}
|
||||
if let Some(timeout) = config.startup_timeout_sec {
|
||||
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
|
||||
}
|
||||
if let Some(timeout) = config.tool_timeout_sec {
|
||||
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
|
||||
}
|
||||
if let Some(approval_mode) = config.default_tools_approval_mode {
|
||||
entry["default_tools_approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
if let Some(enabled_tools) = &config.enabled_tools
|
||||
&& !enabled_tools.is_empty()
|
||||
{
|
||||
entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
|
||||
}
|
||||
if let Some(disabled_tools) = &config.disabled_tools
|
||||
&& !disabled_tools.is_empty()
|
||||
{
|
||||
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
|
||||
}
|
||||
if let Some(scopes) = &config.scopes
|
||||
&& !scopes.is_empty()
|
||||
{
|
||||
entry["scopes"] = array_from_iter(scopes.iter().cloned());
|
||||
}
|
||||
if let Some(oauth) = &config.oauth
|
||||
&& let Some(client_id) = &oauth.client_id
|
||||
&& !client_id.is_empty()
|
||||
{
|
||||
let mut oauth_table = TomlTable::new();
|
||||
oauth_table.set_implicit(false);
|
||||
oauth_table["client_id"] = value(client_id.clone());
|
||||
entry["oauth"] = TomlItem::Table(oauth_table);
|
||||
}
|
||||
if let Some(resource) = &config.oauth_resource
|
||||
&& !resource.is_empty()
|
||||
{
|
||||
entry["oauth_resource"] = value(resource.clone());
|
||||
}
|
||||
if !config.tools.is_empty() {
|
||||
let mut tools = new_implicit_table();
|
||||
let mut tool_entries: Vec<_> = config.tools.iter().collect();
|
||||
tool_entries.sort_by_key(|(name, _)| *name);
|
||||
for (name, tool_config) in tool_entries {
|
||||
tools.insert(name, serialize_mcp_server_tool(tool_config));
|
||||
}
|
||||
entry.insert("tools", TomlItem::Table(tools));
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
if let Some(approval_mode) = config.approval_mode {
|
||||
entry["approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
TomlItem::Table(entry)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
|
||||
TomlItem::Table(serialize_mcp_server_table(config))
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
|
||||
serialize_mcp_server_table(config).into_inline_table()
|
||||
}
|
||||
|
||||
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
|
||||
existing.retain(|key, _| replacement.get(key).is_some());
|
||||
|
||||
for (key, value) in replacement.iter() {
|
||||
if let Some(existing_value) = existing.get_mut(key) {
|
||||
let mut updated_value = value.clone();
|
||||
*updated_value.decor_mut() = existing_value.decor().clone();
|
||||
*existing_value = updated_value;
|
||||
} else {
|
||||
existing.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn table_from_inline(inline: &InlineTable) -> TomlTable {
|
||||
let mut table = new_implicit_table();
|
||||
for (key, value) in inline.iter() {
|
||||
let mut value = value.clone();
|
||||
let decor = value.decor_mut();
|
||||
decor.set_suffix("");
|
||||
table.insert(key, TomlItem::Value(value));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub(super) fn new_implicit_table() -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool(
|
||||
value: &TomlValue,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let table = value.as_inline_table()?;
|
||||
let kind = match table.get("type").and_then(TomlValue::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlValue::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool_table(
|
||||
table: &TomlTable,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let kind = match table.get("type").and_then(TomlItem::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlItem::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tool_suggest_disabled_tools_value(
|
||||
disabled_tools: &[ToolSuggestDisabledTool],
|
||||
) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for disabled_tool in disabled_tools {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert(
|
||||
"type",
|
||||
match disabled_tool.kind {
|
||||
ToolSuggestDiscoverableType::Connector => "connector",
|
||||
ToolSuggestDiscoverableType::Plugin => "plugin",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
table.insert("id", disabled_tool.id.clone().into());
|
||||
array.push(table);
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_iter<I>(iter: I) -> TomlItem
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
let mut array = TomlArray::new();
|
||||
for value in iter {
|
||||
array.push(value);
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for env_var in env_vars {
|
||||
match env_var {
|
||||
McpServerEnvVar::Name(name) => array.push(name.clone()),
|
||||
McpServerEnvVar::Config { name, source } => {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert("name", name.clone().into());
|
||||
if let Some(source) = source {
|
||||
table.insert("source", source.clone().into());
|
||||
}
|
||||
array.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a String)>,
|
||||
{
|
||||
let mut entries: Vec<_> = pairs.into_iter().collect();
|
||||
entries.sort_by_key(|(key, _)| *key);
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(false);
|
||||
for (key, val) in entries {
|
||||
table.insert(key, value(val.clone()));
|
||||
}
|
||||
TomlItem::Table(table)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigDocument {
|
||||
doc: DocumentMut,
|
||||
}
|
||||
|
||||
309
codex-rs/core/src/config/edit/document_helpers.rs
Normal file
309
codex-rs/core/src/config/edit/document_helpers.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::InlineTable;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::Value as TomlValue;
|
||||
use toml_edit::value;
|
||||
|
||||
pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(table) => Some(table),
|
||||
TomlItem::Value(value) => {
|
||||
if let Some(inline) = value.as_inline_table() {
|
||||
*item = TomlItem::Table(table_from_inline(inline));
|
||||
item.as_table_mut()
|
||||
} else {
|
||||
*item = TomlItem::Table(new_implicit_table());
|
||||
item.as_table_mut()
|
||||
}
|
||||
}
|
||||
TomlItem::None => {
|
||||
*item = TomlItem::Table(new_implicit_table());
|
||||
item.as_table_mut()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(table) => Some(table),
|
||||
TomlItem::Value(value) => {
|
||||
let inline = value.as_inline_table()?;
|
||||
*item = TomlItem::Table(table_from_inline(inline));
|
||||
item.as_table_mut()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
|
||||
match &config.transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
entry["command"] = value(command.clone());
|
||||
if !args.is_empty() {
|
||||
entry["args"] = array_from_iter(args.iter().cloned());
|
||||
}
|
||||
if let Some(env) = env
|
||||
&& !env.is_empty()
|
||||
{
|
||||
entry["env"] = table_from_pairs(env.iter());
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
entry["env_vars"] = array_from_env_vars(env_vars);
|
||||
}
|
||||
if let Some(cwd) = cwd {
|
||||
entry["cwd"] = value(cwd.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
entry["url"] = value(url.clone());
|
||||
if let Some(env_var) = bearer_token_env_var {
|
||||
entry["bearer_token_env_var"] = value(env_var.clone());
|
||||
}
|
||||
if let Some(headers) = http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
entry["http_headers"] = table_from_pairs(headers.iter());
|
||||
}
|
||||
if let Some(headers) = env_http_headers
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
entry["env_http_headers"] = table_from_pairs(headers.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.enabled {
|
||||
entry["enabled"] = value(false);
|
||||
}
|
||||
if !config.is_local_environment() {
|
||||
entry["environment_id"] = value(config.environment_id.clone());
|
||||
}
|
||||
if config.required {
|
||||
entry["required"] = value(true);
|
||||
}
|
||||
if config.supports_parallel_tool_calls {
|
||||
entry["supports_parallel_tool_calls"] = value(true);
|
||||
}
|
||||
if let Some(timeout) = config.startup_timeout_sec {
|
||||
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
|
||||
}
|
||||
if let Some(timeout) = config.tool_timeout_sec {
|
||||
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
|
||||
}
|
||||
if let Some(approval_mode) = config.default_tools_approval_mode {
|
||||
entry["default_tools_approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
if let Some(enabled_tools) = &config.enabled_tools
|
||||
&& !enabled_tools.is_empty()
|
||||
{
|
||||
entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned());
|
||||
}
|
||||
if let Some(disabled_tools) = &config.disabled_tools
|
||||
&& !disabled_tools.is_empty()
|
||||
{
|
||||
entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned());
|
||||
}
|
||||
if let Some(scopes) = &config.scopes
|
||||
&& !scopes.is_empty()
|
||||
{
|
||||
entry["scopes"] = array_from_iter(scopes.iter().cloned());
|
||||
}
|
||||
if let Some(oauth) = &config.oauth
|
||||
&& let Some(client_id) = &oauth.client_id
|
||||
&& !client_id.is_empty()
|
||||
{
|
||||
let mut oauth_table = TomlTable::new();
|
||||
oauth_table.set_implicit(false);
|
||||
oauth_table["client_id"] = value(client_id.clone());
|
||||
entry["oauth"] = TomlItem::Table(oauth_table);
|
||||
}
|
||||
if let Some(resource) = &config.oauth_resource
|
||||
&& !resource.is_empty()
|
||||
{
|
||||
entry["oauth_resource"] = value(resource.clone());
|
||||
}
|
||||
if !config.tools.is_empty() {
|
||||
let mut tools = new_implicit_table();
|
||||
let mut tool_entries: Vec<_> = config.tools.iter().collect();
|
||||
tool_entries.sort_by_key(|(name, _)| *name);
|
||||
for (name, tool_config) in tool_entries {
|
||||
tools.insert(name, serialize_mcp_server_tool(tool_config));
|
||||
}
|
||||
entry.insert("tools", TomlItem::Table(tools));
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
|
||||
fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
if let Some(approval_mode) = config.approval_mode {
|
||||
entry["approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
TomlItem::Table(entry)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
|
||||
TomlItem::Table(serialize_mcp_server_table(config))
|
||||
}
|
||||
|
||||
pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable {
|
||||
serialize_mcp_server_table(config).into_inline_table()
|
||||
}
|
||||
|
||||
pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) {
|
||||
existing.retain(|key, _| replacement.get(key).is_some());
|
||||
|
||||
for (key, value) in replacement.iter() {
|
||||
if let Some(existing_value) = existing.get_mut(key) {
|
||||
let mut updated_value = value.clone();
|
||||
*updated_value.decor_mut() = existing_value.decor().clone();
|
||||
*existing_value = updated_value;
|
||||
} else {
|
||||
existing.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn table_from_inline(inline: &InlineTable) -> TomlTable {
|
||||
let mut table = new_implicit_table();
|
||||
for (key, value) in inline.iter() {
|
||||
let mut value = value.clone();
|
||||
let decor = value.decor_mut();
|
||||
decor.set_suffix("");
|
||||
table.insert(key, TomlItem::Value(value));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
pub(super) fn new_implicit_table() -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool(
|
||||
value: &TomlValue,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let table = value.as_inline_table()?;
|
||||
let kind = match table.get("type").and_then(TomlValue::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlValue::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_tool_suggest_disabled_tool_table(
|
||||
table: &TomlTable,
|
||||
) -> Option<ToolSuggestDisabledTool> {
|
||||
let kind = match table.get("type").and_then(TomlItem::as_str) {
|
||||
Some("connector") => ToolSuggestDiscoverableType::Connector,
|
||||
Some("plugin") => ToolSuggestDiscoverableType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let id = table.get("id").and_then(TomlItem::as_str)?;
|
||||
Some(ToolSuggestDisabledTool {
|
||||
kind,
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn tool_suggest_disabled_tools_value(
|
||||
disabled_tools: &[ToolSuggestDisabledTool],
|
||||
) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for disabled_tool in disabled_tools {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert(
|
||||
"type",
|
||||
match disabled_tool.kind {
|
||||
ToolSuggestDiscoverableType::Connector => "connector",
|
||||
ToolSuggestDiscoverableType::Plugin => "plugin",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
table.insert("id", disabled_tool.id.clone().into());
|
||||
array.push(table);
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_iter<I>(iter: I) -> TomlItem
|
||||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
let mut array = TomlArray::new();
|
||||
for value in iter {
|
||||
array.push(value);
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for env_var in env_vars {
|
||||
match env_var {
|
||||
McpServerEnvVar::Name(name) => array.push(name.clone()),
|
||||
McpServerEnvVar::Config { name, source } => {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert("name", name.clone().into());
|
||||
if let Some(source) = source {
|
||||
table.insert("source", source.clone().into());
|
||||
}
|
||||
array.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a String)>,
|
||||
{
|
||||
let mut entries: Vec<_> = pairs.into_iter().collect();
|
||||
entries.sort_by_key(|(key, _)| *key);
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(false);
|
||||
for (key, val) in entries {
|
||||
table.insert(key, value(val.clone()));
|
||||
}
|
||||
TomlItem::Table(table)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// Spawn a review thread using the given prompt.
|
||||
@@ -47,6 +48,15 @@ pub(super) async fn spawn_review_thread(
|
||||
let mut per_turn_config = (*config).clone();
|
||||
per_turn_config.model = Some(model.clone());
|
||||
per_turn_config.features = review_features.clone();
|
||||
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
|
||||
if per_turn_config.features.enabled(Feature::CodeModeOnly) {
|
||||
ToolMode::CodeModeOnly
|
||||
} else if per_turn_config.features.enabled(Feature::CodeMode) {
|
||||
ToolMode::CodeMode
|
||||
} else {
|
||||
ToolMode::Direct
|
||||
}
|
||||
});
|
||||
if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) {
|
||||
let fallback_value = per_turn_config.web_search_mode.value();
|
||||
tracing::warn!(
|
||||
@@ -96,6 +106,7 @@ pub(super) async fn spawn_review_thread(
|
||||
config: per_turn_config,
|
||||
auth_manager: auth_manager_for_context,
|
||||
model_info: model_info.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: session_telemetry_for_context,
|
||||
provider: provider_for_context,
|
||||
reasoning_effort,
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_model_provider::SharedModelProvider;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
|
||||
@@ -55,6 +56,7 @@ pub struct TurnContext {
|
||||
pub config: Arc<Config>,
|
||||
pub(crate) auth_manager: Option<Arc<AuthManager>>,
|
||||
pub(crate) model_info: ModelInfo,
|
||||
pub(crate) tool_mode: ToolMode,
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
pub(crate) provider: SharedModelProvider,
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
@@ -172,6 +174,15 @@ impl TurnContext {
|
||||
let model_info = models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
|
||||
if config.features.enabled(Feature::CodeModeOnly) {
|
||||
ToolMode::CodeModeOnly
|
||||
} else if config.features.enabled(Feature::CodeMode) {
|
||||
ToolMode::CodeMode
|
||||
} else {
|
||||
ToolMode::Direct
|
||||
}
|
||||
});
|
||||
let truncation_policy = model_info.truncation_policy.into();
|
||||
let supported_reasoning_levels = model_info
|
||||
.supported_reasoning_levels
|
||||
@@ -212,6 +223,7 @@ impl TurnContext {
|
||||
config: Arc::new(config),
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
model_info: model_info.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: self
|
||||
.session_telemetry
|
||||
.clone()
|
||||
@@ -475,6 +487,15 @@ impl Session {
|
||||
);
|
||||
|
||||
let mut per_turn_config = per_turn_config;
|
||||
let tool_mode = model_info.tool_mode.unwrap_or_else(|| {
|
||||
if per_turn_config.features.enabled(Feature::CodeModeOnly) {
|
||||
ToolMode::CodeModeOnly
|
||||
} else if per_turn_config.features.enabled(Feature::CodeMode) {
|
||||
ToolMode::CodeMode
|
||||
} else {
|
||||
ToolMode::Direct
|
||||
}
|
||||
});
|
||||
per_turn_config.service_tier = get_service_tier(
|
||||
per_turn_config.service_tier,
|
||||
per_turn_config.features.enabled(Feature::FastMode),
|
||||
@@ -501,6 +522,7 @@ impl Session {
|
||||
config: per_turn_config.clone(),
|
||||
auth_manager: auth_manager_for_context,
|
||||
model_info: model_info.clone(),
|
||||
tool_mode,
|
||||
session_telemetry: session_telemetry_for_context,
|
||||
provider: provider_for_context,
|
||||
reasoning_effort,
|
||||
|
||||
@@ -5,6 +5,7 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::items::ImageGenerationItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_utils_stream_parser::strip_citations;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -125,6 +126,68 @@ async fn save_image_generation_result(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_image_generation_item(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
image_item: &mut ImageGenerationItem,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
match save_image_generation_result(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
&image_item.id,
|
||||
&image_item.result,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => {
|
||||
image_item.saved_path = Some(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
Err(err) => {
|
||||
let output_path = image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
&image_item.id,
|
||||
);
|
||||
let output_dir = output_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
||||
tracing::warn!(
|
||||
call_id = %image_item.id,
|
||||
output_dir = %output_dir.display(),
|
||||
"failed to save generated image: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn finalize_image_generation_item(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
image_item: &mut ImageGenerationItem,
|
||||
) {
|
||||
if persist_image_generation_item(sess, turn_context, image_item)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
let image_output_path =
|
||||
image_generation_artifact_path(&turn_context.config.codex_home, &session_id, "<image_id>");
|
||||
let image_output_dir = image_output_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
||||
let message: ResponseItem = ContextualUserFragment::into(ImageGenerationInstructions::new(
|
||||
image_output_dir.display(),
|
||||
image_output_path.display(),
|
||||
));
|
||||
sess.record_conversation_items(turn_context, &[message])
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Persist a completed model response item and record any cited memory usage.
|
||||
pub(crate) async fn record_completed_response_item(
|
||||
sess: &Session,
|
||||
@@ -487,49 +550,7 @@ pub(crate) async fn handle_non_tool_response_item(
|
||||
}
|
||||
}
|
||||
if let TurnItem::ImageGeneration(image_item) = &mut turn_item {
|
||||
let session_id = sess.conversation_id.to_string();
|
||||
match save_image_generation_result(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
&image_item.id,
|
||||
&image_item.result,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => {
|
||||
image_item.saved_path = Some(path);
|
||||
let image_output_path = image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
"<image_id>",
|
||||
);
|
||||
let image_output_dir = image_output_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
||||
let message: ResponseItem =
|
||||
ContextualUserFragment::into(ImageGenerationInstructions::new(
|
||||
image_output_dir.display(),
|
||||
image_output_path.display(),
|
||||
));
|
||||
sess.record_conversation_items(turn_context, &[message])
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let output_path = image_generation_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
&session_id,
|
||||
&image_item.id,
|
||||
);
|
||||
let output_dir = output_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn_context.config.codex_home.clone());
|
||||
tracing::warn!(
|
||||
call_id = %image_item.id,
|
||||
output_dir = %output_dir.display(),
|
||||
"failed to save generated image: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
finalize_image_generation_item(sess, turn_context, image_item).await;
|
||||
}
|
||||
Some(turn_item)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::unified_exec::resolve_max_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_tools::ToolName;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
|
||||
@@ -91,7 +91,7 @@ impl CodeModeService {
|
||||
router: Arc<ToolRouter>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
) -> Option<codex_code_mode::CodeModeTurnWorker> {
|
||||
if !turn.features.enabled(Feature::CodeMode) {
|
||||
if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,19 @@ pub(crate) fn create_wait_tool() -> ToolSpec {
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for more output before yielding again."
|
||||
.to_string(),
|
||||
"Wait before yielding more output. Defaults to 10000 ms.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of output tokens to return for this wait call.".to_string(),
|
||||
"Output token budget for this wait call. Defaults to 10000 tokens.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"terminate".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to terminate the running exec cell.".to_string(),
|
||||
"True stops the running exec cell; false or omitted waits for output.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -77,20 +76,21 @@ mod tests {
|
||||
(
|
||||
"max_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of output tokens to return for this wait call."
|
||||
"Output token budget for this wait call. Defaults to 10000 tokens."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"terminate".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to terminate the running exec cell.".to_string(),
|
||||
"True stops the running exec cell; false or omitted waits for output."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for more output before yielding again."
|
||||
"Wait before yielding more output. Defaults to 10000 ms."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,14 @@ use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
let mut output_schema = JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
);
|
||||
output_schema.description =
|
||||
Some("JSON Schema for each worker result. Omit to accept any result object.".to_string());
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"csv_path".to_string(),
|
||||
@@ -19,12 +27,15 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
(
|
||||
"id_column".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional column name to use as stable item id.".to_string(),
|
||||
"CSV column to use as stable item id. Omit to use row numbers.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"output_csv_path".to_string(),
|
||||
JsonSchema::string(Some("Optional output CSV path for exported results.".to_string())),
|
||||
JsonSchema::string(Some(
|
||||
"Output CSV path for exported results. Omit to create one next to the input CSV."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_concurrency".to_string(),
|
||||
@@ -36,20 +47,17 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
(
|
||||
"max_workers".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
|
||||
"Alias for max_concurrency. Defaults to 16 and is capped by config.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_runtime_seconds".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
|
||||
"Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"output_schema".to_string(),
|
||||
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
|
||||
),
|
||||
("output_schema".to_string(), output_schema),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
@@ -64,6 +72,13 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
}
|
||||
|
||||
pub fn create_report_agent_job_result_tool() -> ToolSpec {
|
||||
let mut result_schema = JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
);
|
||||
result_schema.description = Some("Result object for this job item.".to_string());
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"job_id".to_string(),
|
||||
@@ -73,14 +88,11 @@ pub fn create_report_agent_job_result_tool() -> ToolSpec {
|
||||
"item_id".to_string(),
|
||||
JsonSchema::string(Some("Identifier of the job item.".to_string())),
|
||||
),
|
||||
(
|
||||
"result".to_string(),
|
||||
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
|
||||
),
|
||||
("result".to_string(), result_schema),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Optional. When true, cancels the remaining job items after this result is recorded."
|
||||
"True cancels remaining job items after this result is recorded; false or omitted continues the job."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,16 @@ use codex_tools::JsonSchema;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn described_object(description: &str) -> JsonSchema {
|
||||
let mut schema = JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
);
|
||||
schema.description = Some(description.to_string());
|
||||
schema
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
|
||||
assert_eq!(
|
||||
@@ -30,13 +40,15 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
|
||||
(
|
||||
"id_column".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional column name to use as stable item id.".to_string(),
|
||||
"CSV column to use as stable item id. Omit to use row numbers."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"output_csv_path".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional output CSV path for exported results.".to_string(),
|
||||
"Output CSV path for exported results. Omit to create one next to the input CSV."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
@@ -49,22 +61,21 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
|
||||
(
|
||||
"max_workers".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
|
||||
"Alias for max_concurrency. Defaults to 16 and is capped by config."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_runtime_seconds".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
|
||||
"Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"output_schema".to_string(),
|
||||
JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
described_object(
|
||||
"JSON Schema for each worker result. Omit to accept any result object.",
|
||||
),
|
||||
),
|
||||
]), Some(vec!["csv_path".to_string(), "instruction".to_string()]), Some(false.into())),
|
||||
@@ -95,16 +106,12 @@ fn report_agent_job_result_tool_requires_result_payload() {
|
||||
),
|
||||
(
|
||||
"result".to_string(),
|
||||
JsonSchema::object(
|
||||
BTreeMap::new(),
|
||||
/*required*/ None,
|
||||
/*additional_properties*/ None,
|
||||
),
|
||||
described_object("Result object for this job item."),
|
||||
),
|
||||
(
|
||||
"stop".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Optional. When true, cancels the remaining job items after this result is recorded."
|
||||
"True cancels remaining job items after this result is recorded; false or omitted continues the job."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
|
||||
@@ -4,15 +4,19 @@ use std::sync::Weak;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_tools::ConversationHistory;
|
||||
use codex_tools::ExtensionTurnItem;
|
||||
use codex_tools::ImageGenerationCompletionFuture;
|
||||
use codex_tools::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::TurnItemEmissionFuture;
|
||||
use codex_tools::TurnItemEmitter;
|
||||
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::ImageGenerationInstructions;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::stream_events_utils::persist_image_generation_item;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -90,6 +94,50 @@ impl TurnItemEmitter for CoreTurnItemEmitter {
|
||||
session.emit_turn_item_completed(turn.as_ref(), item).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn image_generation_completed<'a>(
|
||||
&'a self,
|
||||
call_id: String,
|
||||
prompt: String,
|
||||
result: String,
|
||||
) -> ImageGenerationCompletionFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
|
||||
return None;
|
||||
};
|
||||
let mut item = codex_protocol::items::ImageGenerationItem {
|
||||
id: call_id,
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some(prompt),
|
||||
result,
|
||||
saved_path: None,
|
||||
};
|
||||
let output_hint =
|
||||
persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item)
|
||||
.await
|
||||
.map(|saved_path| {
|
||||
let output_dir = saved_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| turn.config.codex_home.clone());
|
||||
ImageGenerationInstructions::new(output_dir.display(), saved_path.display())
|
||||
.body()
|
||||
});
|
||||
let started_item = codex_protocol::items::ImageGenerationItem {
|
||||
id: item.id.clone(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
};
|
||||
session
|
||||
.emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item))
|
||||
.await;
|
||||
session
|
||||
.emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item))
|
||||
.await;
|
||||
output_hint
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
|
||||
@@ -352,4 +400,130 @@ mod tests {
|
||||
assert_eq!(end.query, expected.query);
|
||||
assert_eq!(end.action, expected.action);
|
||||
}
|
||||
|
||||
struct ImageGenerationExtensionExecutor {
|
||||
output_hint: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {
|
||||
fn tool_name(&self) -> codex_tools::ToolName {
|
||||
codex_tools::ToolName::namespaced("image_gen", "imagegen")
|
||||
}
|
||||
|
||||
fn spec(&self) -> codex_tools::ToolSpec {
|
||||
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
|
||||
name: "imagegen".to_string(),
|
||||
description: "Generates an image.".to_string(),
|
||||
strict: false,
|
||||
parameters: codex_tools::JsonSchema::default(),
|
||||
output_schema: None,
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
call: codex_tools::ToolCall,
|
||||
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
||||
let output_hint = call
|
||||
.turn_item_emitter
|
||||
.image_generation_completed(
|
||||
call.call_id,
|
||||
"A tiny blue square".to_string(),
|
||||
"cG5n".to_string(),
|
||||
)
|
||||
.await;
|
||||
*self.output_hint.lock().await = output_hint;
|
||||
Ok(Box::new(codex_tools::JsonToolOutput::new(
|
||||
json!({ "ok": true }),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_generation_publication_is_finalized_by_core() {
|
||||
let output_hint = Arc::new(Mutex::new(None));
|
||||
let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor {
|
||||
output_hint: Arc::clone(&output_hint),
|
||||
}));
|
||||
let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
|
||||
let expected_path = crate::stream_events_utils::image_generation_artifact_path(
|
||||
&turn.config.codex_home,
|
||||
&session.conversation_id.to_string(),
|
||||
"call-image",
|
||||
);
|
||||
let invocation = ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
||||
call_id: "call-image".to_string(),
|
||||
tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
crate::tools::registry::ToolExecutor::handle(&handler, invocation)
|
||||
.await
|
||||
.expect("extension call should succeed");
|
||||
|
||||
let started = rx.recv().await.expect("item started event");
|
||||
let EventMsg::ItemStarted(started) = started.msg else {
|
||||
panic!("expected item started event");
|
||||
};
|
||||
let TurnItem::ImageGeneration(started_item) = started.item else {
|
||||
panic!("expected image generation item");
|
||||
};
|
||||
let begin = rx.recv().await.expect("legacy image start event");
|
||||
assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_)));
|
||||
let completed = rx.recv().await.expect("item completed event");
|
||||
let EventMsg::ItemCompleted(completed) = completed.msg else {
|
||||
panic!("expected item completed event");
|
||||
};
|
||||
let TurnItem::ImageGeneration(completed_item) = completed.item else {
|
||||
panic!("expected image generation item");
|
||||
};
|
||||
let end = rx.recv().await.expect("legacy image end event");
|
||||
assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_)));
|
||||
|
||||
assert_eq!(
|
||||
started_item,
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "in_progress".to_string(),
|
||||
revised_prompt: None,
|
||||
result: String::new(),
|
||||
saved_path: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
completed_item,
|
||||
codex_protocol::items::ImageGenerationItem {
|
||||
id: "call-image".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A tiny blue square".to_string()),
|
||||
result: "cG5n".to_string(),
|
||||
saved_path: Some(expected_path.clone()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&expected_path).expect("generated artifact should be saved"),
|
||||
b"png"
|
||||
);
|
||||
assert_eq!(
|
||||
*output_hint.lock().await,
|
||||
Some(format!(
|
||||
"Generated images are saved to {} as {} by default.\n\
|
||||
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
expected_path
|
||||
.parent()
|
||||
.expect("generated image path should have a parent")
|
||||
.display(),
|
||||
expected_path.display(),
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ pub fn create_create_goal_tool() -> ToolSpec {
|
||||
(
|
||||
"token_budget".to_string(),
|
||||
JsonSchema::integer(Some(
|
||||
"Optional positive token budget for the new active goal.".to_string(),
|
||||
"Positive token budget for the new goal. Omit unless explicitly requested."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -8,14 +8,13 @@ pub fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
(
|
||||
"server".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional MCP server name. When omitted, lists resources from every configured server."
|
||||
.to_string(),
|
||||
"MCP server name. Omit to list resources from every configured server.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"cursor".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
|
||||
"Opaque cursor from a previous list_mcp_resources call; omit for the first page."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -36,14 +35,14 @@ pub fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
||||
(
|
||||
"server".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
|
||||
"MCP server name. Omit to list resource templates from every configured server."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"cursor".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
|
||||
"Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
|
||||
@@ -16,14 +16,14 @@ fn list_mcp_resources_tool_matches_expected_spec() {
|
||||
(
|
||||
"server".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional MCP server name. When omitted, lists resources from every configured server."
|
||||
"MCP server name. Omit to list resources from every configured server."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
(
|
||||
"cursor".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
|
||||
"Opaque cursor from a previous list_mcp_resources call; omit for the first page."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
@@ -46,14 +46,14 @@ fn list_mcp_resource_templates_tool_matches_expected_spec() {
|
||||
(
|
||||
"server".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
|
||||
"MCP server name. Omit to list resource templates from every configured server."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
(
|
||||
"cursor".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
|
||||
"Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
|
||||
@@ -11,9 +11,11 @@ use std::collections::BTreeMap;
|
||||
pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
|
||||
const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
|
||||
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one.";
|
||||
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent.";
|
||||
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
|
||||
"Model override for the new agent. Omit to inherit the parent model.";
|
||||
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
|
||||
"Service tier override for the new agent. Omit unless explicitly requested.";
|
||||
const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -125,7 +127,7 @@ pub fn create_send_input_tool_v1() -> ToolSpec {
|
||||
(
|
||||
"interrupt".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
|
||||
"True interrupts the current task and handles this message immediately; false or omitted queues it."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -258,7 +260,7 @@ pub fn create_list_agents_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([(
|
||||
"path_prefix".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
|
||||
"Task-path prefix filter without a trailing slash. Omit to list all live agents."
|
||||
.to_string(),
|
||||
)),
|
||||
)]);
|
||||
@@ -555,7 +557,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
|
||||
(
|
||||
"fork_context".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
|
||||
"True forks the current thread history into the new agent; false or omitted starts with only the initial prompt."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -568,7 +570,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
|
||||
(
|
||||
"reasoning_effort".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
|
||||
"Reasoning effort override for the new agent. Omit to inherit the parent effort."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -607,7 +609,7 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
|
||||
(
|
||||
"reasoning_effort".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
|
||||
"Reasoning effort override for the new agent. Omit to inherit the parent effort."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -807,7 +809,7 @@ fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(format!(
|
||||
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
|
||||
"Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
|
||||
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
|
||||
))),
|
||||
),
|
||||
@@ -824,7 +826,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema
|
||||
let properties = BTreeMap::from([(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(format!(
|
||||
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}.",
|
||||
"Timeout in milliseconds. Defaults to {}, min {}, max {}.",
|
||||
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
|
||||
))),
|
||||
)]);
|
||||
|
||||
@@ -306,7 +306,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
|
||||
properties
|
||||
.get("timeout_ms")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some("Optional timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
|
||||
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
|
||||
);
|
||||
assert_eq!(parameters.required.as_ref(), None);
|
||||
assert_eq!(
|
||||
@@ -338,9 +338,7 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
|
||||
properties
|
||||
.get("path_prefix")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(
|
||||
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
|
||||
)
|
||||
Some("Task-path prefix filter without a trailing slash. Omit to list all live agents.")
|
||||
);
|
||||
assert_eq!(
|
||||
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolSpec;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn create_update_plan_tool() -> ToolSpec {
|
||||
let plan_item_properties = BTreeMap::from([
|
||||
("step".to_string(), JsonSchema::string(/*description*/ None)),
|
||||
(
|
||||
"step".to_string(),
|
||||
JsonSchema::string(Some("Task step text.".to_string())),
|
||||
),
|
||||
(
|
||||
"status".to_string(),
|
||||
JsonSchema::string(Some("One of: pending, in_progress, completed".to_string())),
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("pending"), json!("in_progress"), json!("completed")],
|
||||
Some("Step status.".to_string()),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"explanation".to_string(),
|
||||
JsonSchema::string(/*description*/ None),
|
||||
JsonSchema::string(Some(
|
||||
"Optional explanation for this plan update.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"plan".to_string(),
|
||||
|
||||
@@ -28,7 +28,7 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
"Working directory for the command. Defaults to the turn cwd."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -41,20 +41,20 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
|
||||
"True allocates a PTY for the command; false or omitted uses plain pipes."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
"Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
|
||||
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -62,7 +62,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
"True runs the shell with -l/-i semantics; false disables them. Defaults to true."
|
||||
.to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -70,7 +71,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id(
|
||||
properties.insert(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional environment id from the <environment_context> block. If omitted, uses the primary environment.".to_string(),
|
||||
"Environment id from <environment_context>. Omit to use the primary environment."
|
||||
.to_string(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -111,19 +113,19 @@ pub fn create_write_stdin_tool() -> ToolSpec {
|
||||
(
|
||||
"chars".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Bytes to write to stdin (may be empty to poll).".to_string(),
|
||||
"Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
"Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
|
||||
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -149,19 +151,19 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
"Shell script to run in the user's default shell.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"The working directory to execute the command in".to_string(),
|
||||
"Working directory for the command. Defaults to the turn cwd.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"The timeout for the command in milliseconds".to_string(),
|
||||
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -169,7 +171,7 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
|
||||
properties.insert(
|
||||
"login".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
"True runs with login shell semantics; false disables them. Defaults to true."
|
||||
.to_string(),
|
||||
)),
|
||||
);
|
||||
@@ -281,92 +283,108 @@ fn unified_exec_output_schema() -> Value {
|
||||
fn create_approval_parameters(
|
||||
exec_permission_approvals_enabled: bool,
|
||||
) -> BTreeMap<String, JsonSchema> {
|
||||
let mut sandbox_permission_values = vec![json!("use_default")];
|
||||
if exec_permission_approvals_enabled {
|
||||
sandbox_permission_values.push(json!("with_additional_permissions"));
|
||||
}
|
||||
sandbox_permission_values.push(json!("require_escalated"));
|
||||
let sandbox_permissions_description = if exec_permission_approvals_enabled {
|
||||
"Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions`, or `require_escalated` for unsandboxed execution."
|
||||
} else {
|
||||
"Per-command sandbox override. Defaults to `use_default`; use `require_escalated` for unsandboxed execution."
|
||||
};
|
||||
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
"sandbox_permissions".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
if exec_permission_approvals_enabled {
|
||||
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
} else {
|
||||
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
|
||||
}
|
||||
.to_string(),
|
||||
)),
|
||||
JsonSchema::string_enum(
|
||||
sandbox_permission_values,
|
||||
Some(sandbox_permissions_description.to_string()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"justification".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
r#"Only set if sandbox_permissions is \"require_escalated\".
|
||||
Request approval from the user to run this command outside the sandbox.
|
||||
Phrased as a simple question that summarizes the purpose of the
|
||||
command as it relates to the task at hand - e.g. 'Do you want to
|
||||
fetch and pull the latest version of this git branch?'"#
|
||||
.to_string(),
|
||||
"User-facing approval question for `require_escalated`; omit otherwise.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"prefix_rule".to_string(),
|
||||
JsonSchema::array(JsonSchema::string(/*description*/ None), Some(
|
||||
r#"Only specify when sandbox_permissions is `require_escalated`.
|
||||
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
|
||||
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
|
||||
r#"Reusable approval prefix for `cmd`, only with `sandbox_permissions: "require_escalated"`; for example ["git", "pull"]."#.to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
if exec_permission_approvals_enabled {
|
||||
properties.insert(
|
||||
"additional_permissions".to_string(),
|
||||
permission_profile_schema(),
|
||||
let mut additional_permissions = permission_profile_schema();
|
||||
additional_permissions.description = Some(
|
||||
"Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`."
|
||||
.to_string(),
|
||||
);
|
||||
properties.insert("additional_permissions".to_string(), additional_permissions);
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
fn permission_profile_schema() -> JsonSchema {
|
||||
JsonSchema::object(
|
||||
let mut schema = JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
("network".to_string(), network_permissions_schema()),
|
||||
("file_system".to_string(), file_system_permissions_schema()),
|
||||
]),
|
||||
/*required*/ None,
|
||||
Some(false.into()),
|
||||
)
|
||||
);
|
||||
schema.description = Some("Filesystem or network access request.".to_string());
|
||||
schema
|
||||
}
|
||||
|
||||
fn network_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::object(
|
||||
let mut schema = JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"enabled".to_string(),
|
||||
JsonSchema::boolean(Some("Set to true to request network access.".to_string())),
|
||||
JsonSchema::boolean(Some(
|
||||
"True requests network access; false or omitted requests none.".to_string(),
|
||||
)),
|
||||
)]),
|
||||
/*required*/ None,
|
||||
Some(false.into()),
|
||||
)
|
||||
);
|
||||
schema.description = Some("Network access request.".to_string());
|
||||
schema
|
||||
}
|
||||
|
||||
fn file_system_permissions_schema() -> JsonSchema {
|
||||
JsonSchema::object(
|
||||
let mut schema = JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"read".to_string(),
|
||||
JsonSchema::array(
|
||||
JsonSchema::string(/*description*/ None),
|
||||
Some("Absolute paths to grant read access to.".to_string()),
|
||||
Some(
|
||||
"Absolute paths to grant read access; omit when none are needed."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
),
|
||||
(
|
||||
"write".to_string(),
|
||||
JsonSchema::array(
|
||||
JsonSchema::string(/*description*/ None),
|
||||
Some("Absolute paths to grant write access to.".to_string()),
|
||||
Some(
|
||||
"Absolute paths to grant write access; omit when none are needed."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
/*required*/ None,
|
||||
Some(false.into()),
|
||||
)
|
||||
);
|
||||
schema.description = Some("Filesystem access request.".to_string());
|
||||
schema
|
||||
}
|
||||
|
||||
fn windows_shell_guidance() -> &'static str {
|
||||
|
||||
@@ -31,7 +31,7 @@ fn exec_command_tool_matches_expected_spec() {
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
"Working directory for the command. Defaults to the turn cwd."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -44,27 +44,26 @@ fn exec_command_tool_matches_expected_spec() {
|
||||
(
|
||||
"tty".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
|
||||
"True allocates a PTY for the command; false or omitted uses plain pipes."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
"Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated."
|
||||
.to_string(),
|
||||
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"login".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
|
||||
"True runs the shell with -l/-i semantics; false disables them. Defaults to true.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -103,19 +102,19 @@ fn write_stdin_tool_matches_expected_spec() {
|
||||
(
|
||||
"chars".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Bytes to write to stdin (may be empty to poll).".to_string(),
|
||||
"Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
"Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"max_output_tokens".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
|
||||
"Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -201,25 +200,25 @@ Examples of valid command strings:
|
||||
(
|
||||
"command".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
"Shell script to run in the user's default shell.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"The working directory to execute the command in".to_string(),
|
||||
"Working directory for the command. Defaults to the turn cwd.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"The timeout for the command in milliseconds".to_string(),
|
||||
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"login".to_string(),
|
||||
JsonSchema::boolean(Some(
|
||||
"Whether to run the shell with login shell semantics. Defaults to true."
|
||||
"True runs with login shell semantics; false disables them. Defaults to true."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn create_test_sync_tool() -> ToolSpec {
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum time in milliseconds to wait at the barrier".to_string(),
|
||||
"Maximum barrier wait in milliseconds. Defaults to 1000.".to_string(),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
@@ -29,13 +29,13 @@ pub fn create_test_sync_tool() -> ToolSpec {
|
||||
(
|
||||
"sleep_before_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Optional delay in milliseconds before any other action".to_string(),
|
||||
"Delay before any other action. Defaults to no delay.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"sleep_after_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Optional delay in milliseconds after completing the barrier".to_string(),
|
||||
"Delay after completing the barrier. Defaults to no delay.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -35,7 +35,7 @@ fn test_sync_tool_matches_expected_spec() {
|
||||
(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum time in milliseconds to wait at the barrier"
|
||||
"Maximum barrier wait in milliseconds. Defaults to 1000."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
@@ -47,14 +47,14 @@ fn test_sync_tool_matches_expected_spec() {
|
||||
(
|
||||
"sleep_after_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Optional delay in milliseconds after completing the barrier"
|
||||
"Delay after completing the barrier. Defaults to no delay."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"sleep_before_ms".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Optional delay in milliseconds before any other action".to_string(),
|
||||
"Delay before any other action. Defaults to no delay.".to_string(),
|
||||
)),
|
||||
),
|
||||
]), /*required*/ None, Some(false.into())),
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(crate) fn create_tool_search_tool(
|
||||
(
|
||||
"limit".to_string(),
|
||||
JsonSchema::number(Some(format!(
|
||||
"Maximum number of tools to return (defaults to {default_limit})."
|
||||
"Maximum number of tools to return. Defaults to {default_limit}."
|
||||
))),
|
||||
),
|
||||
]);
|
||||
@@ -98,7 +98,7 @@ mod tests {
|
||||
(
|
||||
"limit".to_string(),
|
||||
JsonSchema::number(Some(
|
||||
"Maximum number of tools to return (defaults to 8)."
|
||||
"Maximum number of tools to return. Defaults to 8."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct ViewImageToolOptions {
|
||||
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([(
|
||||
"path".to_string(),
|
||||
JsonSchema::string(Some("Local filesystem path to an image file".to_string())),
|
||||
JsonSchema::string(Some("Local filesystem path to an image file.".to_string())),
|
||||
)]);
|
||||
if options.can_request_original_image_detail {
|
||||
properties.insert(
|
||||
@@ -23,7 +23,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
|
||||
JsonSchema::string_enum(
|
||||
vec![json!("high"), json!("original")],
|
||||
Some(
|
||||
"Optional detail override. Supported values are `high` and `original`; omit this field for default high resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
|
||||
"Image detail level. Defaults to `high`; use `original` to preserve exact resolution.".to_string(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -32,7 +32,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
|
||||
properties.insert(
|
||||
"environment_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Optional selected environment id to target. Omit this to use the primary environment."
|
||||
"Environment id from <environment_context>. Omit to use the primary environment."
|
||||
.to_string(),
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tools::DiscoverableTool;
|
||||
@@ -230,8 +231,10 @@ fn spec_for_model_request(
|
||||
exposure: ToolExposure,
|
||||
spec: ToolSpec,
|
||||
) -> ToolSpec {
|
||||
if code_mode_enabled(turn_context)
|
||||
&& exposure != ToolExposure::DirectModelOnly
|
||||
if matches!(
|
||||
turn_context.tool_mode,
|
||||
ToolMode::CodeMode | ToolMode::CodeModeOnly
|
||||
) && exposure != ToolExposure::DirectModelOnly
|
||||
&& codex_code_mode::is_code_mode_nested_tool(spec.name())
|
||||
{
|
||||
codex_tools::augment_tool_spec_for_code_mode(spec)
|
||||
@@ -282,14 +285,6 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.provider.capabilities().namespace_tools
|
||||
}
|
||||
|
||||
fn code_mode_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.features.get().enabled(Feature::CodeMode)
|
||||
}
|
||||
|
||||
fn code_mode_only_enabled(turn_context: &TurnContext) -> bool {
|
||||
code_mode_enabled(turn_context) && turn_context.features.get().enabled(Feature::CodeModeOnly)
|
||||
}
|
||||
|
||||
fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool {
|
||||
turn_context.features.get().enabled(Feature::MultiAgentV2)
|
||||
}
|
||||
@@ -398,7 +393,7 @@ fn is_hidden_by_code_mode_only(
|
||||
tool_name: &ToolName,
|
||||
exposure: ToolExposure,
|
||||
) -> bool {
|
||||
code_mode_only_enabled(turn_context)
|
||||
turn_context.tool_mode == ToolMode::CodeModeOnly
|
||||
&& exposure != ToolExposure::DirectModelOnly
|
||||
&& codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name(
|
||||
tool_name,
|
||||
@@ -410,7 +405,10 @@ fn build_code_mode_executors(
|
||||
executors: &[Arc<dyn CoreToolRuntime>],
|
||||
deferred_tools_available: bool,
|
||||
) -> Vec<Arc<dyn CoreToolRuntime>> {
|
||||
if !code_mode_enabled(turn_context) {
|
||||
if !matches!(
|
||||
turn_context.tool_mode,
|
||||
ToolMode::CodeMode | ToolMode::CodeModeOnly
|
||||
) {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
@@ -444,7 +442,7 @@ fn build_code_mode_executors(
|
||||
create_code_mode_tool(
|
||||
&enabled_tools,
|
||||
&namespace_descriptions,
|
||||
code_mode_only_enabled(turn_context),
|
||||
turn_context.tool_mode == ToolMode::CodeModeOnly,
|
||||
deferred_tools_available,
|
||||
),
|
||||
code_mode_nested_tool_specs,
|
||||
@@ -847,7 +845,10 @@ fn append_extension_tool_executors(
|
||||
.iter()
|
||||
.map(|executor| executor.tool_name())
|
||||
.collect::<HashSet<_>>();
|
||||
if code_mode_enabled(turn_context) {
|
||||
if matches!(
|
||||
turn_context.tool_mode,
|
||||
ToolMode::CodeMode | ToolMode::CodeModeOnly
|
||||
) {
|
||||
reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME));
|
||||
reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::openai_models::ApplyPatchToolType;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -215,6 +216,15 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) {
|
||||
.expect("test feature should be disableable in config");
|
||||
}
|
||||
turn.config = Arc::new(config);
|
||||
turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| {
|
||||
if turn.config.features.enabled(Feature::CodeModeOnly) {
|
||||
ToolMode::CodeModeOnly
|
||||
} else if turn.config.features.enabled(Feature::CodeMode) {
|
||||
ToolMode::CodeMode
|
||||
} else {
|
||||
ToolMode::Direct
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_features(turn: &mut TurnContext, features: &[Feature]) {
|
||||
@@ -797,6 +807,20 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_mode_selector_overrides_feature_flags() {
|
||||
let direct = probe(|turn| {
|
||||
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
|
||||
turn.model_info.tool_mode = Some(ToolMode::Direct);
|
||||
turn.tool_mode = ToolMode::Direct;
|
||||
})
|
||||
.await;
|
||||
direct.assert_visible_lacks(&[
|
||||
codex_code_mode::PUBLIC_TOOL_NAME,
|
||||
codex_code_mode::WAIT_TOOL_NAME,
|
||||
]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v1_multi_agent_tools_defer_when_tool_search_available() {
|
||||
let plan = probe(|turn| {
|
||||
|
||||
@@ -248,6 +248,39 @@ where
|
||||
wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await
|
||||
}
|
||||
|
||||
/// Waits for a configured MCP server to finish startup and requires it to be ready.
|
||||
pub async fn wait_for_mcp_server(codex: &CodexThread, server_name: &str) -> anyhow::Result<()> {
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
|
||||
// Wait for the startup summary regardless of outcome, then interpret the
|
||||
// requested server's ready, failed, or cancelled entry below.
|
||||
let summary = loop {
|
||||
let event = codex
|
||||
.next_event()
|
||||
.await
|
||||
.expect("stream ended unexpectedly while waiting for MCP startup");
|
||||
if let EventMsg::McpStartupComplete(summary) = event.msg {
|
||||
break summary;
|
||||
}
|
||||
};
|
||||
if let Some(failure) = summary
|
||||
.failed
|
||||
.iter()
|
||||
.find(|failure| failure.server == server_name)
|
||||
{
|
||||
let error = &failure.error;
|
||||
anyhow::bail!("MCP server {server_name} failed to start: {error}");
|
||||
}
|
||||
if summary.cancelled.iter().any(|server| server == server_name) {
|
||||
anyhow::bail!("MCP server {server_name} startup was cancelled");
|
||||
}
|
||||
assert!(
|
||||
summary.ready.iter().any(|server| server == server_name),
|
||||
"expected MCP server {server_name} to be ready; startup summary: {summary:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn submit_thread_settings(
|
||||
codex: &CodexThread,
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides,
|
||||
|
||||
@@ -38,6 +38,7 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -283,6 +284,7 @@ async fn run_code_mode_turn_with_rmcp_config(
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(server).await?;
|
||||
wait_for_mcp_server(&test.codex, "rmcp").await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
server,
|
||||
|
||||
@@ -22,6 +22,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -284,6 +285,7 @@ async fn pre_tool_use_blocks_mcp_tool_before_execution(
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, RMCP_SERVER).await?;
|
||||
|
||||
test.submit_turn("call the rmcp echo tool with the MCP pre hook")
|
||||
.await?;
|
||||
@@ -375,6 +377,7 @@ async fn pre_tool_use_rewrites_mcp_tool_before_execution() -> Result<()> {
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, RMCP_SERVER).await?;
|
||||
|
||||
test.submit_turn("call the rmcp echo tool with the MCP pre hook rewrite")
|
||||
.await?;
|
||||
@@ -471,6 +474,7 @@ async fn post_tool_use_records_mcp_tool_payload_and_context(
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&test.codex, RMCP_SERVER).await?;
|
||||
|
||||
test.submit_turn("call the rmcp echo tool with the MCP post hook")
|
||||
.await?;
|
||||
|
||||
@@ -63,6 +63,7 @@ mod json_result;
|
||||
mod live_cli;
|
||||
mod mcp_turn_metadata;
|
||||
mod model_overrides;
|
||||
mod model_runtime_selectors;
|
||||
mod model_switching;
|
||||
mod model_visible_layout;
|
||||
mod models_cache_ttl;
|
||||
|
||||
173
codex-rs/core/tests/suite/model_runtime_selectors.rs
Normal file
173
codex-rs/core/tests/suite/model_runtime_selectors.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Config;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::submit_thread_settings;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep;
|
||||
|
||||
fn remote_model(slug: &str) -> ModelInfo {
|
||||
ModelInfo {
|
||||
visibility: ModelVisibility::List,
|
||||
used_fallback_model_metadata: false,
|
||||
..model_info_from_slug(slug)
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.get("name")
|
||||
.or_else(|| tool.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn wait_for_model_available(manager: &SharedModelsManager, slug: &str) -> ModelPreset {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if let Some(model) = manager
|
||||
.list_models(RefreshStrategy::Online)
|
||||
.await
|
||||
.iter()
|
||||
.find(|model| model.model == slug)
|
||||
.cloned()
|
||||
{
|
||||
return model;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for the remote model {slug} to appear");
|
||||
}
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_body_for_remote_model(
|
||||
remote_model: ModelInfo,
|
||||
configure: impl FnOnce(&mut Config) + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let model_slug = remote_model.slug.clone();
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let response_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(configure);
|
||||
let test = builder.build(&server).await?;
|
||||
let models_manager = test.thread_manager.get_models_manager();
|
||||
let available_model = wait_for_model_available(&models_manager, &model_slug).await;
|
||||
assert_eq!(available_model.model, model_slug);
|
||||
assert_eq!(models_mock.requests().len(), 1);
|
||||
|
||||
submit_thread_settings(
|
||||
&test.codex,
|
||||
ThreadSettingsOverrides {
|
||||
model: Some(model_slug),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "list tools".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
environments: None,
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(response_mock.single_request().body_json())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let mut direct_model = remote_model("test-tool-mode-direct");
|
||||
direct_model.tool_mode = Some(ToolMode::Direct);
|
||||
let direct_body = response_body_for_remote_model(direct_model, |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeModeOnly)
|
||||
.expect("test config should allow feature update");
|
||||
})
|
||||
.await?;
|
||||
let direct_tools = tool_names(&direct_body);
|
||||
assert!(
|
||||
direct_tools
|
||||
.iter()
|
||||
.all(|name| name != codex_code_mode::PUBLIC_TOOL_NAME
|
||||
&& name != codex_code_mode::WAIT_TOOL_NAME),
|
||||
"direct mode should override enabled code mode flags: {direct_tools:?}"
|
||||
);
|
||||
|
||||
let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only");
|
||||
code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly);
|
||||
let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?;
|
||||
assert_eq!(
|
||||
tool_names(&code_mode_only_body),
|
||||
vec![
|
||||
codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
|
||||
codex_code_mode::WAIT_TOOL_NAME.to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -112,6 +112,7 @@ fn test_model_info(
|
||||
input_modalities,
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
@@ -929,6 +930,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
|
||||
@@ -370,5 +370,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
};
|
||||
|
||||
let _models_mock = mount_models_once(
|
||||
@@ -702,6 +703,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
};
|
||||
|
||||
let _models_mock = mount_models_once(
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -21,7 +20,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
@@ -140,45 +139,6 @@ async fn build_apps_enabled_plugin_test_codex(
|
||||
.codex)
|
||||
}
|
||||
|
||||
async fn wait_for_sample_mcp_ready(codex: &codex_core::CodexThread) -> Result<()> {
|
||||
let startup_event = wait_for_event_with_timeout(
|
||||
codex,
|
||||
|ev| match ev {
|
||||
EventMsg::McpStartupComplete(summary) => {
|
||||
summary.ready.iter().any(|server| server == "sample")
|
||||
|| summary
|
||||
.failed
|
||||
.iter()
|
||||
.any(|failure| failure.server == "sample")
|
||||
|| summary.cancelled.iter().any(|server| server == "sample")
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
Duration::from_secs(70),
|
||||
)
|
||||
.await;
|
||||
let EventMsg::McpStartupComplete(startup) = startup_event else {
|
||||
unreachable!("event guard guarantees McpStartupComplete");
|
||||
};
|
||||
if let Some(failure) = startup
|
||||
.failed
|
||||
.iter()
|
||||
.find(|failure| failure.server == "sample")
|
||||
{
|
||||
let error = &failure.error;
|
||||
bail!("plugin MCP server failed to start: {error}");
|
||||
}
|
||||
if startup.cancelled.iter().any(|server| server == "sample") {
|
||||
bail!("plugin MCP server startup was cancelled");
|
||||
}
|
||||
assert!(
|
||||
startup.ready.iter().any(|server| server == "sample"),
|
||||
"expected plugin MCP server to be ready; startup summary: {startup:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
@@ -296,7 +256,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
let codex =
|
||||
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||
.await?;
|
||||
wait_for_sample_mcp_ready(&codex).await?;
|
||||
wait_for_mcp_server(&codex, "sample").await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
|
||||
@@ -477,6 +477,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
@@ -726,6 +727,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
@@ -1209,6 +1211,7 @@ fn test_remote_model_with_policy(
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
|
||||
@@ -55,7 +55,7 @@ use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use reqwest::Client;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
@@ -268,44 +268,6 @@ fn copy_binary_to_remote_env(
|
||||
Ok(remote_path)
|
||||
}
|
||||
|
||||
async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> {
|
||||
let startup_event = wait_for_event_with_timeout(
|
||||
&fixture.codex,
|
||||
|ev| match ev {
|
||||
EventMsg::McpStartupComplete(summary) => {
|
||||
summary.ready.iter().any(|server| server == server_name)
|
||||
|| summary
|
||||
.failed
|
||||
.iter()
|
||||
.any(|failure| failure.server == server_name)
|
||||
|| summary.cancelled.iter().any(|server| server == server_name)
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
Duration::from_secs(70),
|
||||
)
|
||||
.await;
|
||||
let EventMsg::McpStartupComplete(summary) = startup_event else {
|
||||
unreachable!("event guard guarantees McpStartupComplete");
|
||||
};
|
||||
if let Some(failure) = summary
|
||||
.failed
|
||||
.iter()
|
||||
.find(|failure| failure.server == server_name)
|
||||
{
|
||||
let error = &failure.error;
|
||||
anyhow::bail!("MCP server {server_name} failed to start: {error}");
|
||||
}
|
||||
if summary.cancelled.iter().any(|server| server == server_name) {
|
||||
anyhow::bail!("MCP server {server_name} startup was cancelled");
|
||||
}
|
||||
ensure!(
|
||||
summary.ready.iter().any(|server| server == server_name),
|
||||
"expected MCP server {server_name} to be ready; startup summary: {summary:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct TestMcpServerOptions {
|
||||
environment_id: String,
|
||||
supports_parallel_tool_calls: bool,
|
||||
@@ -517,6 +479,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.submit(read_only_user_turn(&fixture, "call the rmcp echo tool"))
|
||||
@@ -640,6 +604,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
let expected_cwd = expected_cwd
|
||||
.lock()
|
||||
@@ -702,6 +667,7 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() ->
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
let expected_cwd = expected_cwd
|
||||
.lock()
|
||||
@@ -760,7 +726,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
|
||||
wait_for_mcp_server(&fixture, server_name).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.submit_turn_with_permission_profile(
|
||||
@@ -857,6 +823,8 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
// Keep this baseline on the mutable sync tool so read-only hints do not
|
||||
@@ -991,6 +959,8 @@ async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in()
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.submit(read_only_user_turn(
|
||||
@@ -1073,6 +1043,8 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
// Exercise the server opt-in with the mutable sync tool rather than the
|
||||
@@ -1157,7 +1129,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture, server_name).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
@@ -1290,7 +1262,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture, server_name).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
@@ -1377,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
input_modalities: vec![InputModality::Text],
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
@@ -1426,6 +1399,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.thread_manager
|
||||
@@ -1528,6 +1502,8 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
.submit(read_only_user_turn(&fixture, "call the rmcp echo tool"))
|
||||
@@ -1646,6 +1622,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
@@ -1738,6 +1715,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.codex
|
||||
@@ -1921,6 +1899,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
})
|
||||
.build_with_remote_env(&server)
|
||||
.await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
// Phase 4: submit the user turn that should trigger the MCP tool call.
|
||||
fixture
|
||||
.codex
|
||||
@@ -2109,7 +2089,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
.await?;
|
||||
// Phase 5: wait for MCP startup before the turn is submitted, which keeps
|
||||
// failures tied to server startup/discovery.
|
||||
wait_for_mcp_server(&fixture, server_name).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
// Phase 6: submit the user turn that should invoke the OAuth-backed tool.
|
||||
fixture
|
||||
|
||||
@@ -47,6 +47,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -163,7 +164,7 @@ async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query for deferred tools."},
|
||||
"limit": {"type": "number", "description": "Maximum number of tools to return (defaults to 8)."},
|
||||
"limit": {"type": "number", "description": "Maximum number of tools to return. Defaults to 8."},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false,
|
||||
@@ -1114,6 +1115,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, "rmcp").await?;
|
||||
|
||||
test.submit_turn_with_approval_and_permission_profile(
|
||||
"Find the rmcp echo and image tools.",
|
||||
@@ -1243,6 +1245,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result<
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, "rmcp").await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
@@ -1391,6 +1394,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, "rmcp").await?;
|
||||
|
||||
test.submit_turn_with_approval_and_permission_profile(
|
||||
"Find the rmcp echo tool.",
|
||||
|
||||
@@ -60,6 +60,7 @@ fn test_model_info(
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers,
|
||||
@@ -183,7 +184,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."
|
||||
"Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent."
|
||||
),
|
||||
"expected inherited-model guidance in spawn_agent description: {description:?}"
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -460,6 +461,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, server_name).await?;
|
||||
let db = test.codex.state_db().expect("state db enabled");
|
||||
let thread_id = test.session_configured.thread_id;
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
|
||||
@@ -23,6 +23,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -410,6 +411,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
|
||||
config.tool_output_token_limit = Some(500);
|
||||
});
|
||||
let fixture = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.submit_turn_with_permission_profile(
|
||||
@@ -509,6 +511,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let fixture = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
let session_model = fixture.session_configured.model.clone();
|
||||
let permission_profile = PermissionProfile::read_only();
|
||||
let sandbox_policy = permission_profile.to_legacy_sandbox_policy(fixture.cwd.path())?;
|
||||
@@ -798,6 +801,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
|
||||
.expect("test mcp servers should accept any configuration");
|
||||
});
|
||||
let fixture = builder.build(&server).await?;
|
||||
wait_for_mcp_server(&fixture.codex, server_name).await?;
|
||||
|
||||
fixture
|
||||
.submit_turn_with_permission_profile(
|
||||
|
||||
@@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
|
||||
input_modalities: vec![InputModality::Text],
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "codex-debug-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-app-server-protocol.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
||||
@@ -1,60 +0,0 @@
|
||||
WARNING: this code is mainly generated by Codex and should not be used in production
|
||||
|
||||
# codex-debug-client
|
||||
|
||||
A tiny interactive client for `codex app-server` (protocol v2 only). It prints
|
||||
all JSON-RPC lines from the server and lets you send new turns as you type.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the app-server client (it will spawn `codex app-server` itself):
|
||||
|
||||
```
|
||||
cargo run -p codex-debug-client -- \
|
||||
--codex-bin codex \
|
||||
--approval-policy on-request \
|
||||
--output-file /tmp/app-server-server-json.jsonl
|
||||
```
|
||||
|
||||
You can resume a specific thread:
|
||||
|
||||
```
|
||||
cargo run -p codex-debug-client -- --thread-id thr_123
|
||||
```
|
||||
|
||||
### CLI flags
|
||||
|
||||
- `--codex-bin <path>`: path to the `codex` binary (default: `codex`).
|
||||
- `-c, --config key=value`: pass through `--config` overrides to `codex`.
|
||||
- `--thread-id <id>`: resume a thread instead of starting a new one.
|
||||
- `--approval-policy <policy>`: `untrusted`, `on-failure` (deprecated), `on-request`, `never`.
|
||||
- `--auto-approve`: auto-approve command/file-change approvals (default: decline).
|
||||
- `--final-only`: only show completed assistant messages and tool items.
|
||||
- `--output-file <path>`: write raw server JSONL to this file instead of stdout.
|
||||
- `--model <name>`: optional model override for thread start/resume.
|
||||
- `--model-provider <name>`: optional provider override.
|
||||
- `--cwd <path>`: optional working directory override.
|
||||
|
||||
## Interactive commands
|
||||
|
||||
Type a line to send it as a new turn. Commands are prefixed with `:`:
|
||||
|
||||
- `:help` show help
|
||||
- `:new` start a new thread
|
||||
- `:resume <thread-id>` resume a thread
|
||||
- `:use <thread-id>` switch active thread without resuming
|
||||
- `:refresh-thread` list available threads
|
||||
- `:quit` exit
|
||||
|
||||
The prompt shows the active thread id. Client messages (help, errors, approvals)
|
||||
print to stderr; raw server JSON prints to stdout so you can pipe/record it
|
||||
unless `--final-only` is set. Pass `--output-file <path>` to record raw server
|
||||
JSONL to a file instead of stdout.
|
||||
|
||||
## Notes
|
||||
|
||||
- The client performs the required initialize/initialized handshake.
|
||||
- It prints every server notification and response line as it arrives.
|
||||
- Approvals for `item/commandExecution/requestApproval` and
|
||||
`item/fileChange/requestApproval` are auto-responded to with decline unless
|
||||
`--auto-approve` is set.
|
||||
@@ -1,415 +0,0 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::io::Write;
|
||||
use std::process::Child;
|
||||
use std::process::ChildStdin;
|
||||
use std::process::ChildStdout;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientNotification;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::output::Output;
|
||||
use crate::reader::start_reader;
|
||||
use crate::state::PendingRequest;
|
||||
use crate::state::ReaderEvent;
|
||||
use crate::state::State;
|
||||
|
||||
pub struct AppServerClient {
|
||||
child: Child,
|
||||
stdin: Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdout: Option<BufReader<ChildStdout>>,
|
||||
next_request_id: AtomicI64,
|
||||
state: Arc<Mutex<State>>,
|
||||
output: Output,
|
||||
filtered_output: bool,
|
||||
}
|
||||
|
||||
impl AppServerClient {
|
||||
pub fn spawn(
|
||||
codex_bin: &str,
|
||||
config_overrides: &[String],
|
||||
output: Output,
|
||||
filtered_output: bool,
|
||||
) -> Result<Self> {
|
||||
let mut cmd = Command::new(codex_bin);
|
||||
for override_kv in config_overrides {
|
||||
cmd.arg("--config").arg(override_kv);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.arg("app-server")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to start `{codex_bin}` app-server"))?;
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.context("codex app-server stdin unavailable")?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.context("codex app-server stdout unavailable")?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
stdin: Arc::new(Mutex::new(Some(stdin))),
|
||||
stdout: Some(BufReader::new(stdout)),
|
||||
next_request_id: AtomicI64::new(1),
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
output,
|
||||
filtered_output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn initialize(&mut self) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let request = ClientRequest::Initialize {
|
||||
request_id: request_id.clone(),
|
||||
params: codex_app_server_protocol::InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "debug-client".to_string(),
|
||||
title: Some("Debug Client".to_string()),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
request_attestation: false,
|
||||
opt_out_notification_methods: None,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
self.send(&request)?;
|
||||
let response = self.read_until_response(&request_id)?;
|
||||
let _parsed: codex_app_server_protocol::InitializeResponse =
|
||||
serde_json::from_value(response.result).context("decode initialize response")?;
|
||||
let initialized = ClientNotification::Initialized;
|
||||
self.send(&initialized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_thread(&mut self, params: ThreadStartParams) -> Result<String> {
|
||||
let request_id = self.next_request_id();
|
||||
let request = ClientRequest::ThreadStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
self.send(&request)?;
|
||||
let response = self.read_until_response(&request_id)?;
|
||||
let parsed: ThreadStartResponse =
|
||||
serde_json::from_value(response.result).context("decode thread/start response")?;
|
||||
let thread_id = parsed.thread.id;
|
||||
self.set_thread_id(thread_id.clone());
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
pub fn resume_thread(&mut self, params: ThreadResumeParams) -> Result<String> {
|
||||
let request_id = self.next_request_id();
|
||||
let request = ClientRequest::ThreadResume {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
self.send(&request)?;
|
||||
let response = self.read_until_response(&request_id)?;
|
||||
let parsed: ThreadResumeResponse =
|
||||
serde_json::from_value(response.result).context("decode thread/resume response")?;
|
||||
let thread_id = parsed.thread.id;
|
||||
self.set_thread_id(thread_id.clone());
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
pub fn request_thread_start(&self, params: ThreadStartParams) -> Result<RequestId> {
|
||||
let request_id = self.next_request_id();
|
||||
self.track_pending(request_id.clone(), PendingRequest::Start);
|
||||
let request = ClientRequest::ThreadStart {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
self.send(&request)?;
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
pub fn request_thread_resume(&self, params: ThreadResumeParams) -> Result<RequestId> {
|
||||
let request_id = self.next_request_id();
|
||||
self.track_pending(request_id.clone(), PendingRequest::Resume);
|
||||
let request = ClientRequest::ThreadResume {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
self.send(&request)?;
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
pub fn request_thread_list(&self, cursor: Option<String>) -> Result<RequestId> {
|
||||
let request_id = self.next_request_id();
|
||||
self.track_pending(request_id.clone(), PendingRequest::List);
|
||||
let request = ClientRequest::ThreadList {
|
||||
request_id: request_id.clone(),
|
||||
params: ThreadListParams {
|
||||
cursor,
|
||||
limit: None,
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
use_state_db_only: false,
|
||||
search_term: None,
|
||||
},
|
||||
};
|
||||
self.send(&request)?;
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
pub fn send_turn(&self, thread_id: &str, text: String) -> Result<RequestId> {
|
||||
let request_id = self.next_request_id();
|
||||
let request = ClientRequest::TurnStart {
|
||||
request_id: request_id.clone(),
|
||||
params: TurnStartParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
client_user_message_id: None,
|
||||
input: vec![UserInput::Text {
|
||||
text,
|
||||
// Debug client sends plain text with no UI markup spans.
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
self.send(&request)?;
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
pub fn start_reader(
|
||||
&mut self,
|
||||
events: Sender<ReaderEvent>,
|
||||
auto_approve: bool,
|
||||
filtered_output: bool,
|
||||
) -> Result<()> {
|
||||
let stdout = self.stdout.take().context("reader already started")?;
|
||||
start_reader(
|
||||
stdout,
|
||||
Arc::clone(&self.stdin),
|
||||
Arc::clone(&self.state),
|
||||
events,
|
||||
self.output.clone(),
|
||||
auto_approve,
|
||||
filtered_output,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn thread_id(&self) -> Option<String> {
|
||||
let state = self.state.lock().expect("state lock poisoned");
|
||||
state.thread_id.clone()
|
||||
}
|
||||
|
||||
pub fn set_thread_id(&self, thread_id: String) {
|
||||
let mut state = self.state.lock().expect("state lock poisoned");
|
||||
state.thread_id = Some(thread_id);
|
||||
self.remember_thread_locked(&mut state);
|
||||
}
|
||||
|
||||
pub fn use_thread(&self, thread_id: String) -> bool {
|
||||
let mut state = self.state.lock().expect("state lock poisoned");
|
||||
let known = state.known_threads.iter().any(|id| id == &thread_id);
|
||||
state.thread_id = Some(thread_id);
|
||||
self.remember_thread_locked(&mut state);
|
||||
known
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) {
|
||||
if let Ok(mut stdin) = self.stdin.lock() {
|
||||
let _ = stdin.take();
|
||||
}
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
|
||||
fn track_pending(&self, request_id: RequestId, kind: PendingRequest) {
|
||||
let mut state = self.state.lock().expect("state lock poisoned");
|
||||
state.pending.insert(request_id, kind);
|
||||
}
|
||||
|
||||
fn remember_thread_locked(&self, state: &mut State) {
|
||||
if let Some(thread_id) = state.thread_id.as_ref()
|
||||
&& !state.known_threads.iter().any(|id| id == thread_id)
|
||||
{
|
||||
state.known_threads.push(thread_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn next_request_id(&self) -> RequestId {
|
||||
let id = self.next_request_id.fetch_add(1, Ordering::SeqCst);
|
||||
RequestId::Integer(id)
|
||||
}
|
||||
|
||||
fn send<T: Serialize>(&self, value: &T) -> Result<()> {
|
||||
let json = serde_json::to_string(value).context("serialize message")?;
|
||||
let mut line = json;
|
||||
line.push('\n');
|
||||
let mut stdin = self.stdin.lock().expect("stdin lock poisoned");
|
||||
let Some(stdin) = stdin.as_mut() else {
|
||||
anyhow::bail!("stdin already closed");
|
||||
};
|
||||
stdin.write_all(line.as_bytes()).context("write message")?;
|
||||
stdin.flush().context("flush message")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_until_response(&mut self, request_id: &RequestId) -> Result<JSONRPCResponse> {
|
||||
let stdin = Arc::clone(&self.stdin);
|
||||
let output = self.output.clone();
|
||||
let reader = self.stdout.as_mut().context("stdout missing")?;
|
||||
let mut buffer = String::new();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
let bytes = reader
|
||||
.read_line(&mut buffer)
|
||||
.context("read server output")?;
|
||||
if bytes == 0 {
|
||||
anyhow::bail!("server closed stdout while awaiting response {request_id:?}");
|
||||
}
|
||||
|
||||
let line = buffer.trim_end_matches(['\n', '\r']);
|
||||
if !line.is_empty() {
|
||||
let _ = output.server_json_line(line, self.filtered_output);
|
||||
}
|
||||
|
||||
let message = match serde_json::from_str::<JSONRPCMessage>(line) {
|
||||
Ok(message) => message,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Response(response) if &response.id == request_id => {
|
||||
return Ok(response);
|
||||
}
|
||||
JSONRPCMessage::Request(request) => {
|
||||
let _ = handle_server_request(request, &stdin);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_server_request(
|
||||
request: JSONRPCRequest,
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
) -> Result<()> {
|
||||
let Ok(server_request) = codex_app_server_protocol::ServerRequest::try_from(request) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match server_request {
|
||||
codex_app_server_protocol::ServerRequest::CommandExecutionRequestApproval {
|
||||
request_id,
|
||||
..
|
||||
} => {
|
||||
let response = codex_app_server_protocol::CommandExecutionRequestApprovalResponse {
|
||||
decision: CommandExecutionApprovalDecision::Decline,
|
||||
};
|
||||
send_jsonrpc_response(stdin, request_id, response)
|
||||
}
|
||||
codex_app_server_protocol::ServerRequest::FileChangeRequestApproval {
|
||||
request_id, ..
|
||||
} => {
|
||||
let response = codex_app_server_protocol::FileChangeRequestApprovalResponse {
|
||||
decision: FileChangeApprovalDecision::Decline,
|
||||
};
|
||||
send_jsonrpc_response(stdin, request_id, response)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_jsonrpc_response<T: Serialize>(
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
request_id: RequestId,
|
||||
response: T,
|
||||
) -> Result<()> {
|
||||
let result = serde_json::to_value(response).context("serialize response")?;
|
||||
let message = JSONRPCMessage::Response(JSONRPCResponse {
|
||||
id: request_id,
|
||||
result,
|
||||
});
|
||||
send_with_stdin(stdin, &message)
|
||||
}
|
||||
|
||||
fn send_with_stdin<T: Serialize>(stdin: &Arc<Mutex<Option<ChildStdin>>>, value: &T) -> Result<()> {
|
||||
let json = serde_json::to_string(value).context("serialize message")?;
|
||||
let mut line = json;
|
||||
line.push('\n');
|
||||
let mut stdin = stdin.lock().expect("stdin lock poisoned");
|
||||
let Some(stdin) = stdin.as_mut() else {
|
||||
anyhow::bail!("stdin already closed");
|
||||
};
|
||||
stdin.write_all(line.as_bytes()).context("write message")?;
|
||||
stdin.flush().context("flush message")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_thread_start_params(
|
||||
approval_policy: AskForApproval,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
cwd: Option<String>,
|
||||
) -> ThreadStartParams {
|
||||
ThreadStartParams {
|
||||
model,
|
||||
model_provider,
|
||||
cwd,
|
||||
approval_policy: Some(approval_policy),
|
||||
experimental_raw_events: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_thread_resume_params(
|
||||
thread_id: String,
|
||||
approval_policy: AskForApproval,
|
||||
model: Option<String>,
|
||||
model_provider: Option<String>,
|
||||
cwd: Option<String>,
|
||||
) -> ThreadResumeParams {
|
||||
ThreadResumeParams {
|
||||
thread_id,
|
||||
model,
|
||||
model_provider,
|
||||
cwd,
|
||||
approval_policy: Some(approval_policy),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InputAction {
|
||||
Message(String),
|
||||
Command(UserCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UserCommand {
|
||||
Help,
|
||||
Quit,
|
||||
NewThread,
|
||||
Resume(String),
|
||||
Use(String),
|
||||
RefreshThread,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
EmptyCommand,
|
||||
MissingArgument { name: &'static str },
|
||||
UnknownCommand { command: String },
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::EmptyCommand => "empty command after ':'".to_string(),
|
||||
Self::MissingArgument { name } => {
|
||||
format!("missing required argument: {name}")
|
||||
}
|
||||
Self::UnknownCommand { command } => format!("unknown command: {command}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_input(line: &str) -> Result<Option<InputAction>, ParseError> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(command_line) = trimmed.strip_prefix(':') else {
|
||||
return Ok(Some(InputAction::Message(trimmed.to_string())));
|
||||
};
|
||||
|
||||
let mut parts = command_line.split_whitespace();
|
||||
let Some(command) = parts.next() else {
|
||||
return Err(ParseError::EmptyCommand);
|
||||
};
|
||||
|
||||
match command {
|
||||
"help" | "h" => Ok(Some(InputAction::Command(UserCommand::Help))),
|
||||
"quit" | "q" | "exit" => Ok(Some(InputAction::Command(UserCommand::Quit))),
|
||||
"new" => Ok(Some(InputAction::Command(UserCommand::NewThread))),
|
||||
"resume" => {
|
||||
let thread_id = parts
|
||||
.next()
|
||||
.ok_or(ParseError::MissingArgument { name: "thread-id" })?;
|
||||
Ok(Some(InputAction::Command(UserCommand::Resume(
|
||||
thread_id.to_string(),
|
||||
))))
|
||||
}
|
||||
"use" => {
|
||||
let thread_id = parts
|
||||
.next()
|
||||
.ok_or(ParseError::MissingArgument { name: "thread-id" })?;
|
||||
Ok(Some(InputAction::Command(UserCommand::Use(
|
||||
thread_id.to_string(),
|
||||
))))
|
||||
}
|
||||
"refresh-thread" => Ok(Some(InputAction::Command(UserCommand::RefreshThread))),
|
||||
_ => Err(ParseError::UnknownCommand {
|
||||
command: command.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::InputAction;
|
||||
use super::ParseError;
|
||||
use super::UserCommand;
|
||||
use super::parse_input;
|
||||
|
||||
#[test]
|
||||
fn parses_message() {
|
||||
let result = parse_input("hello there").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(InputAction::Message("hello there".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_help_command() {
|
||||
let result = parse_input(":help").unwrap();
|
||||
assert_eq!(result, Some(InputAction::Command(UserCommand::Help)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_new_thread() {
|
||||
let result = parse_input(":new").unwrap();
|
||||
assert_eq!(result, Some(InputAction::Command(UserCommand::NewThread)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_resume() {
|
||||
let result = parse_input(":resume thr_123").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(InputAction::Command(UserCommand::Resume(
|
||||
"thr_123".to_string()
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_use() {
|
||||
let result = parse_input(":use thr_456").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(InputAction::Command(UserCommand::Use(
|
||||
"thr_456".to_string()
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_refresh_thread() {
|
||||
let result = parse_input(":refresh-thread").unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(InputAction::Command(UserCommand::RefreshThread))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_resume_arg() {
|
||||
let result = parse_input(":resume");
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ParseError::MissingArgument { name: "thread-id" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_use_arg() {
|
||||
let result = parse_input(":use");
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ParseError::MissingArgument { name: "thread-id" })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
mod client;
|
||||
mod commands;
|
||||
mod output;
|
||||
mod reader;
|
||||
mod state;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::ArgAction;
|
||||
use clap::Parser;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
|
||||
use crate::client::AppServerClient;
|
||||
use crate::client::build_thread_resume_params;
|
||||
use crate::client::build_thread_start_params;
|
||||
use crate::commands::InputAction;
|
||||
use crate::commands::UserCommand;
|
||||
use crate::commands::parse_input;
|
||||
use crate::output::Output;
|
||||
use crate::state::ReaderEvent;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author = "Codex", version, about = "Minimal app-server client")]
|
||||
struct Cli {
|
||||
/// Path to the `codex` CLI binary.
|
||||
#[arg(long, default_value = "codex")]
|
||||
codex_bin: String,
|
||||
|
||||
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
|
||||
#[arg(short = 'c', long = "config", value_name = "key=value", action = ArgAction::Append)]
|
||||
config_overrides: Vec<String>,
|
||||
|
||||
/// Resume an existing thread instead of starting a new one.
|
||||
#[arg(long)]
|
||||
thread_id: Option<String>,
|
||||
|
||||
/// Set the approval policy for the thread.
|
||||
#[arg(long, default_value = "on-request")]
|
||||
approval_policy: String,
|
||||
|
||||
/// Auto-approve command/file-change approvals.
|
||||
#[arg(long, default_value_t = false)]
|
||||
auto_approve: bool,
|
||||
|
||||
/// Only show final assistant messages and tool calls.
|
||||
#[arg(long, default_value_t = false)]
|
||||
final_only: bool,
|
||||
|
||||
/// Write raw server JSONL to this file instead of stdout.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
output_file: Option<PathBuf>,
|
||||
|
||||
/// Optional model override when starting/resuming a thread.
|
||||
#[arg(long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// Optional model provider override when starting/resuming a thread.
|
||||
#[arg(long)]
|
||||
model_provider: Option<String>,
|
||||
|
||||
/// Optional working directory override when starting/resuming a thread.
|
||||
#[arg(long)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let jsonl_file = cli
|
||||
.output_file
|
||||
.as_ref()
|
||||
.map(File::create)
|
||||
.transpose()
|
||||
.with_context(|| {
|
||||
let Some(path) = cli.output_file.as_ref() else {
|
||||
return "open output file".to_string();
|
||||
};
|
||||
format!("open output file {}", path.display())
|
||||
})?;
|
||||
let output = Output::new(jsonl_file);
|
||||
let approval_policy = parse_approval_policy(&cli.approval_policy)?;
|
||||
|
||||
let mut client = AppServerClient::spawn(
|
||||
&cli.codex_bin,
|
||||
&cli.config_overrides,
|
||||
output.clone(),
|
||||
cli.final_only,
|
||||
)?;
|
||||
client.initialize()?;
|
||||
|
||||
let thread_id = if let Some(thread_id) = cli.thread_id.as_ref() {
|
||||
client.resume_thread(build_thread_resume_params(
|
||||
thread_id.clone(),
|
||||
approval_policy,
|
||||
cli.model.clone(),
|
||||
cli.model_provider.clone(),
|
||||
cli.cwd.clone(),
|
||||
))?
|
||||
} else {
|
||||
client.start_thread(build_thread_start_params(
|
||||
approval_policy,
|
||||
cli.model.clone(),
|
||||
cli.model_provider.clone(),
|
||||
cli.cwd.clone(),
|
||||
))?
|
||||
};
|
||||
|
||||
output
|
||||
.client_line(&format!("connected to thread {thread_id}"))
|
||||
.ok();
|
||||
output.set_prompt(&thread_id);
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel();
|
||||
client.start_reader(event_tx, cli.auto_approve, cli.final_only)?;
|
||||
|
||||
print_help(&output);
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut lines = stdin.lock().lines();
|
||||
|
||||
loop {
|
||||
drain_events(&event_rx, &output);
|
||||
let prompt_thread = client
|
||||
.thread_id()
|
||||
.unwrap_or_else(|| "no-thread".to_string());
|
||||
output.prompt(&prompt_thread).ok();
|
||||
|
||||
let Some(line) = lines.next() else {
|
||||
break;
|
||||
};
|
||||
let line = line.context("read stdin")?;
|
||||
|
||||
match parse_input(&line) {
|
||||
Ok(None) => continue,
|
||||
Ok(Some(InputAction::Message(message))) => {
|
||||
let Some(active_thread) = client.thread_id() else {
|
||||
output
|
||||
.client_line("no active thread; use :new or :resume <id>")
|
||||
.ok();
|
||||
continue;
|
||||
};
|
||||
if let Err(err) = client.send_turn(&active_thread, message) {
|
||||
output
|
||||
.client_line(&format!("failed to send turn: {err}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Ok(Some(InputAction::Command(command))) => {
|
||||
if !handle_command(command, &client, &output, approval_policy, &cli) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
output.client_line(&err.message()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_command(
|
||||
command: UserCommand,
|
||||
client: &AppServerClient,
|
||||
output: &Output,
|
||||
approval_policy: AskForApproval,
|
||||
cli: &Cli,
|
||||
) -> bool {
|
||||
match command {
|
||||
UserCommand::Help => {
|
||||
print_help(output);
|
||||
true
|
||||
}
|
||||
UserCommand::Quit => false,
|
||||
UserCommand::NewThread => {
|
||||
match client.request_thread_start(build_thread_start_params(
|
||||
approval_policy,
|
||||
cli.model.clone(),
|
||||
cli.model_provider.clone(),
|
||||
cli.cwd.clone(),
|
||||
)) {
|
||||
Ok(request_id) => {
|
||||
output
|
||||
.client_line(&format!("requested new thread ({request_id:?})"))
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
output
|
||||
.client_line(&format!("failed to start thread: {err}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
UserCommand::Resume(thread_id) => {
|
||||
match client.request_thread_resume(build_thread_resume_params(
|
||||
thread_id,
|
||||
approval_policy,
|
||||
cli.model.clone(),
|
||||
cli.model_provider.clone(),
|
||||
cli.cwd.clone(),
|
||||
)) {
|
||||
Ok(request_id) => {
|
||||
output
|
||||
.client_line(&format!("requested thread resume ({request_id:?})"))
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
output
|
||||
.client_line(&format!("failed to resume thread: {err}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
UserCommand::Use(thread_id) => {
|
||||
let known = client.use_thread(thread_id.clone());
|
||||
output.set_prompt(&thread_id);
|
||||
if known {
|
||||
output
|
||||
.client_line(&format!("switched active thread to {thread_id}"))
|
||||
.ok();
|
||||
} else {
|
||||
output
|
||||
.client_line(&format!(
|
||||
"switched active thread to {thread_id} (unknown; use :resume to load)"
|
||||
))
|
||||
.ok();
|
||||
}
|
||||
true
|
||||
}
|
||||
UserCommand::RefreshThread => {
|
||||
match client.request_thread_list(/*cursor*/ None) {
|
||||
Ok(request_id) => {
|
||||
output
|
||||
.client_line(&format!("requested thread list ({request_id:?})"))
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
output
|
||||
.client_line(&format!("failed to list threads: {err}"))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_approval_policy(value: &str) -> Result<AskForApproval> {
|
||||
match value {
|
||||
"untrusted" | "unless-trusted" | "unlessTrusted" => Ok(AskForApproval::UnlessTrusted),
|
||||
"on-failure" | "onFailure" => Ok(AskForApproval::OnFailure),
|
||||
"on-request" | "onRequest" => Ok(AskForApproval::OnRequest),
|
||||
"never" => Ok(AskForApproval::Never),
|
||||
_ => anyhow::bail!(
|
||||
"unknown approval policy: {value}. Expected one of: untrusted, on-failure, on-request, never"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_events(event_rx: &mpsc::Receiver<ReaderEvent>, output: &Output) {
|
||||
while let Ok(event) = event_rx.try_recv() {
|
||||
match event {
|
||||
ReaderEvent::ThreadReady { thread_id } => {
|
||||
output
|
||||
.client_line(&format!("active thread is now {thread_id}"))
|
||||
.ok();
|
||||
output.set_prompt(&thread_id);
|
||||
}
|
||||
ReaderEvent::ThreadList {
|
||||
thread_ids,
|
||||
next_cursor,
|
||||
} => {
|
||||
if thread_ids.is_empty() {
|
||||
output.client_line("threads: (none)").ok();
|
||||
} else {
|
||||
output.client_line("threads:").ok();
|
||||
for thread_id in thread_ids {
|
||||
output.client_line(&format!(" {thread_id}")).ok();
|
||||
}
|
||||
}
|
||||
if let Some(next_cursor) = next_cursor {
|
||||
output
|
||||
.client_line(&format!(
|
||||
"more threads available, next cursor: {next_cursor}"
|
||||
))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help(output: &Output) {
|
||||
let _ = output.client_line("commands:");
|
||||
let _ = output.client_line(" :help show this help");
|
||||
let _ = output.client_line(" :new start a new thread");
|
||||
let _ = output.client_line(" :resume <thread-id> resume an existing thread");
|
||||
let _ = output.client_line(" :use <thread-id> switch the active thread");
|
||||
let _ = output.client_line(" :refresh-thread list available threads");
|
||||
let _ = output.client_line(" :quit exit");
|
||||
let _ = output.client_line("type a message to send it as a new turn");
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum LabelColor {
|
||||
Assistant,
|
||||
Tool,
|
||||
ToolMeta,
|
||||
Thread,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PromptState {
|
||||
thread_id: Option<String>,
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Output {
|
||||
lock: Arc<Mutex<()>>,
|
||||
prompt: Arc<Mutex<PromptState>>,
|
||||
color: bool,
|
||||
jsonl_file: Option<Arc<Mutex<File>>>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
pub fn new(jsonl_file: Option<File>) -> Self {
|
||||
let no_color = std::env::var_os("NO_COLOR").is_some();
|
||||
let color = !no_color && io::stdout().is_terminal() && io::stderr().is_terminal();
|
||||
Self {
|
||||
lock: Arc::new(Mutex::new(())),
|
||||
prompt: Arc::new(Mutex::new(PromptState::default())),
|
||||
color,
|
||||
jsonl_file: jsonl_file.map(|file| Arc::new(Mutex::new(file))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_json_line(&self, line: &str, filtered_output: bool) -> io::Result<()> {
|
||||
let _guard = self.lock.lock().expect("output lock poisoned");
|
||||
|
||||
if let Some(file) = self.jsonl_file.as_ref() {
|
||||
let mut file = file.lock().expect("jsonl file lock poisoned");
|
||||
writeln!(file, "{line}")?;
|
||||
file.flush()?;
|
||||
}
|
||||
|
||||
if self.jsonl_file.is_none() && !filtered_output {
|
||||
self.clear_prompt_line_locked()?;
|
||||
let mut stdout = io::stdout();
|
||||
writeln!(stdout, "{line}")?;
|
||||
stdout.flush()?;
|
||||
self.redraw_prompt_locked()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn server_line(&self, line: &str) -> io::Result<()> {
|
||||
let _guard = self.lock.lock().expect("output lock poisoned");
|
||||
self.clear_prompt_line_locked()?;
|
||||
let mut stdout = io::stdout();
|
||||
writeln!(stdout, "{line}")?;
|
||||
stdout.flush()?;
|
||||
self.redraw_prompt_locked()
|
||||
}
|
||||
|
||||
pub fn client_line(&self, line: &str) -> io::Result<()> {
|
||||
let _guard = self.lock.lock().expect("output lock poisoned");
|
||||
self.clear_prompt_line_locked()?;
|
||||
let mut stderr = io::stderr();
|
||||
writeln!(stderr, "{line}")?;
|
||||
stderr.flush()
|
||||
}
|
||||
|
||||
pub fn prompt(&self, thread_id: &str) -> io::Result<()> {
|
||||
let _guard = self.lock.lock().expect("output lock poisoned");
|
||||
self.set_prompt_locked(thread_id);
|
||||
self.write_prompt_locked()
|
||||
}
|
||||
|
||||
pub fn set_prompt(&self, thread_id: &str) {
|
||||
let _guard = self.lock.lock().expect("output lock poisoned");
|
||||
self.set_prompt_locked(thread_id);
|
||||
}
|
||||
|
||||
pub fn format_label(&self, label: &str, color: LabelColor) -> String {
|
||||
if !self.color {
|
||||
return label.to_string();
|
||||
}
|
||||
|
||||
let code = match color {
|
||||
LabelColor::Assistant => "32",
|
||||
LabelColor::Tool => "36",
|
||||
LabelColor::ToolMeta => "33",
|
||||
LabelColor::Thread => "34",
|
||||
};
|
||||
format!("\x1b[{code}m{label}\x1b[0m")
|
||||
}
|
||||
|
||||
fn clear_prompt_line_locked(&self) -> io::Result<()> {
|
||||
let mut prompt = self.prompt.lock().expect("prompt lock poisoned");
|
||||
if prompt.visible {
|
||||
let mut stderr = io::stderr();
|
||||
writeln!(stderr)?;
|
||||
stderr.flush()?;
|
||||
prompt.visible = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redraw_prompt_locked(&self) -> io::Result<()> {
|
||||
if self
|
||||
.prompt
|
||||
.lock()
|
||||
.expect("prompt lock poisoned")
|
||||
.thread_id
|
||||
.is_some()
|
||||
{
|
||||
self.write_prompt_locked()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_prompt_locked(&self, thread_id: &str) {
|
||||
let mut prompt = self.prompt.lock().expect("prompt lock poisoned");
|
||||
prompt.thread_id = Some(thread_id.to_string());
|
||||
}
|
||||
|
||||
fn write_prompt_locked(&self) -> io::Result<()> {
|
||||
let mut prompt = self.prompt.lock().expect("prompt lock poisoned");
|
||||
let Some(thread_id) = prompt.thread_id.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut stderr = io::stderr();
|
||||
write!(stderr, "({thread_id})> ")?;
|
||||
stderr.flush()?;
|
||||
prompt.visible = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_json_line_writes_to_configured_file() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-debug-client-output-{}.jsonl",
|
||||
std::process::id()
|
||||
));
|
||||
let file = File::create(&path).expect("create output file");
|
||||
let output = Output::new(Some(file));
|
||||
|
||||
output
|
||||
.server_json_line(r#"{"id":1}"#, false)
|
||||
.expect("write unfiltered line");
|
||||
output
|
||||
.server_json_line(r#"{"id":2}"#, true)
|
||||
.expect("write filtered line");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&path).expect("read output file"),
|
||||
"{\"id\":1}\n{\"id\":2}\n"
|
||||
);
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::process::ChildStdout;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::thread;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::output::LabelColor;
|
||||
use crate::output::Output;
|
||||
use crate::state::PendingRequest;
|
||||
use crate::state::ReaderEvent;
|
||||
use crate::state::State;
|
||||
|
||||
pub fn start_reader(
|
||||
mut stdout: BufReader<ChildStdout>,
|
||||
stdin: Arc<Mutex<Option<std::process::ChildStdin>>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
events: Sender<ReaderEvent>,
|
||||
output: Output,
|
||||
auto_approve: bool,
|
||||
filtered_output: bool,
|
||||
) -> JoinHandle<()> {
|
||||
thread::spawn(move || {
|
||||
let command_decision = if auto_approve {
|
||||
CommandExecutionApprovalDecision::Accept
|
||||
} else {
|
||||
CommandExecutionApprovalDecision::Decline
|
||||
};
|
||||
let file_decision = if auto_approve {
|
||||
FileChangeApprovalDecision::Accept
|
||||
} else {
|
||||
FileChangeApprovalDecision::Decline
|
||||
};
|
||||
|
||||
let mut buffer = String::new();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
match stdout.read_line(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
let _ = output.client_line(&format!("failed to read from server: {err}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let line = buffer.trim_end_matches(['\n', '\r']);
|
||||
if !line.is_empty() {
|
||||
let _ = output.server_json_line(line, filtered_output);
|
||||
}
|
||||
|
||||
let Ok(message) = serde_json::from_str::<JSONRPCMessage>(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match message {
|
||||
JSONRPCMessage::Request(request) => {
|
||||
if let Err(err) = handle_server_request(
|
||||
request,
|
||||
&command_decision,
|
||||
&file_decision,
|
||||
&stdin,
|
||||
&output,
|
||||
) {
|
||||
let _ =
|
||||
output.client_line(&format!("failed to handle server request: {err}"));
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Response(response) => {
|
||||
if let Err(err) = handle_response(response, &state, &events) {
|
||||
let _ = output.client_line(&format!("failed to handle response: {err}"));
|
||||
}
|
||||
}
|
||||
JSONRPCMessage::Notification(notification) => {
|
||||
if filtered_output
|
||||
&& let Err(err) = handle_filtered_notification(notification, &output)
|
||||
{
|
||||
let _ =
|
||||
output.client_line(&format!("failed to filter notification: {err}"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_server_request(
|
||||
request: JSONRPCRequest,
|
||||
command_decision: &CommandExecutionApprovalDecision,
|
||||
file_decision: &FileChangeApprovalDecision,
|
||||
stdin: &Arc<Mutex<Option<std::process::ChildStdin>>>,
|
||||
output: &Output,
|
||||
) -> anyhow::Result<()> {
|
||||
let server_request = match ServerRequest::try_from(request) {
|
||||
Ok(server_request) => server_request,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
match server_request {
|
||||
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
|
||||
let response = CommandExecutionRequestApprovalResponse {
|
||||
decision: command_decision.clone(),
|
||||
};
|
||||
output.client_line(&format!(
|
||||
"auto-response for command approval {request_id:?}: {command_decision:?} ({params:?})"
|
||||
))?;
|
||||
send_response(stdin, request_id, response)
|
||||
}
|
||||
ServerRequest::FileChangeRequestApproval { request_id, params } => {
|
||||
let response = FileChangeRequestApprovalResponse {
|
||||
decision: file_decision.clone(),
|
||||
};
|
||||
output.client_line(&format!(
|
||||
"auto-response for file change approval {request_id:?}: {file_decision:?} ({params:?})"
|
||||
))?;
|
||||
send_response(stdin, request_id, response)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_response(
|
||||
response: JSONRPCResponse,
|
||||
state: &Arc<Mutex<State>>,
|
||||
events: &Sender<ReaderEvent>,
|
||||
) -> anyhow::Result<()> {
|
||||
let pending = {
|
||||
let mut state = state.lock().expect("state lock poisoned");
|
||||
state.pending.remove(&response.id)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match pending {
|
||||
PendingRequest::Start => {
|
||||
let parsed = serde_json::from_value::<ThreadStartResponse>(response.result)
|
||||
.context("decode thread/start response")?;
|
||||
let thread_id = parsed.thread.id;
|
||||
{
|
||||
let mut state = state.lock().expect("state lock poisoned");
|
||||
state.thread_id = Some(thread_id.clone());
|
||||
if !state.known_threads.iter().any(|id| id == &thread_id) {
|
||||
state.known_threads.push(thread_id.clone());
|
||||
}
|
||||
}
|
||||
events.send(ReaderEvent::ThreadReady { thread_id }).ok();
|
||||
}
|
||||
PendingRequest::Resume => {
|
||||
let parsed = serde_json::from_value::<ThreadResumeResponse>(response.result)
|
||||
.context("decode thread/resume response")?;
|
||||
let thread_id = parsed.thread.id;
|
||||
{
|
||||
let mut state = state.lock().expect("state lock poisoned");
|
||||
state.thread_id = Some(thread_id.clone());
|
||||
if !state.known_threads.iter().any(|id| id == &thread_id) {
|
||||
state.known_threads.push(thread_id.clone());
|
||||
}
|
||||
}
|
||||
events.send(ReaderEvent::ThreadReady { thread_id }).ok();
|
||||
}
|
||||
PendingRequest::List => {
|
||||
let parsed = serde_json::from_value::<ThreadListResponse>(response.result)
|
||||
.context("decode thread/list response")?;
|
||||
let thread_ids: Vec<String> = parsed.data.into_iter().map(|thread| thread.id).collect();
|
||||
{
|
||||
let mut state = state.lock().expect("state lock poisoned");
|
||||
for thread_id in &thread_ids {
|
||||
if !state.known_threads.iter().any(|id| id == thread_id) {
|
||||
state.known_threads.push(thread_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
events
|
||||
.send(ReaderEvent::ThreadList {
|
||||
thread_ids,
|
||||
next_cursor: parsed.next_cursor,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_filtered_notification(
|
||||
notification: JSONRPCNotification,
|
||||
output: &Output,
|
||||
) -> anyhow::Result<()> {
|
||||
let Ok(server_notification) = ServerNotification::try_from(notification) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match server_notification {
|
||||
ServerNotification::ItemCompleted(payload) => {
|
||||
emit_filtered_item(payload.item, &payload.thread_id, output)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_filtered_item(item: ThreadItem, thread_id: &str, output: &Output) -> anyhow::Result<()> {
|
||||
let thread_label = output.format_label(thread_id, LabelColor::Thread);
|
||||
match item {
|
||||
ThreadItem::AgentMessage { text, .. } => {
|
||||
let label = output.format_label("assistant", LabelColor::Assistant);
|
||||
output.server_line(&format!("{thread_label} {label}: {text}"))?;
|
||||
}
|
||||
ThreadItem::Plan { text, .. } => {
|
||||
let label = output.format_label("assistant", LabelColor::Assistant);
|
||||
output.server_line(&format!("{thread_label} {label}: plan"))?;
|
||||
write_multiline(output, &thread_label, &format!("{label}:"), &text)?;
|
||||
}
|
||||
ThreadItem::CommandExecution {
|
||||
command,
|
||||
status,
|
||||
exit_code,
|
||||
aggregated_output,
|
||||
..
|
||||
} => {
|
||||
let label = output.format_label("tool", LabelColor::Tool);
|
||||
output.server_line(&format!(
|
||||
"{thread_label} {label}: command {command} ({status:?})"
|
||||
))?;
|
||||
if let Some(exit_code) = exit_code {
|
||||
let label = output.format_label("tool exit", LabelColor::ToolMeta);
|
||||
output.server_line(&format!("{thread_label} {label}: {exit_code}"))?;
|
||||
}
|
||||
if let Some(aggregated_output) = aggregated_output {
|
||||
let label = output.format_label("tool output", LabelColor::ToolMeta);
|
||||
write_multiline(
|
||||
output,
|
||||
&thread_label,
|
||||
&format!("{label}:"),
|
||||
&aggregated_output,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ThreadItem::FileChange {
|
||||
changes, status, ..
|
||||
} => {
|
||||
let label = output.format_label("tool", LabelColor::Tool);
|
||||
output.server_line(&format!(
|
||||
"{thread_label} {label}: file change ({status:?}, {} files)",
|
||||
changes.len()
|
||||
))?;
|
||||
}
|
||||
ThreadItem::McpToolCall {
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
arguments,
|
||||
result,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
let label = output.format_label("tool", LabelColor::Tool);
|
||||
output.server_line(&format!(
|
||||
"{thread_label} {label}: {server}.{tool} ({status:?})"
|
||||
))?;
|
||||
if !arguments.is_null() {
|
||||
let label = output.format_label("tool args", LabelColor::ToolMeta);
|
||||
output.server_line(&format!("{thread_label} {label}: {arguments}"))?;
|
||||
}
|
||||
if let Some(result) = result {
|
||||
let label = output.format_label("tool result", LabelColor::ToolMeta);
|
||||
output.server_line(&format!("{thread_label} {label}: {result:?}"))?;
|
||||
}
|
||||
if let Some(error) = error {
|
||||
let label = output.format_label("tool error", LabelColor::ToolMeta);
|
||||
output.server_line(&format!("{thread_label} {label}: {error:?}"))?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_multiline(
|
||||
output: &Output,
|
||||
thread_label: &str,
|
||||
header: &str,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
output.server_line(&format!("{thread_label} {header}"))?;
|
||||
for line in text.lines() {
|
||||
output.server_line(&format!("{thread_label} {line}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_response<T: Serialize>(
|
||||
stdin: &Arc<Mutex<Option<std::process::ChildStdin>>>,
|
||||
request_id: codex_app_server_protocol::RequestId,
|
||||
response: T,
|
||||
) -> anyhow::Result<()> {
|
||||
let result = serde_json::to_value(response).context("serialize response")?;
|
||||
let message = JSONRPCResponse {
|
||||
id: request_id,
|
||||
result,
|
||||
};
|
||||
let json = serde_json::to_string(&message).context("serialize response message")?;
|
||||
let mut line = json;
|
||||
line.push('\n');
|
||||
|
||||
let mut stdin = stdin.lock().expect("stdin lock poisoned");
|
||||
let Some(stdin) = stdin.as_mut() else {
|
||||
anyhow::bail!("stdin already closed");
|
||||
};
|
||||
stdin.write_all(line.as_bytes()).context("write response")?;
|
||||
stdin.flush().context("flush response")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_app_server_protocol::RequestId;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {
|
||||
pub pending: HashMap<RequestId, PendingRequest>,
|
||||
pub thread_id: Option<String>,
|
||||
pub known_threads: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PendingRequest {
|
||||
Start,
|
||||
Resume,
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ReaderEvent {
|
||||
ThreadReady {
|
||||
thread_id: String,
|
||||
},
|
||||
ThreadList {
|
||||
thread_ids: Vec<String>,
|
||||
next_cursor: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - in a remote environment, that means the remote runtime after the
|
||||
//! orchestrator has forwarded `http/request` over JSON-RPC
|
||||
|
||||
use std::error::Error as StdError;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
@@ -135,15 +136,21 @@ impl ReqwestHttpRequestRunner {
|
||||
}
|
||||
|
||||
let headers = Self::build_headers(params.headers)?;
|
||||
let mut request = self.client.request(method, url).headers(headers);
|
||||
let mut request = self.client.request(method.clone(), url).headers(headers);
|
||||
if let Some(body) = params.body {
|
||||
request = request.body(body.into_inner());
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| internal_error(format!("http/request failed: {error}")))?;
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
let error_message = error.to_string();
|
||||
log_send_error(&method, error);
|
||||
return Err(internal_error(format!(
|
||||
"http/request failed: {error_message}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let status = response.status().as_u16();
|
||||
let headers = Self::response_headers(response.headers());
|
||||
|
||||
@@ -265,3 +272,26 @@ impl ReqwestHttpRequestRunner {
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn log_send_error(method: &Method, error: reqwest::Error) {
|
||||
let error = error.without_url();
|
||||
let source_chain = error_source_chain(&error);
|
||||
tracing::warn!(
|
||||
http_method = method.as_str(),
|
||||
error_is_timeout = error.is_timeout(),
|
||||
error_is_connect = error.is_connect(),
|
||||
error = %error,
|
||||
error_sources = ?source_chain,
|
||||
"http/request send failed"
|
||||
);
|
||||
}
|
||||
|
||||
fn error_source_chain(error: &reqwest::Error) -> Option<String> {
|
||||
let mut sources = Vec::new();
|
||||
let mut source = error.source();
|
||||
while let Some(error) = source {
|
||||
sources.push(error.to_string());
|
||||
source = error.source();
|
||||
}
|
||||
(!sources.is_empty()).then(|| sources.join(": "))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use capabilities::ResponseItemInjector;
|
||||
pub use codex_tools::ConversationHistory;
|
||||
pub use codex_tools::ExtensionTurnItem;
|
||||
pub use codex_tools::FunctionCallError;
|
||||
pub use codex_tools::ImageGenerationCompletionFuture;
|
||||
pub use codex_tools::JsonToolOutput;
|
||||
pub use codex_tools::NoopTurnItemEmitter;
|
||||
pub use codex_tools::ResponsesApiTool;
|
||||
|
||||
@@ -17,11 +17,13 @@ use codex_extension_api::ToolFinishInput;
|
||||
use codex_extension_api::ToolLifecycleContributor;
|
||||
use codex_extension_api::ToolLifecycleFuture;
|
||||
use codex_extension_api::TurnAbortInput;
|
||||
use codex_extension_api::TurnErrorInput;
|
||||
use codex_extension_api::TurnLifecycleContributor;
|
||||
use codex_extension_api::TurnStartInput;
|
||||
use codex_extension_api::TurnStopInput;
|
||||
use codex_otel::MetricsClient;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
@@ -248,6 +250,22 @@ where
|
||||
}
|
||||
runtime.accounting_state().finish_turn(turn_id);
|
||||
}
|
||||
|
||||
async fn on_turn_error(&self, input: TurnErrorInput<'_>) {
|
||||
if input.error != CodexErrorInfo::UsageLimitExceeded {
|
||||
return;
|
||||
}
|
||||
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = runtime
|
||||
.usage_limit_active_goal_for_turn(input.turn_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to usage-limit active goal after usage-limit error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_core::ThreadManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
|
||||
use crate::accounting::BudgetLimitedGoalDisposition;
|
||||
@@ -275,7 +275,7 @@ impl GoalRuntimeHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn inject_active_turn_steering(&self, item: ResponseInputItem) {
|
||||
pub(crate) async fn inject_active_turn_steering(&self, item: ResponseItem) {
|
||||
let Some(thread_manager) = self.inner.thread_manager.upgrade() else {
|
||||
tracing::debug!("skipping goal steering because thread manager is unavailable");
|
||||
return;
|
||||
@@ -284,11 +284,7 @@ impl GoalRuntimeHandle {
|
||||
tracing::debug!("skipping goal steering because live thread is unavailable");
|
||||
return;
|
||||
};
|
||||
if thread
|
||||
.inject_response_items_into_active_turn(vec![item])
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if thread.inject_if_running(vec![item]).await.is_err() {
|
||||
tracing::debug!("skipping goal steering because no turn is active");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
use codex_core::context::ContextualUserFragment;
|
||||
use codex_core::context::InternalContextSource;
|
||||
use codex_core::context::InternalModelContextFragment;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
|
||||
pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
|
||||
pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem {
|
||||
goal_context_input_item(budget_limit_prompt(goal))
|
||||
}
|
||||
|
||||
pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
|
||||
pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseItem {
|
||||
goal_context_input_item(objective_updated_prompt(goal))
|
||||
}
|
||||
|
||||
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
|
||||
InternalModelContextFragment::new(InternalContextSource::from_static("goal"), prompt)
|
||||
.into_response_input_item()
|
||||
fn goal_context_input_item(prompt: String) -> ResponseItem {
|
||||
ContextualUserFragment::into(InternalModelContextFragment::new(
|
||||
InternalContextSource::from_static("goal"),
|
||||
prompt,
|
||||
))
|
||||
}
|
||||
|
||||
fn budget_limit_prompt(goal: &ThreadGoal) -> String {
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_extension_api::ToolCallSource;
|
||||
use codex_extension_api::ToolExecutor;
|
||||
use codex_extension_api::ToolFinishInput;
|
||||
use codex_extension_api::ToolPayload;
|
||||
use codex_extension_api::TurnErrorInput;
|
||||
use codex_extension_api::TurnStartInput;
|
||||
use codex_extension_api::TurnStopInput;
|
||||
use codex_goal_extension::GoalRuntimeHandle;
|
||||
@@ -26,6 +27,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -398,7 +400,7 @@ async fn budget_limited_goal_keeps_accounting_after_later_tool_finish() -> anyho
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> anyhow::Result<()> {
|
||||
async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> anyhow::Result<()> {
|
||||
let runtime = test_runtime().await?;
|
||||
let thread_id = test_thread_id()?;
|
||||
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
|
||||
@@ -425,11 +427,18 @@ async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> an
|
||||
),
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.runtime_handle()
|
||||
.usage_limit_active_goal_for_turn("turn-1")
|
||||
.await
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let turn_store = ExtensionData::new("turn-1");
|
||||
for contributor in harness.registry.turn_lifecycle_contributors() {
|
||||
contributor
|
||||
.on_turn_error(TurnErrorInput {
|
||||
turn_id: "turn-1",
|
||||
error: CodexErrorInfo::UsageLimitExceeded,
|
||||
session_store: &harness.session_store,
|
||||
thread_store: &harness.thread_store,
|
||||
turn_store: &turn_store,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
|
||||
@@ -14,7 +14,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
codex-api = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
@@ -28,10 +27,6 @@ http = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
@@ -17,7 +16,6 @@ use codex_model_provider_info::ModelProviderInfo;
|
||||
|
||||
use crate::backend::CodexImagesBackend;
|
||||
use crate::tool::ImageGenerationTool;
|
||||
use crate::tool::generated_image_output_dir;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ImageGenerationExtension {
|
||||
@@ -28,7 +26,6 @@ struct ImageGenerationExtension {
|
||||
struct ImageGenerationExtensionConfig {
|
||||
enabled: bool,
|
||||
provider: ModelProviderInfo,
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl From<&Config> for ImageGenerationExtensionConfig {
|
||||
@@ -38,7 +35,6 @@ impl From<&Config> for ImageGenerationExtensionConfig {
|
||||
enabled: config.features.enabled(Feature::ImageGenExt)
|
||||
&& config.model_provider.is_openai(),
|
||||
provider: config.model_provider.clone(),
|
||||
codex_home: config.codex_home.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,13 +76,9 @@ impl ToolContributor for ImageGenerationExtension {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Arc::new(ImageGenerationTool::new(
|
||||
CodexImagesBackend::new(create_model_provider(
|
||||
config.provider.clone(),
|
||||
Some(self.auth_manager.clone()),
|
||||
)),
|
||||
generated_image_output_dir(&config.codex_home, thread_store.level_id()),
|
||||
))]
|
||||
vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new(
|
||||
create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())),
|
||||
)))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,13 @@ use super::GeneratedImageOutput;
|
||||
use super::ImageRequest;
|
||||
use super::ImagegenAction;
|
||||
use super::ImagegenArgs;
|
||||
use super::generated_image_output_dir;
|
||||
use super::imagegen_tool_spec;
|
||||
use super::persist_generated_image;
|
||||
use super::request_for_action;
|
||||
use crate::IMAGE_GEN_NAMESPACE;
|
||||
use crate::IMAGEGEN_TOOL_NAME;
|
||||
|
||||
const RESULT: &str = "cG5n";
|
||||
const OUTPUT_HINT: &str = "Generated images are saved to /tmp as /tmp/call-1.png by default.";
|
||||
|
||||
#[test]
|
||||
fn uses_reserved_image_gen_namespace() {
|
||||
@@ -55,15 +54,11 @@ fn generate_uses_fixed_request_defaults() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_output_returns_image_input_and_persists_artifact() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT)
|
||||
.await
|
||||
.expect("generated image should persist");
|
||||
#[test]
|
||||
fn generated_output_returns_image_input_and_output_hint() {
|
||||
let output = GeneratedImageOutput {
|
||||
result: RESULT.to_string(),
|
||||
output_hint: Some(output_hint),
|
||||
output_hint: Some(OUTPUT_HINT.to_string()),
|
||||
};
|
||||
|
||||
let ResponseInputItem::FunctionCallOutput {
|
||||
@@ -84,19 +79,10 @@ async fn generated_output_returns_image_input_and_persists_artifact() {
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: format!(
|
||||
"Generated images are saved to {} as {} by default.\n\
|
||||
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
tempdir.path().display(),
|
||||
tempdir.path().join("call-1.png").display(),
|
||||
),
|
||||
text: OUTPUT_HINT.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"),
|
||||
b"png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -265,14 +251,6 @@ fn edit_without_image_history_returns_tool_error() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() {
|
||||
assert_eq!(
|
||||
generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"),
|
||||
std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1")
|
||||
);
|
||||
}
|
||||
|
||||
fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs {
|
||||
ImagegenArgs {
|
||||
prompt: prompt.to_string(),
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_api::ImageBackground;
|
||||
use codex_api::ImageEditRequest;
|
||||
use codex_api::ImageGenerationRequest;
|
||||
@@ -41,21 +36,16 @@ use crate::backend::CodexImagesBackend;
|
||||
const IMAGE_MODEL: &str = "gpt-image-2";
|
||||
const MAX_EDIT_IMAGES: usize = 5;
|
||||
const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md");
|
||||
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ImageGenerationTool {
|
||||
backend: CodexImagesBackend,
|
||||
output_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ImageGenerationTool {
|
||||
/// Creates an image-generation tool backed by an image API executor.
|
||||
pub(crate) fn new(backend: CodexImagesBackend, output_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
output_dir,
|
||||
}
|
||||
pub(crate) fn new(backend: CodexImagesBackend) -> Self {
|
||||
Self { backend }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +84,6 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
|
||||
async fn handle(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
|
||||
let args = parse_args(&call)?;
|
||||
let request = request_for_action(&args, call.conversation_history.items())?;
|
||||
|
||||
let response = match request {
|
||||
ImageRequest::Generate(request) => self.backend.generate(request).await,
|
||||
ImageRequest::Edit(request) => self.backend.edit(request).await,
|
||||
@@ -107,18 +96,10 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
|
||||
"image generation returned no image data".to_string(),
|
||||
));
|
||||
};
|
||||
let output_hint =
|
||||
match persist_generated_image(&self.output_dir, &call.call_id, &result).await {
|
||||
Ok(output_hint) => Some(output_hint),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
call_id = %call.call_id,
|
||||
output_dir = %self.output_dir.display(),
|
||||
"failed to save generated image: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let output_hint = call
|
||||
.turn_item_emitter
|
||||
.image_generation_completed(call.call_id.clone(), args.prompt, result.clone())
|
||||
.await;
|
||||
Ok(Box::new(GeneratedImageOutput {
|
||||
result,
|
||||
output_hint,
|
||||
@@ -268,58 +249,6 @@ fn parse_args(call: &ToolCall) -> Result<ImagegenArgs, FunctionCallError> {
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
|
||||
}
|
||||
|
||||
/// Resolves where generated images for one thread are persisted by the extension.
|
||||
pub(crate) fn generated_image_output_dir(codex_home: &Path, thread_id: &str) -> PathBuf {
|
||||
codex_home
|
||||
.join(GENERATED_IMAGE_ARTIFACTS_DIR)
|
||||
.join(sanitize_path_component(thread_id))
|
||||
}
|
||||
|
||||
fn generated_image_output_path(output_dir: &Path, call_id: &str) -> PathBuf {
|
||||
output_dir.join(format!("{}.png", sanitize_path_component(call_id)))
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str) -> String {
|
||||
let sanitized: String = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if sanitized.is_empty() {
|
||||
"generated_image".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_generated_image(
|
||||
output_dir: &Path,
|
||||
call_id: &str,
|
||||
result: &str,
|
||||
) -> Result<String, String> {
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(result.trim().as_bytes())
|
||||
.map_err(|err| format!("invalid image generation payload: {err}"))?;
|
||||
tokio::fs::create_dir_all(output_dir)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
tokio::fs::write(generated_image_output_path(output_dir, call_id), bytes)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(format!(
|
||||
"Generated images are saved to {} as {} by default.\n\
|
||||
If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
output_dir.display(),
|
||||
generated_image_output_path(output_dir, call_id).display(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds the namespace function schema exposed to the model.
|
||||
fn imagegen_tool_spec() -> ToolSpec {
|
||||
let mut schema_value = serde_json::to_value(
|
||||
@@ -369,7 +298,7 @@ impl ToolOutput for GeneratedImageOutput {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns generated bytes and persisted-artifact context for the model's follow-up response.
|
||||
/// Returns generated bytes and persisted-artifact context for model follow-up.
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
let mut content = vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: format!("data:image/png;base64,{}", self.result),
|
||||
|
||||
@@ -26,6 +26,7 @@ codex-tools = { workspace = true }
|
||||
http = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -146,6 +146,8 @@ mod tests {
|
||||
use super::Config;
|
||||
use super::WebSearchExtensionConfig;
|
||||
use super::install;
|
||||
use crate::tool::RUN_TOOL_NAME;
|
||||
use crate::tool::WEB_NAMESPACE;
|
||||
|
||||
#[test]
|
||||
fn installed_extension_contributes_web_run_when_enabled() {
|
||||
@@ -170,6 +172,9 @@ mod tests {
|
||||
.map(|tool| tool.tool_name())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(tool_names, vec![ToolName::namespaced("web", "run")]);
|
||||
assert_eq!(
|
||||
tool_names,
|
||||
vec![ToolName::namespaced(WEB_NAMESPACE, RUN_TOOL_NAME)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_api::SearchClient;
|
||||
use codex_api::SearchCommands;
|
||||
use codex_api::SearchQuery;
|
||||
use codex_api::SearchRequest;
|
||||
use codex_api::SearchSettings;
|
||||
use codex_core::web_search_action_detail;
|
||||
use codex_extension_api::ExtensionTurnItem;
|
||||
use codex_extension_api::FunctionCallError;
|
||||
use codex_extension_api::ResponsesApiTool;
|
||||
use codex_extension_api::ToolCall;
|
||||
@@ -13,18 +16,21 @@ use codex_extension_api::ToolSpec;
|
||||
use codex_extension_api::parse_tool_input_schema_without_compaction;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_model_provider::SharedModelProvider;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolExposure;
|
||||
use codex_tools::default_namespace_description;
|
||||
use http::HeaderMap;
|
||||
use url::Url;
|
||||
|
||||
use crate::history::recent_input;
|
||||
use crate::output::EncryptedSearchOutput;
|
||||
use crate::schema::commands_schema;
|
||||
|
||||
const WEB_NAMESPACE: &str = "web";
|
||||
const RUN_TOOL_NAME: &str = "run";
|
||||
pub(crate) const WEB_NAMESPACE: &str = "web";
|
||||
pub(crate) const RUN_TOOL_NAME: &str = "run";
|
||||
const WEB_RUN_DESCRIPTION: &str = include_str!("../web_run_description.md");
|
||||
|
||||
pub(crate) struct WebSearchTool {
|
||||
@@ -66,6 +72,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
|
||||
|
||||
async fn handle(&self, call: ToolCall) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
|
||||
let commands = parse_commands(&call)?;
|
||||
let command_action = command_action(&commands);
|
||||
let provider = self
|
||||
.provider
|
||||
.api_provider()
|
||||
@@ -92,10 +99,16 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
|
||||
u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX),
|
||||
),
|
||||
};
|
||||
call.turn_item_emitter
|
||||
.emit_started(web_search_item(&call.call_id, WebSearchAction::Other))
|
||||
.await;
|
||||
let response = client
|
||||
.search(&request, HeaderMap::new())
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
|
||||
call.turn_item_emitter
|
||||
.emit_completed(web_search_item(&call.call_id, command_action))
|
||||
.await;
|
||||
|
||||
Ok(Box::new(EncryptedSearchOutput::new(
|
||||
response.encrypted_output,
|
||||
@@ -112,3 +125,110 @@ fn parse_commands(call: &ToolCall) -> Result<SearchCommands, FunctionCallError>
|
||||
serde_json::from_str(arguments)
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
|
||||
}
|
||||
|
||||
fn command_action(commands: &SearchCommands) -> WebSearchAction {
|
||||
commands
|
||||
.search_query
|
||||
.as_deref()
|
||||
.and_then(query_action)
|
||||
.or_else(|| commands.image_query.as_deref().and_then(query_action))
|
||||
.or_else(|| {
|
||||
commands
|
||||
.open
|
||||
.as_deref()
|
||||
.and_then(|operations| operations.first())
|
||||
.and_then(|operation| {
|
||||
literal_url(&operation.ref_id)
|
||||
.map(|url| WebSearchAction::OpenPage { url: Some(url) })
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
commands
|
||||
.find
|
||||
.as_deref()
|
||||
.and_then(|operations| operations.first())
|
||||
.map(|operation| WebSearchAction::FindInPage {
|
||||
url: literal_url(&operation.ref_id),
|
||||
pattern: Some(operation.pattern.clone()),
|
||||
})
|
||||
})
|
||||
.unwrap_or(WebSearchAction::Other)
|
||||
}
|
||||
|
||||
fn query_action(queries: &[SearchQuery]) -> Option<WebSearchAction> {
|
||||
match queries {
|
||||
[] => None,
|
||||
[query] => Some(WebSearchAction::Search {
|
||||
query: Some(query.q.clone()),
|
||||
queries: None,
|
||||
}),
|
||||
queries => Some(WebSearchAction::Search {
|
||||
query: None,
|
||||
queries: Some(queries.iter().map(|query| query.q.clone()).collect()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal_url(ref_id: &str) -> Option<String> {
|
||||
Url::parse(ref_id).is_ok().then(|| ref_id.to_string())
|
||||
}
|
||||
|
||||
fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem {
|
||||
ExtensionTurnItem::WebSearch(WebSearchItem {
|
||||
id: call_id.to_string(),
|
||||
query: web_search_action_detail(&action),
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_api::SearchCommands;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::command_action;
|
||||
|
||||
#[test]
|
||||
fn command_action_reports_queries_and_navigation_detail() {
|
||||
let cases = [
|
||||
(
|
||||
r#"{"image_query":[{"q":"waterfalls"},{"q":"mountains"}]}"#,
|
||||
WebSearchAction::Search {
|
||||
query: None,
|
||||
queries: Some(vec!["waterfalls".to_string(), "mountains".to_string()]),
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"{"open":[{"ref_id":"https://example.com/docs"}]}"#,
|
||||
WebSearchAction::OpenPage {
|
||||
url: Some("https://example.com/docs".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"{"find":[{"ref_id":"https://example.com/docs","pattern":"install"}]}"#,
|
||||
WebSearchAction::FindInPage {
|
||||
url: Some("https://example.com/docs".to_string()),
|
||||
pattern: Some("install".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"{"find":[{"ref_id":"turn0search0","pattern":"install"}]}"#,
|
||||
WebSearchAction::FindInPage {
|
||||
url: None,
|
||||
pattern: Some("install".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
r#"{"open":[{"ref_id":"turn0search0"}]}"#,
|
||||
WebSearchAction::Other,
|
||||
),
|
||||
];
|
||||
|
||||
for (arguments, expected) in cases {
|
||||
let commands: SearchCommands =
|
||||
serde_json::from_str(arguments).expect("valid search command arguments");
|
||||
assert_eq!(command_action(&commands), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: true, // this is the fallback model metadata
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::str::FromStr;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
@@ -227,6 +229,25 @@ pub enum TruncationMode {
|
||||
Tokens,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolMode {
|
||||
Direct,
|
||||
CodeMode,
|
||||
CodeModeOnly,
|
||||
}
|
||||
|
||||
fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let Some(value) = Option::<String>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(serde_json::from_value(serde_json::Value::String(value)).ok())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct TruncationPolicyConfig {
|
||||
pub mode: TruncationMode,
|
||||
@@ -318,6 +339,12 @@ pub struct ModelInfo {
|
||||
pub used_fallback_model_metadata: bool,
|
||||
#[serde(default)]
|
||||
pub supports_search_tool: bool,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_optional_model_selector"
|
||||
)]
|
||||
pub tool_mode: Option<ToolMode>,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
@@ -612,6 +639,7 @@ mod tests {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,6 +857,44 @@ mod tests {
|
||||
assert!(!model.supports_image_detail_original);
|
||||
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
|
||||
assert!(!model.supports_search_tool);
|
||||
assert_eq!(model.tool_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_info_deserializes_known_tool_mode() {
|
||||
let mut value =
|
||||
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.expect("model info should be an object");
|
||||
object.insert(
|
||||
"tool_mode".to_string(),
|
||||
serde_json::Value::String("code_mode_only".to_string()),
|
||||
);
|
||||
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
||||
|
||||
assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_info_treats_unknown_tool_mode_as_omitted() {
|
||||
let mut value =
|
||||
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.expect("model info should be an object");
|
||||
object.insert(
|
||||
"tool_mode".to_string(),
|
||||
serde_json::Value::String("future_tool_mode".to_string()),
|
||||
);
|
||||
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
||||
|
||||
assert_eq!(model.tool_mode, None);
|
||||
let serialized = serde_json::to_value(model).expect("serialize model info");
|
||||
let object = serialized
|
||||
.as_object()
|
||||
.expect("model info should be an object");
|
||||
assert!(!object.contains_key("tool_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,7 @@ use reqwest::header::CONTENT_TYPE;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
use rmcp::model::ClientJsonRpcMessage;
|
||||
use rmcp::model::JsonRpcMessage;
|
||||
use rmcp::model::ServerJsonRpcMessage;
|
||||
use rmcp::transport::streamable_http_client::AuthRequiredError;
|
||||
use rmcp::transport::streamable_http_client::InsufficientScopeError;
|
||||
@@ -88,6 +89,8 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
auth_token: Option<String>,
|
||||
custom_headers: HashMap<HeaderName, reqwest::header::HeaderValue>,
|
||||
) -> std::result::Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
|
||||
let (mcp_method, mcp_request_id) = client_jsonrpc_message_fields(&message);
|
||||
let has_session_id = session_id.is_some();
|
||||
let mut headers = self.default_headers.clone();
|
||||
headers.extend(custom_headers);
|
||||
self.add_auth_headers(&mut headers);
|
||||
@@ -121,7 +124,8 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
}
|
||||
|
||||
let body = serde_json::to_vec(&message).map_err(StreamableHttpError::Deserialize)?;
|
||||
let (response, mut body_stream) = self
|
||||
let has_authorization_header = headers.contains_key(AUTHORIZATION);
|
||||
let response = self
|
||||
.http_client
|
||||
.http_request_stream(HttpRequestParams {
|
||||
method: "POST".to_string(),
|
||||
@@ -132,9 +136,22 @@ impl StreamableHttpClient for StreamableHttpClientAdapter {
|
||||
request_id: "buffered-request".to_string(),
|
||||
stream_response: true,
|
||||
})
|
||||
.await
|
||||
.map_err(StreamableHttpClientAdapterError::from)
|
||||
.map_err(StreamableHttpError::Client)?;
|
||||
.await;
|
||||
let (response, mut body_stream) = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
log_post_message_http_error(
|
||||
&uri,
|
||||
mcp_method.as_deref(),
|
||||
mcp_request_id.as_deref(),
|
||||
has_session_id,
|
||||
has_authorization_header,
|
||||
);
|
||||
return Err(StreamableHttpError::Client(
|
||||
StreamableHttpClientAdapterError::from(error),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if response.status == StatusCode::NOT_FOUND.as_u16() && session_id.is_some() {
|
||||
return Err(StreamableHttpError::Client(
|
||||
@@ -354,6 +371,50 @@ fn body_preview(body: impl Into<String>) -> String {
|
||||
body_preview
|
||||
}
|
||||
|
||||
fn client_jsonrpc_message_fields(
|
||||
message: &ClientJsonRpcMessage,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
match message {
|
||||
JsonRpcMessage::Request(request) => (
|
||||
Some(request.request.method().to_string()),
|
||||
Some(request.id.to_string()),
|
||||
),
|
||||
JsonRpcMessage::Response(response) => (None, Some(response.id.to_string())),
|
||||
JsonRpcMessage::Notification(_) => (None, None),
|
||||
JsonRpcMessage::Error(error) => (None, error.id.as_ref().map(ToString::to_string)),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_post_message_http_error(
|
||||
uri: &str,
|
||||
mcp_method: Option<&str>,
|
||||
mcp_request_id: Option<&str>,
|
||||
has_session_id: bool,
|
||||
has_authorization_header: bool,
|
||||
) {
|
||||
let parsed_url = reqwest::Url::parse(uri).ok();
|
||||
tracing::warn!(
|
||||
endpoint_scheme = parsed_url
|
||||
.as_ref()
|
||||
.map(reqwest::Url::scheme)
|
||||
.unwrap_or("<invalid>"),
|
||||
endpoint_host = parsed_url
|
||||
.as_ref()
|
||||
.and_then(reqwest::Url::host_str)
|
||||
.unwrap_or("<invalid>"),
|
||||
endpoint_path = parsed_url
|
||||
.as_ref()
|
||||
.map(reqwest::Url::path)
|
||||
.unwrap_or("<invalid>"),
|
||||
endpoint_has_query = parsed_url.as_ref().is_some_and(|url| url.query().is_some()),
|
||||
mcp_method = mcp_method.unwrap_or("<none>"),
|
||||
mcp_request_id = mcp_request_id.unwrap_or("<none>"),
|
||||
has_session_id = has_session_id,
|
||||
has_authorization_header = has_authorization_header,
|
||||
"streamable HTTP post_message failed"
|
||||
);
|
||||
}
|
||||
|
||||
fn insert_header<Error>(
|
||||
headers: &mut HeaderMap,
|
||||
name: HeaderName,
|
||||
|
||||
@@ -94,133 +94,6 @@ async fn insert_state_db_thread(
|
||||
runtime
|
||||
}
|
||||
|
||||
// TODO(jif) fix
|
||||
// #[tokio::test]
|
||||
// async fn list_threads_prefers_state_db_when_available() {
|
||||
// let temp = TempDir::new().unwrap();
|
||||
// let home = temp.path();
|
||||
// let fs_uuid = Uuid::from_u128(101);
|
||||
// write_session_file(
|
||||
// home,
|
||||
// "2025-01-03T13-00-00",
|
||||
// fs_uuid,
|
||||
// 1,
|
||||
// Some(SessionSource::Cli),
|
||||
// )
|
||||
// .unwrap();
|
||||
//
|
||||
// let db_uuid = Uuid::from_u128(102);
|
||||
// let db_thread_id = ThreadId::from_string(&db_uuid.to_string()).expect("valid thread id");
|
||||
// let db_rollout_path = home.join(format!(
|
||||
// "sessions/2025/01/03/rollout-2025-01-03T12-00-00-{db_uuid}.jsonl"
|
||||
// ));
|
||||
// insert_state_db_thread(home, db_thread_id, db_rollout_path.as_path(), false).await;
|
||||
//
|
||||
// let page = RolloutRecorder::list_threads(
|
||||
// home,
|
||||
// 10,
|
||||
// None,
|
||||
// ThreadSortKey::CreatedAt,
|
||||
// NO_SOURCE_FILTER,
|
||||
// None,
|
||||
// TEST_PROVIDER,
|
||||
// )
|
||||
// .await
|
||||
// .expect("thread listing should succeed");
|
||||
//
|
||||
// assert_eq!(page.items.len(), 1);
|
||||
// assert_eq!(page.items[0].path, db_rollout_path);
|
||||
// assert_eq!(page.items[0].thread_id, Some(db_thread_id));
|
||||
// assert_eq!(page.items[0].cwd, Some(home.to_path_buf()));
|
||||
// assert_eq!(
|
||||
// page.items[0].first_user_message.as_deref(),
|
||||
// Some("Hello from user")
|
||||
// );
|
||||
// }
|
||||
|
||||
// #[tokio::test]
|
||||
// async fn list_threads_db_excludes_archived_entries() {
|
||||
// let temp = TempDir::new().unwrap();
|
||||
// let home = temp.path();
|
||||
// let sessions_root = home.join("sessions/2025/01/03");
|
||||
// let archived_root = home.join("archived_sessions");
|
||||
// fs::create_dir_all(&sessions_root).unwrap();
|
||||
// fs::create_dir_all(&archived_root).unwrap();
|
||||
//
|
||||
// let active_uuid = Uuid::from_u128(211);
|
||||
// let active_thread_id =
|
||||
// ThreadId::from_string(&active_uuid.to_string()).expect("valid active thread id");
|
||||
// let active_rollout_path =
|
||||
// sessions_root.join(format!("rollout-2025-01-03T12-00-00-{active_uuid}.jsonl"));
|
||||
// insert_state_db_thread(home, active_thread_id, active_rollout_path.as_path(), false).await;
|
||||
//
|
||||
// let archived_uuid = Uuid::from_u128(212);
|
||||
// let archived_thread_id =
|
||||
// ThreadId::from_string(&archived_uuid.to_string()).expect("valid archived thread id");
|
||||
// let archived_rollout_path =
|
||||
// archived_root.join(format!("rollout-2025-01-03T11-00-00-{archived_uuid}.jsonl"));
|
||||
// insert_state_db_thread(
|
||||
// home,
|
||||
// archived_thread_id,
|
||||
// archived_rollout_path.as_path(),
|
||||
// true,
|
||||
// )
|
||||
// .await;
|
||||
//
|
||||
// let page = RolloutRecorder::list_threads(
|
||||
// home,
|
||||
// 10,
|
||||
// None,
|
||||
// ThreadSortKey::CreatedAt,
|
||||
// NO_SOURCE_FILTER,
|
||||
// None,
|
||||
// TEST_PROVIDER,
|
||||
// )
|
||||
// .await
|
||||
// .expect("thread listing should succeed");
|
||||
//
|
||||
// assert_eq!(page.items.len(), 1);
|
||||
// assert_eq!(page.items[0].path, active_rollout_path);
|
||||
// }
|
||||
|
||||
// #[tokio::test]
|
||||
// async fn list_threads_falls_back_to_files_when_state_db_is_unavailable() {
|
||||
// let temp = TempDir::new().unwrap();
|
||||
// let home = temp.path();
|
||||
// let fs_uuid = Uuid::from_u128(301);
|
||||
// write_session_file(
|
||||
// home,
|
||||
// "2025-01-03T13-00-00",
|
||||
// fs_uuid,
|
||||
// 1,
|
||||
// Some(SessionSource::Cli),
|
||||
// )
|
||||
// .unwrap();
|
||||
//
|
||||
// let page = RolloutRecorder::list_threads(
|
||||
// home,
|
||||
// 10,
|
||||
// None,
|
||||
// ThreadSortKey::CreatedAt,
|
||||
// NO_SOURCE_FILTER,
|
||||
// None,
|
||||
// TEST_PROVIDER,
|
||||
// )
|
||||
// .await
|
||||
// .expect("thread listing should succeed");
|
||||
//
|
||||
// assert_eq!(page.items.len(), 1);
|
||||
// let file_name = page.items[0]
|
||||
// .path
|
||||
// .file_name()
|
||||
// .and_then(|value| value.to_str())
|
||||
// .expect("rollout file name should be utf8");
|
||||
// assert!(
|
||||
// file_name.contains(&fs_uuid.to_string()),
|
||||
// "expected file path from filesystem listing, got: {file_name}"
|
||||
// );
|
||||
// }
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_path_falls_back_when_db_path_is_stale() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -63,6 +63,7 @@ pub use responses_api::mcp_tool_to_responses_api_tool;
|
||||
pub use responses_api::tool_definition_to_responses_api_tool;
|
||||
pub use tool_call::ConversationHistory;
|
||||
pub use tool_call::ExtensionTurnItem;
|
||||
pub use tool_call::ImageGenerationCompletionFuture;
|
||||
pub use tool_call::NoopTurnItemEmitter;
|
||||
pub use tool_call::ToolCall;
|
||||
pub use tool_call::TurnItemEmissionFuture;
|
||||
|
||||
@@ -29,6 +29,10 @@ impl ConversationHistory {
|
||||
/// Future returned when an extension tool emits a visible turn-item lifecycle event.
|
||||
pub type TurnItemEmissionFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
|
||||
/// Future returned when an image-generation extension publishes completed image bytes.
|
||||
pub type ImageGenerationCompletionFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>>;
|
||||
|
||||
/// Visible turn items that an extension fully owns and may emit as-is.
|
||||
///
|
||||
/// Add only item kinds that require no additional host finalization before
|
||||
@@ -48,6 +52,19 @@ pub trait TurnItemEmitter: Send + Sync {
|
||||
|
||||
/// Emits the completion of one visible turn item.
|
||||
fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>;
|
||||
|
||||
/// Publishes image bytes for host persistence and visible completion.
|
||||
///
|
||||
/// Returns persisted-artifact context for the extension's model-facing
|
||||
/// function output when the host saves the generated image successfully.
|
||||
fn image_generation_completed<'a>(
|
||||
&'a self,
|
||||
_call_id: String,
|
||||
_prompt: String,
|
||||
_result: String,
|
||||
) -> ImageGenerationCompletionFuture<'a> {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn-item emitter used when a caller does not expose visible item emission.
|
||||
|
||||
@@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo {
|
||||
input_modalities: codex_protocol::openai_models::default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ started hidden:
|
||||
history:
|
||||
• SessionStart hook (completed)
|
||||
hook context: session context
|
||||
second line
|
||||
|
||||
@@ -3381,7 +3381,7 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
|
||||
codex_app_server_protocol::HookRunStatus::Completed,
|
||||
vec![codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
|
||||
text: "session context".to_string(),
|
||||
text: "session context\nsecond line".to_string(),
|
||||
}],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -48,6 +48,9 @@ const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300);
|
||||
/// enough to read instead of removing it immediately when the success event arrives.
|
||||
const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);
|
||||
|
||||
const HOOK_OUTPUT_INDENT: &str = " ";
|
||||
const HOOK_OUTPUT_BODY_INDENT: &str = " ";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HookRunCell {
|
||||
/// Stable protocol id used to match begin/end updates for the same hook invocation.
|
||||
@@ -443,10 +446,18 @@ impl HookRunCell {
|
||||
.into(),
|
||||
);
|
||||
for entry in entries {
|
||||
// Output entries are already short hook-authored strings; keep their prefixes
|
||||
// explicit so warnings/stops/errors remain easy to scan in history.
|
||||
lines
|
||||
.push(format!(" {}{}", hook_output_prefix(entry.kind), entry.text).into());
|
||||
let prefix = hook_output_prefix(entry.kind);
|
||||
let mut output_lines = entry.text.split('\n');
|
||||
if let Some(first_line) = output_lines.next() {
|
||||
lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into());
|
||||
}
|
||||
for line in output_lines {
|
||||
if line.is_empty() {
|
||||
lines.push("".into());
|
||||
} else {
|
||||
lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HookRunState::PendingReveal { .. } => {}
|
||||
@@ -748,6 +759,50 @@ mod tests {
|
||||
assert!(bullet.style.add_modifier.contains(Modifier::BOLD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_multiline_context_preserves_display_and_raw_lines() {
|
||||
let cell = completed_hook_cell(
|
||||
HookEventName::SessionStart,
|
||||
HookRunStatus::Completed,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Context,
|
||||
text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory"
|
||||
.to_string(),
|
||||
}],
|
||||
);
|
||||
let expected = vec![
|
||||
"• SessionStart hook (completed)".to_string(),
|
||||
" hook context: ## Working Memory Recall".to_string(),
|
||||
"".to_string(),
|
||||
" Source: Codex compaction".to_string(),
|
||||
" Scope: Durable workspace memory".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected);
|
||||
assert_eq!(line_texts(&cell.raw_lines()), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_multiline_warning_prefixes_first_line_only() {
|
||||
let cell = completed_hook_cell(
|
||||
HookEventName::PostToolUse,
|
||||
HookRunStatus::Completed,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: "Heads up\nReview generated files".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
line_texts(&cell.display_lines(/*width*/ 80)),
|
||||
vec![
|
||||
"• PostToolUse hook (completed)".to_string(),
|
||||
" warning: Heads up".to_string(),
|
||||
" Review generated files".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_hook_does_not_animate_transcript() {
|
||||
let cell =
|
||||
@@ -790,12 +845,7 @@ mod tests {
|
||||
let rendered: Vec<String> = cell
|
||||
.display_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.map(line_text)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
@@ -804,6 +854,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn completed_hook_cell(
|
||||
event_name: HookEventName,
|
||||
status: HookRunStatus,
|
||||
entries: Vec<HookOutputEntry>,
|
||||
) -> HookCell {
|
||||
let mut run = hook_run_summary("hook-1");
|
||||
run.event_name = event_name;
|
||||
run.status = status;
|
||||
run.status_message = None;
|
||||
run.completed_at = Some(2);
|
||||
run.duration_ms = Some(1);
|
||||
run.entries = entries;
|
||||
HookCell::new_completed(run, /*animations_enabled*/ false)
|
||||
}
|
||||
|
||||
fn line_texts(lines: &[Line<'_>]) -> Vec<String> {
|
||||
lines.iter().map(line_text).collect()
|
||||
}
|
||||
|
||||
fn line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn hook_run_summary(id: &str) -> HookRunSummary {
|
||||
HookRunSummary {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
|
||||
fn web_search_header(completed: bool) -> &'static str {
|
||||
if completed {
|
||||
"Searched"
|
||||
"Searched the web"
|
||||
} else {
|
||||
"Searching the web"
|
||||
}
|
||||
@@ -104,7 +104,8 @@ impl HistoryCell for WebSearchCell {
|
||||
let text: Text<'static> = if detail.is_empty() {
|
||||
Line::from(vec![header.bold()]).into()
|
||||
} else {
|
||||
Line::from(vec![header.bold(), " ".into(), detail.into()]).into()
|
||||
let separator = if self.completed { " for " } else { " " };
|
||||
Line::from(vec![header.bold(), separator.into(), detail.into()]).into()
|
||||
};
|
||||
PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width)
|
||||
}
|
||||
@@ -115,7 +116,8 @@ impl HistoryCell for WebSearchCell {
|
||||
if detail.is_empty() {
|
||||
vec![Line::from(header)]
|
||||
} else {
|
||||
vec![Line::from(format!("{header} {detail}"))]
|
||||
let separator = if self.completed { " for " } else { " " };
|
||||
vec![Line::from(format!("{header}{separator}{detail}"))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Searched example search query with several generic words to
|
||||
exercise wrapping
|
||||
• Searched the web for example search query with several generic
|
||||
words to exercise wrapping
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Searched example search query with several generic words to
|
||||
exercise wrapping
|
||||
• Searched the web for example search query with several generic
|
||||
words to exercise wrapping
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Searched the web
|
||||
@@ -1084,6 +1084,14 @@ fn standalone_windows_update_available_history_cell_snapshot() {
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_history_cell_without_detail_snapshot() {
|
||||
let cell = new_web_search_call("call-1".to_string(), String::new(), WebSearchAction::Other);
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 64)).join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_history_cell_wraps_with_indented_continuation() {
|
||||
let query = "example search query with several generic words to exercise wrapping".to_string();
|
||||
@@ -1100,8 +1108,8 @@ fn web_search_history_cell_wraps_with_indented_continuation() {
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec![
|
||||
"• Searched example search query with several generic words to".to_string(),
|
||||
" exercise wrapping".to_string(),
|
||||
"• Searched the web for example search query with several generic".to_string(),
|
||||
" words to exercise wrapping".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1119,7 +1127,10 @@ fn web_search_history_cell_short_query_does_not_wrap() {
|
||||
);
|
||||
let rendered = render_lines(&cell.display_lines(/*width*/ 64));
|
||||
|
||||
assert_eq!(rendered, vec!["• Searched short query".to_string()]);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["• Searched the web for short query".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user