Compare commits

...

20 Commits

Author SHA1 Message Date
Won Park
10b0399034 Route extension image generation through the native image completion pipeline (#24972)
## Why

The standalone `image_gen.imagegen` extension should behave like native
image generation for artifact persistence and UI completion, while
returning its save-location guidance as part of the tool result instead
of injecting a developer message.

## What Changed

- Added an image-generation completion hook for extension tools so core
can persist generated images and emit the existing `ImageGeneration`
lifecycle events.
- Reused core image artifact persistence for extension output and
removed extension-local save-path/file-writing logic.
- Split shared image persistence from built-in finalization so native
image generation keeps its existing developer-message instruction
behavior.
- Returned the generated image save-location instruction through the
extension `FunctionCallOutput`, alongside the generated image input for
model follow-up.
- Preserved the existing image-generation event shape for current UI and
replay compatibility.
- Avoided cloning the full generated-image base64 payload when emitting
the in-progress image item.
- Removed dependencies no longer needed after moving persistence out of
the extension crate.

## Fast Follow
- Adjust the existing Extension API and add a general `TurnItem`
finalization path for re-usability of code

## Validation

- Ran `just fmt`.
- Ran `just bazel-lock-update`.
- Ran `just bazel-lock-check`.
- Ran `just test -p codex-tools -p codex-extension-api -p
codex-image-generation-extension`.
- Ran `just test -p codex-core
image_generation_publication_is_finalized_by_core`.
- Ran `just test -p codex-core
handle_output_item_done_records_image_save_history_message`.
- Ran `just fix -p codex-tools -p codex-extension-api -p codex-core -p
codex-image-generation-extension`.
2026-05-29 17:33:13 +00:00
Adam Perry @ OpenAI
3e666dd32a [codex] Wait for MCP readiness in core integration tests (#24964)
Ensures MCP-backed `codex-core` integration tests exercise initialized
servers instead of racing server startup.

I've been idly investigating a few flakes and the failure modes are much
more confusing when a tool call fails because of a failed server start
than when the failed server start causes the test to fail directly.
2026-05-29 10:22:27 -07:00
xl-openai
e29bbb5368 feat: Add focused diagnostics for MCP HTTP send failures (#25013)
Adds failure-only logging for MCP streamable HTTP post_message calls and
the underlying reqwest send path, capturing the MCP method/request id,
endpoint shape, auth-header presence, timeout/connect classification,
and sanitized error source chain without logging headers, bodies,
tokens, or full URLs.
2026-05-29 10:09:33 -07:00
jif-oai
f4e9d2caac Move config document helpers into their own module (#25110)
## Why

`core/src/config/edit.rs` owns the config edit state machine, but it
also carried the TOML document helper code inline as a nested module.
Moving those helpers into their own file keeps the edit orchestration
easier to scan without changing the config persistence behavior.

## What changed

- Moved the existing `document_helpers` module from
`core/src/config/edit.rs` into
`core/src/config/edit/document_helpers.rs`.
- Added `mod document_helpers;` so the existing `pub(super)` helper API
remains available to the rest of `config::edit`.

## Testing

Not run; this is a refactor-only module extraction with no intended
behavior change.
2026-05-29 18:49:21 +02:00
sayan-oai
96f1347fa3 Show activity for standalone web search calls (#24693)
## Why

Standalone `web.run` calls run in the extension, so they need normal
web-search progress activity while a request is in flight and durable
completed activity after a thread is reloaded.

Follow-up to #23823; uses the extension turn-item emission path added in
#24813.

## What changed

- Emit standalone `web.run` start/completion items through the host
turn-item emitter, preserving standard client delivery and rollout
persistence.
- Include useful completion detail for queries, image queries, and
literal-URL `open`/`find` commands.
- Render completed searches as `Searched the web` or `Searched the web
for <detail>`, with snapshot coverage for the detail-free case.
- Extend the app-server round-trip test to verify completed search
activity is reconstructed by `thread/read` after a fresh-process reload.

## Testing

- `just test -p codex-web-search-extension`
- `just test -p codex-app-server -E
"test(standalone_web_search_round_trips_encrypted_output)"`
2026-05-29 16:12:58 +00:00
Ahmed Ibrahim
5577a9e148 [codex] Add model tool mode selector (#25031)
## Why
Some models need to select their code-execution behavior through model
catalog metadata. Models without that metadata must continue to follow
the existing `CodeMode` and `CodeModeOnly` feature flags, including when
a newer server sends an enum value this client does not recognize.

## What changed
- add optional `ModelInfo.tool_mode` metadata with `direct`,
`code_mode`, and `code_mode_only`
- treat omitted and unknown wire values as `None`
- resolve `None` from the existing feature flags
- carry the resolved `ToolMode` directly on `TurnContext`, outside
`Config`
- use the resolved value for turn creation, model switches, review
turns, tool planning, and code execution

## Coverage
- add protocol coverage for omitted, known, and unknown enum values
- add focused coverage for flag fallback and explicit metadata
overriding feature flags
- add core integration coverage that fetches remote model metadata
through `/v1/models` and verifies the outbound `/responses` tools for
explicit `direct` and `code_mode_only` selectors

## Stack
- followed by #25032
2026-05-29 09:05:05 -07:00
Abhinav
251b2412b2 Render multiline hook output in TUI (#24965)
# Why

Fixes #24529. Completed hook output in the TUI rendered each
`HookOutputEntry` as one ratatui line, so explicit newlines inside hook
output were not shown as separate transcript rows. That made multiline
`SessionStart.additionalContext` hard to inspect even though the
model-facing context path preserved the original text.

# What

- Split completed hook output entries on explicit newlines before
rendering them in `codex-rs/tui/src/history_cell/hook_cell.rs`.
- Keep the hook output prefix, such as `hook context:` or `warning:`, on
the first physical line only.
- Preserve explicit blank lines and render continuation lines with the
hook body indent.
- Add unit coverage for multiline context and warning output, plus a
chatwidget snapshot regression for `SessionStart` history output.

# Testing

- `cargo nextest run -p codex-tui completed_hook_multiline
hook_completed_before_reveal_renders_completed_without_running_flash`
- `just argument-comment-lint -p codex-tui -- --ignore-rust-version
--lib --tests`
2026-05-29 15:12:40 +00:00
jif-oai
b40ad0d84d Remove stale rollout TODO tests (#25106)
## Summary

Remove a stale `TODO(jif)` block of commented-out rollout listing tests
that still referenced an older listing API.

The current rollout listing behavior is covered by the active state DB
and filesystem fallback tests, so keeping the dead commented tests just
adds noise.

## Validation

- `just fmt`
- `just test -p codex-rollout`
2026-05-29 17:09:00 +02:00
jif-oai
27e256bc40 Handle goal usage limits from turn errors (#25095)
## Summary
- handle goal usage-limit turn errors in the goal extension
- exercise the extension path in the goal backend test

## Tests
- just fmt
- just test -p codex-goal-extension
- just fix -p codex-goal-extension
2026-05-29 15:39:05 +02:00
jif-oai
1c55bb2702 [codex] Improve built-in tool schema docs (#24794)
## Summary
- Clarify default, omission, and bounded behavior across built-in tool
schemas, including unified exec, classic shell, Code Mode exec/wait,
multi-agent, agent job, MCP resource, image, goal, plan, tool_search,
and test-sync fields.
- Convert update_plan status to an enum and add short field descriptions
where the schema previously relied on surrounding context.
- Remove the dedicated permission-approval schema test and keep only
updates to existing expected-spec tests.

## Validation
- Ran `just fmt`.
- Ran `git diff --check`.
- Did not run clippy or tests, per request.

Regression has been eval
[here](https://openai.slack.com/archives/C09GDSP1J9X/p1779905065496949)
and we proved there are no regressions
2026-05-29 13:32:19 +02:00
jif-oai
3deda3116c fix: main (#25075) 2026-05-29 12:53:31 +02:00
jif-oai
191c39aa75 Drop debug-client prompt state tracking (#25070)
Deletes `codex-rs/debug-client/src/state.rs` as one step in removing the
stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:23 +02:00
jif-oai
43fa4e5d25 Remove debug-client server event reader (#25069)
Deletes `codex-rs/debug-client/src/reader.rs` as one step in removing
the stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:19 +02:00
jif-oai
5c1387846d Delete debug-client JSONL output helper (#25068)
Deletes `codex-rs/debug-client/src/output.rs` as one step in removing
the stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:16 +02:00
jif-oai
e2b8ec616a Remove the debug-client CLI entrypoint (#25067)
Deletes `codex-rs/debug-client/src/main.rs` as one step in removing the
stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:12 +02:00
jif-oai
3d3cc5a953 Retire debug-client interactive command parsing (#25066)
Deletes `codex-rs/debug-client/src/commands.rs` as one step in removing
the stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:09 +02:00
jif-oai
1197c7d654 Delete debug-client app-server process plumbing (#25065)
Deletes `codex-rs/debug-client/src/client.rs` as one step in removing
the stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:05 +02:00
jif-oai
a9a92cbb0a Remove the generated debug-client README (#25064)
Deletes `codex-rs/debug-client/README.md` as one step in removing the
stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:51:01 +02:00
jif-oai
fc8c723553 Drop the stale debug-client manifest (#25063)
Deletes `codex-rs/debug-client/Cargo.toml` as one step in removing the
stale app-server debug client.

This intentionally leaves Cargo workspace and lockfile cleanup for a
later follow-up PR.
2026-05-29 12:50:58 +02:00
jif-oai
8f6a945ec9 Use inject_if_running for active goal steering (#24924)
## Why

This PR is stacked on #24918, which moves goal steering onto
source-labeled internal model context fragments. Active-turn goal
steering should use the same running-turn injection path as other
runtime steering, so those fragments enter the pending input queue as
`ResponseItem`s through the existing
[`Session::inject_if_running`](8d6f6cdf69/codex-rs/core/src/session/inject.rs (L12-L27))
behavior instead of through a goal-specific conversion wrapper.

## What Changed

- Exposes a narrow `CodexThread::inject_if_running` bridge for callers
that only hold a thread handle.
- Changes `ext/goal` active-turn steering to pass `ResponseItem`s
directly.
- Builds goal steering prompts as contextual internal model context
`ResponseItem`s before injecting them into the running turn.

## Testing

Not run locally; PR metadata update only.
2026-05-29 11:24:39 +02:00
83 changed files with 1650 additions and 2422 deletions

17
codex-rs/Cargo.lock generated
View File

@@ -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]]

View File

@@ -14,7 +14,6 @@ members = [
"app-server-client",
"app-server-protocol",
"app-server-test-client",
"debug-client",
"apply-patch",
"arg0",
"feedback",

View File

@@ -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,
}
}

View File

@@ -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"))

View File

@@ -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."#;

View File

@@ -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,
}],
};

View File

@@ -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(

View File

@@ -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,
}

View 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)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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;
}

View File

@@ -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(),
)),
),

View File

@@ -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(),
)),
),

View File

@@ -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(),
)),
),

View File

@@ -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(),
))
);
}
}

View File

@@ -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(),
)),
),
]);

View File

@@ -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(),
)),
),

View File

@@ -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(),
),),
),

View File

@@ -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,
))),
)]);

View File

@@ -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"],

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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(),
)),
),

View File

@@ -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(),
)),
),
(

View File

@@ -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())),

View File

@@ -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(),
),),
),

View File

@@ -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(),
)),
);

View File

@@ -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));
}

View File

@@ -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| {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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;

View 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(())
}

View File

@@ -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(),

View File

@@ -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,
}
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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

View File

@@ -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.",

View File

@@ -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:?}"
);

View File

@@ -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();

View File

@@ -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(

View File

@@ -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(),

View File

@@ -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

View File

@@ -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.

View File

@@ -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()
}
}

View File

@@ -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" })
);
}
}

View File

@@ -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");
}

View File

@@ -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);
}
}

View File

@@ -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(())
}

View File

@@ -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>,
},
}

View File

@@ -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(": "))
}

View File

@@ -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;

View File

@@ -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]

View File

@@ -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");
}
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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"] }

View File

@@ -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())),
)))]
}
}

View File

@@ -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(),

View File

@@ -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),

View File

@@ -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 }

View File

@@ -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)]
);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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,
}
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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();

View File

@@ -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;

View File

@@ -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.

View File

@@ -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,
}
}

View File

@@ -7,3 +7,4 @@ started hidden:
history:
• SessionStart hook (completed)
hook context: session context
second line

View File

@@ -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(),
}],
),
);

View File

@@ -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(),

View File

@@ -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}"))]
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
• Searched the web

View File

@@ -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]