mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
merge origin/main into code-branch-20250918-001557
This commit is contained in:
@@ -3,6 +3,35 @@
|
||||
> [!TIP]
|
||||
> We're constantly improving Code! This page documents the core changes. You can also check our [releases page](https://github.com/just-every/code/releases) for additional information.
|
||||
|
||||
## [0.2.155] - 2025-09-18
|
||||
|
||||
- Auth: fix onboarding auth prompt gating. (87a76d25)
|
||||
- CLI: add long-run calculator script. (b01e2b38)
|
||||
- TUI: add pulldown-cmark dependency to fix build. (f1718b03)
|
||||
- Docs: clarify config directories. (cc22fbd9)
|
||||
|
||||
## [0.2.154] - 2025-09-18
|
||||
|
||||
- TUI/Input: fix Shift+Tab crash. (354a6faa)
|
||||
- TUI/Agents: improve visibility for multi‑agent commands. (8add2c42)
|
||||
- TUI/Slash: make @ shortcut work with /solve and /plan. (db324a6c)
|
||||
|
||||
## [0.2.153] - 2025-09-18
|
||||
|
||||
- Core/Config: prioritize ~/.code for legacy config reads and writes. (d268969, 2629790)
|
||||
- TUI/History: strip sed/head/tail pipes when showing line ranges. (d1880bb)
|
||||
- TUI: skip alternate scroll on Apple Terminal for smoother scrolling. (f712474)
|
||||
- Resume: restore full history replay. (6d1bfdd)
|
||||
- Core: persist GPT-5 overrides across sessions. (26e538a)
|
||||
|
||||
## [0.2.152] - 2025-09-17
|
||||
|
||||
- TUI: add terminal overlay and agent install flow. (104a5f9f, c678d670)
|
||||
- TUI/Explore: enrich run summaries with pipeline context; polish explore labels. (e25f8faa, d7ce1345)
|
||||
- Core/Exec: enforce dry-run guard for formatter commands. (360fbf94)
|
||||
- Explore: support read-only git commands. (b6b9fc41)
|
||||
- TUI: add plan names and sync terminal title. (29eda799)
|
||||
|
||||
## [0.2.151] - 2025-09-16
|
||||
|
||||
- TUI/History: append merge completion banner for clearer post-merge status. (736293a9)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- 🧠 **Reasoning Control** - /reasoning for dynamic effort adjustment
|
||||
- 🔌 **MCP support** – Extend with filesystem, DBs, APIs, or your own tools.
|
||||
- 🔒 **Safety modes** – Read-only, approvals, and workspace sandboxing.
|
||||
- 🔁 **Backwards compatible** – Supports `~/.codex/*` or default `~/.coder/*`
|
||||
- 🔁 **Backwards compatible** – Reads both `~/.code/*` (primary) and legacy `~/.codex/*`; writes only to `~/.code/*`
|
||||
|
||||
 
|
||||
| <img src="docs/screenshots/simple.png" alt="Simple interface" width="100%"><br>Simple interface | <img src="docs/screenshots/diff.png" alt="Unified diff viewer" width="100%"><br>Unified diffs |
|
||||
@@ -51,7 +51,7 @@ Note: If another tool already provides a `code` command (e.g. VS Code), our CLI
|
||||
**Authenticate** (one of the following):
|
||||
- **Sign in with ChatGPT** (Plus/Pro/Team; uses models available to your plan)
|
||||
- Run `code` and pick "Sign in with ChatGPT"
|
||||
- Stores creds locally at `~/.coder/auth.json` (also reads legacy `~/.codex/auth.json`)
|
||||
- Stores creds locally at `~/.code/auth.json` (still reads legacy `~/.codex/auth.json` if present)
|
||||
- **API key** (usage-based)
|
||||
- Set `export OPENAI_API_KEY=xyz` and run `code`
|
||||
|
||||
@@ -173,7 +173,7 @@ Code supports MCP for extended capabilities:
|
||||
- **API integrations**: Connect to external services
|
||||
- **Custom tools**: Build your own extensions
|
||||
|
||||
Configure MCP in `~/.codex/config.toml`. Define each server under a named table like `[mcp_servers.<name>]` (this maps to the JSON `mcpServers` object used by other clients):
|
||||
Configure MCP in `~/.code/config.toml` (legacy `~/.codex/config.toml` is still read if present). Define each server under a named table like `[mcp_servers.<name>]` (this maps to the JSON `mcpServers` object used by other clients):
|
||||
|
||||
```toml
|
||||
[mcp_servers.filesystem]
|
||||
@@ -184,7 +184,10 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
 
|
||||
## Configuration
|
||||
|
||||
Main config file: `~/.codex/config.toml`
|
||||
Main config file: `~/.code/config.toml`
|
||||
|
||||
> [!NOTE]
|
||||
> Code reads from both `~/.code/` and `~/.codex/` for backwards compatibility, but it only writes updates to `~/.code/`. If you switch back to Codex and it fails to start, remove `~/.codex/config.toml`. If Code appears to miss settings after upgrading, copy your legacy `~/.codex/config.toml` into `~/.code/`.
|
||||
|
||||
```toml
|
||||
# Model settings
|
||||
@@ -222,7 +225,7 @@ model_reasoning_summary = "detailed"
|
||||
> This fork adds browser integration, multi-agent commands (`/plan`, `/solve`, `/code`), theme system, and enhanced reasoning controls while maintaining full compatibility.
|
||||
|
||||
**Can I use my existing Codex configuration?**
|
||||
> Yes! This fork is fully backward compatible with existing `~/.codex/` configurations.
|
||||
> Yes. Code reads from both `~/.code/` (primary) and legacy `~/.codex/` directories. We only write to `~/.code/`, so Codex will keep running if you switch back; copy or remove legacy files if you notice conflicts.
|
||||
|
||||
**Does this work with ChatGPT Plus?**
|
||||
> Absolutely. Use the same "Sign in with ChatGPT" flow as the original.
|
||||
@@ -275,7 +278,7 @@ Using OpenAI, Anthropic or Google services through Code means you agree to **the
|
||||
- If you configure other model providers, you're responsible for their terms.
|
||||
|
||||
### Privacy
|
||||
- Your auth file lives at `~/.codex/auth.json`.
|
||||
- Your auth file lives at `~/.code/auth.json` (legacy `~/.codex/auth.json` is still read).
|
||||
- Inputs/outputs you send to AI providers are handled under their Terms and Privacy Policy; consult those documents (and any org-level data-sharing settings).
|
||||
|
||||
### Subject to change
|
||||
|
||||
@@ -173,11 +173,10 @@ Code supports MCP for extended capabilities:
|
||||
- **API integrations**: Connect to external services
|
||||
- **Custom tools**: Build your own extensions
|
||||
|
||||
Configure MCP in `~/.codex/config.toml`:
|
||||
Configure MCP in `~/.codex/config.toml`. Define each server under a named table like `[mcp_servers.<name>]` (this maps to the JSON `mcpServers` object used by other clients):
|
||||
|
||||
```toml
|
||||
[[mcp_servers]]
|
||||
name = "filesystem"
|
||||
[mcp_servers.filesystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@just-every/code",
|
||||
"version": "0.2.149",
|
||||
"version": "0.2.154",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
|
||||
"bin": {
|
||||
@@ -35,10 +35,10 @@
|
||||
"prettier": "^3.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@just-every/code-darwin-arm64": "0.2.149",
|
||||
"@just-every/code-darwin-x64": "0.2.149",
|
||||
"@just-every/code-linux-x64-musl": "0.2.149",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.149",
|
||||
"@just-every/code-win32-x64": "0.2.149"
|
||||
"@just-every/code-darwin-arm64": "0.2.154",
|
||||
"@just-every/code-darwin-x64": "0.2.154",
|
||||
"@just-every/code-linux-x64-musl": "0.2.154",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.154",
|
||||
"@just-every/code-win32-x64": "0.2.154"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openai/codex",
|
||||
"version": "0.2.149",
|
||||
"version": "0.2.154",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.2.149",
|
||||
"version": "0.2.154",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.15.14"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@just-every/code",
|
||||
"version": "0.2.151",
|
||||
"version": "0.2.155",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
|
||||
"bin": {
|
||||
@@ -35,10 +35,10 @@
|
||||
"prettier": "^3.3.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@just-every/code-darwin-arm64": "0.2.151",
|
||||
"@just-every/code-darwin-x64": "0.2.151",
|
||||
"@just-every/code-linux-x64-musl": "0.2.151",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.151",
|
||||
"@just-every/code-win32-x64": "0.2.151"
|
||||
"@just-every/code-darwin-arm64": "0.2.155",
|
||||
"@just-every/code-darwin-x64": "0.2.155",
|
||||
"@just-every/code-linux-x64-musl": "0.2.155",
|
||||
"@just-every/code-linux-arm64-musl": "0.2.155",
|
||||
"@just-every/code-win32-x64": "0.2.155"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
@@ -1207,6 +1207,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"path-clean",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
"ratatui-image",
|
||||
|
||||
+1
-2
@@ -90,7 +90,7 @@ codex --sandbox workspace-write
|
||||
codex --sandbox danger-full-access
|
||||
```
|
||||
|
||||
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
|
||||
The same setting can be persisted in `~/.code/config.toml` via the top-level `sandbox_mode = "MODE"` key (Code will also read legacy `~/.codex/config.toml`), e.g. `sandbox_mode = "workspace-write"`.
|
||||
|
||||
If you want to prevent the agent from updating Git metadata (e.g., local safety), you can opt‑out with a workspace‑write tweak:
|
||||
|
||||
@@ -125,4 +125,3 @@ This folder is the root of a Cargo workspace. It contains quite a bit of experim
|
||||
- [`exec/`](./exec) "headless" CLI for use in automation.
|
||||
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
|
||||
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::config::resolve_codex_path_for_read;
|
||||
use codex_core::CODEX_APPLY_PATCH_ARG1;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::symlink;
|
||||
@@ -106,14 +107,15 @@ where
|
||||
|
||||
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
|
||||
|
||||
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
|
||||
/// Load env vars from ~/.code/.env (legacy ~/.codex/.env is still read) and `$(pwd)/.env`.
|
||||
///
|
||||
/// Security: Do not allow `.env` files to create or modify any variables
|
||||
/// with names starting with `CODEX_`.
|
||||
fn load_dotenv() {
|
||||
// 1) Load from global ~/.code/.env (or ~/.codex/.env) first.
|
||||
if let Ok(codex_home) = codex_core::config::find_codex_home() {
|
||||
if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) {
|
||||
let global_env_path = resolve_codex_path_for_read(&codex_home, Path::new(".env"));
|
||||
if let Ok(iter) = dotenvy::from_path_iter(global_env_path) {
|
||||
// Global env may legitimately contain provider keys for Code usage.
|
||||
set_filtered(iter);
|
||||
}
|
||||
|
||||
@@ -174,9 +174,11 @@ struct GenerateTsCommand {
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct OrderReplayArgs {
|
||||
/// Path to a response.json captured under ~/.codex/debug_logs/*_response.json
|
||||
/// Path to a response.json captured under ~/.code/debug_logs/*_response.json
|
||||
/// (legacy ~/.codex/debug_logs/ is still read).
|
||||
response_json: std::path::PathBuf,
|
||||
/// Path to codex-tui.log (typically ~/.codex/log/codex-tui.log)
|
||||
/// Path to codex-tui.log (typically ~/.code/log/codex-tui.log; legacy
|
||||
/// ~/.codex/log/codex-tui.log is still read).
|
||||
tui_log: std::path::PathBuf,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use codex_core::config_types::McpServerConfig;
|
||||
/// - `serve` — run the MCP server on stdio
|
||||
/// - `list` — list configured servers (with `--json`)
|
||||
/// - `get` — show a single server (with `--json`)
|
||||
/// - `add` — add a server launcher entry to `~/.codex/config.toml`
|
||||
/// - `add` — add a server launcher entry to `~/.code/config.toml` (Code also reads legacy `~/.codex/config.toml`)
|
||||
/// - `remove` — delete a server entry
|
||||
#[derive(Debug, clap::Parser)]
|
||||
pub struct McpCli {
|
||||
|
||||
@@ -18,7 +18,7 @@ use toml::Value;
|
||||
#[derive(Parser, Debug, Default, Clone)]
|
||||
pub struct CliConfigOverrides {
|
||||
/// Override a configuration value that would otherwise be loaded from
|
||||
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
|
||||
/// `~/.code/config.toml` (Code also reads legacy `~/.codex/config.toml`). Use a dotted path (`foo.bar.baz`) to override
|
||||
/// nested values. The `value` portion is parsed as JSON. If it fails to
|
||||
/// parse as JSON, the raw string is used as a literal.
|
||||
///
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ This file has moved. Please see the latest configuration documentation here:
|
||||
- Values can contain objects, such as `--config shell_environment_policy.include_only=["PATH", "HOME", "USER"]`.
|
||||
- For consistency with `config.toml`, values are in TOML format rather than JSON format, so use `{a = 1, b = 2}` rather than `{"a": 1, "b": 2}`.
|
||||
- If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that both `-c model="o3"` and `-c model=o3` are equivalent.
|
||||
- The `$CODEX_HOME/config.toml` configuration file where the `CODEX_HOME` environment value defaults to `~/.codex`. (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.)
|
||||
- The `$CODE_HOME/config.toml` configuration file. `CODE_HOME` defaults to `~/.code`; Code also reads from `$CODEX_HOME`/`~/.codex` for backwards compatibility but only writes to `~/.code`. (Logs and other state use the same directory.)
|
||||
|
||||
Both the `--config` flag and the `config.toml` file support the following options:
|
||||
|
||||
@@ -369,7 +369,7 @@ This config option is comparable to how Claude and Cursor define `mcpServers` in
|
||||
}
|
||||
```
|
||||
|
||||
Should be represented as follows in `~/.codex/config.toml`:
|
||||
Should be represented as follows in `~/.code/config.toml` (Code will also read the legacy `~/.codex/config.toml` if it exists):
|
||||
|
||||
```toml
|
||||
# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`.
|
||||
@@ -500,7 +500,7 @@ if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
To have Codex use this script for notifications, you would configure it via `notify` in `~/.codex/config.toml` using the appropriate path to `notify.py` on your computer:
|
||||
To have Codex use this script for notifications, you would configure it via `notify` in `~/.code/config.toml` (legacy `~/.codex/config.toml` is still read) using the appropriate path to `notify.py` on your computer:
|
||||
|
||||
```toml
|
||||
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
|
||||
|
||||
@@ -26,37 +26,41 @@ When using the planning tool:
|
||||
|
||||
## Codex CLI harness, sandboxing, and approvals
|
||||
|
||||
The Codex CLI harness supports several different sandboxing, and approval configurations that the user can choose from.
|
||||
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
|
||||
|
||||
Filesystem sandboxing defines which files can be read or written. The options are:
|
||||
- **read-only**: You can only read files.
|
||||
- **workspace-write**: You can read files. You can write to files in this folder, but not outside it.
|
||||
- **danger-full-access**: No filesystem sandboxing.
|
||||
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
|
||||
- **read-only**: The sandbox only permits reading files.
|
||||
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
|
||||
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
|
||||
|
||||
Network sandboxing defines whether network can be accessed without approval. Options are
|
||||
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
|
||||
- **restricted**: Requires approval
|
||||
- **enabled**: No approval needed
|
||||
|
||||
Approvals are your mechanism to get user consent to perform more privileged actions. Although they introduce friction to the user because your work is paused until the user responds, you should leverage them to accomplish your important work. Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
Approval options are
|
||||
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
|
||||
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
|
||||
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
|
||||
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
|
||||
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
|
||||
|
||||
When you are running with approvals `on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /tmp)
|
||||
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
|
||||
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
|
||||
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
|
||||
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval.
|
||||
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
|
||||
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
|
||||
- (for all of these, you should weigh alternative paths that do not require approval)
|
||||
|
||||
When sandboxing is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
|
||||
|
||||
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
|
||||
|
||||
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
|
||||
|
||||
When requesting approval to execute a command that will require escalated privileges:
|
||||
- Provide the `with_escalated_permissions` parameter with the boolean value true
|
||||
- Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::mcp_protocol::AuthMode;
|
||||
|
||||
use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_id_token;
|
||||
use crate::config::resolve_codex_path_for_read;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAuth {
|
||||
@@ -238,8 +239,9 @@ fn load_auth(
|
||||
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
|
||||
// (if it is set).
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
let auth_read_path = resolve_codex_path_for_read(codex_home, Path::new("auth.json"));
|
||||
let client = crate::default_client::create_client(originator);
|
||||
let auth_dot_json = match try_read_auth_json(&auth_file) {
|
||||
let auth_dot_json = match try_read_auth_json(&auth_read_path) {
|
||||
Ok(auth) => auth,
|
||||
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
|
||||
// environment variable.
|
||||
|
||||
@@ -34,6 +34,7 @@ use codex_protocol::models::ResponseItem;
|
||||
pub(crate) async fn stream_chat_completions(
|
||||
prompt: &Prompt,
|
||||
model_family: &ModelFamily,
|
||||
model_slug: &str,
|
||||
client: &reqwest::Client,
|
||||
provider: &ModelProviderInfo,
|
||||
debug_logger: &Arc<Mutex<DebugLogger>>,
|
||||
@@ -284,7 +285,7 @@ pub(crate) async fn stream_chat_completions(
|
||||
|
||||
let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?;
|
||||
let mut payload = json!({
|
||||
"model": model_family.slug,
|
||||
"model": model_slug,
|
||||
"messages": messages,
|
||||
"stream": true,
|
||||
"tools": tools_json,
|
||||
|
||||
@@ -114,6 +114,11 @@ impl ModelClient {
|
||||
self.effort
|
||||
}
|
||||
|
||||
/// Get the reasoning summary configuration
|
||||
pub fn get_reasoning_summary(&self) -> ReasoningSummaryConfig {
|
||||
self.summary
|
||||
}
|
||||
|
||||
/// Get the text verbosity configuration
|
||||
#[allow(dead_code)]
|
||||
pub fn get_text_verbosity(&self) -> TextVerbosityConfig {
|
||||
@@ -133,10 +138,19 @@ impl ModelClient {
|
||||
match self.provider.wire_api {
|
||||
WireApi::Responses => self.stream_responses(prompt).await,
|
||||
WireApi::Chat => {
|
||||
let effective_family = prompt
|
||||
.model_family_override
|
||||
.as_ref()
|
||||
.unwrap_or(&self.config.model_family);
|
||||
let model_slug = prompt
|
||||
.model_override
|
||||
.as_deref()
|
||||
.unwrap_or(self.config.model.as_str());
|
||||
// Create the raw streaming connection first.
|
||||
let response_stream = stream_chat_completions(
|
||||
prompt,
|
||||
&self.config.model_family,
|
||||
effective_family,
|
||||
model_slug,
|
||||
&self.client,
|
||||
&self.provider,
|
||||
&self.debug_logger,
|
||||
@@ -231,6 +245,11 @@ impl ModelClient {
|
||||
// For Azure, we send `store: true` and preserve reasoning item IDs.
|
||||
let azure_workaround = self.provider.is_azure_responses_endpoint();
|
||||
|
||||
let model_slug = prompt
|
||||
.model_override
|
||||
.as_deref()
|
||||
.unwrap_or(self.config.model.as_str());
|
||||
|
||||
let payload = ResponsesApiRequest {
|
||||
model: &self.config.model,
|
||||
instructions: &full_instructions,
|
||||
@@ -248,6 +267,9 @@ impl ModelClient {
|
||||
};
|
||||
|
||||
let mut payload_json = serde_json::to_value(&payload)?;
|
||||
if let Some(model_value) = payload_json.get_mut("model") {
|
||||
*model_value = serde_json::Value::String(model_slug.to_string());
|
||||
}
|
||||
if azure_workaround {
|
||||
attach_item_ids(&mut payload_json, &input_with_instructions);
|
||||
}
|
||||
@@ -260,12 +282,16 @@ impl ModelClient {
|
||||
let endpoint = self
|
||||
.provider
|
||||
.get_full_url(&auth_manager.as_ref().and_then(|m| m.auth()));
|
||||
trace!("POST to {}: {}", endpoint, serde_json::to_string(&payload)?);
|
||||
trace!(
|
||||
"POST to {}: {}",
|
||||
endpoint,
|
||||
serde_json::to_string(&payload_json)?
|
||||
);
|
||||
|
||||
// Start logging the request and get a request_id to track the response
|
||||
let request_id = if let Ok(logger) = self.debug_logger.lock() {
|
||||
logger
|
||||
.start_request_log(&endpoint, &serde_json::to_value(&payload)?)
|
||||
.start_request_log(&endpoint, &payload_json)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
|
||||
@@ -33,7 +33,7 @@ const USER_INSTRUCTIONS_END: &str = "\n\n</user_instructions>";
|
||||
pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md");
|
||||
|
||||
/// API request payload for a single model turn
|
||||
#[derive(Default, Debug, Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Prompt {
|
||||
/// Conversation context input items.
|
||||
pub input: Vec<ResponseItem>,
|
||||
@@ -59,16 +59,44 @@ pub struct Prompt {
|
||||
/// Optional override for the built-in BASE_INSTRUCTIONS.
|
||||
pub base_instructions_override: Option<String>,
|
||||
|
||||
/// Whether to prepend the default developer instructions block.
|
||||
pub include_additional_instructions: bool,
|
||||
|
||||
/// Optional `text.format` for structured outputs (used by side-channel requests).
|
||||
pub text_format: Option<TextFormat>,
|
||||
|
||||
/// Optional per-request model slug override.
|
||||
pub model_override: Option<String>,
|
||||
|
||||
/// Optional per-request model family override matching `model_override`.
|
||||
pub model_family_override: Option<ModelFamily>,
|
||||
}
|
||||
|
||||
impl Default for Prompt {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input: Vec::new(),
|
||||
store: false,
|
||||
user_instructions: None,
|
||||
environment_context: None,
|
||||
tools: Vec::new(),
|
||||
status_items: Vec::new(),
|
||||
base_instructions_override: None,
|
||||
include_additional_instructions: true,
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub(crate) fn get_full_instructions(&self, model: &ModelFamily) -> Cow<'_, str> {
|
||||
let effective_model = self.model_family_override.as_ref().unwrap_or(model);
|
||||
let base = self
|
||||
.base_instructions_override
|
||||
.as_deref()
|
||||
.unwrap_or(model.base_instructions.deref());
|
||||
.unwrap_or(effective_model.base_instructions.deref());
|
||||
let mut sections: Vec<&str> = vec![base];
|
||||
|
||||
// When there are no custom instructions, add apply_patch_tool_instructions if:
|
||||
@@ -81,7 +109,7 @@ impl Prompt {
|
||||
_ => false,
|
||||
});
|
||||
if self.base_instructions_override.is_none()
|
||||
&& model.needs_special_apply_patch_instructions
|
||||
&& effective_model.needs_special_apply_patch_instructions
|
||||
&& !is_apply_patch_tool_present
|
||||
{
|
||||
sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS);
|
||||
@@ -96,37 +124,55 @@ impl Prompt {
|
||||
}
|
||||
|
||||
fn get_formatted_environment_context(&self) -> Option<String> {
|
||||
self.environment_context
|
||||
.as_ref()
|
||||
.map(|ec| {
|
||||
let ec_str = serde_json::to_string_pretty(ec).unwrap_or_else(|_| format!("{:?}", ec));
|
||||
format!("{ENVIRONMENT_CONTEXT_START}{ec_str}{ENVIRONMENT_CONTEXT_END}")
|
||||
})
|
||||
self.environment_context.as_ref().map(|ec| {
|
||||
let ec_str = serde_json::to_string_pretty(ec).unwrap_or_else(|_| format!("{:?}", ec));
|
||||
format!("{ENVIRONMENT_CONTEXT_START}{ec_str}{ENVIRONMENT_CONTEXT_END}")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
let mut input_with_instructions =
|
||||
Vec::with_capacity(self.input.len() + self.status_items.len() + 3);
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ADDITIONAL_INSTRUCTIONS.to_string(),
|
||||
}],
|
||||
});
|
||||
if let Some(ec) = self.get_formatted_environment_context() {
|
||||
if self.include_additional_instructions {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ec }],
|
||||
});
|
||||
}
|
||||
if let Some(ui) = self.get_formatted_user_instructions() {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ui }],
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ADDITIONAL_INSTRUCTIONS.to_string(),
|
||||
}],
|
||||
});
|
||||
if let Some(ec) = self.get_formatted_environment_context() {
|
||||
let has_environment_context = self.input.iter().any(|item| {
|
||||
matches!(item, ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|c| matches!(c,
|
||||
ContentItem::InputText { text } if text.contains(ENVIRONMENT_CONTEXT_START.trim())
|
||||
)))
|
||||
});
|
||||
if !has_environment_context {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ec }],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(ui) = self.get_formatted_user_instructions() {
|
||||
let has_user_instructions = self.input.iter().any(|item| {
|
||||
matches!(item, ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|c| matches!(c,
|
||||
ContentItem::InputText { text } if text.contains(USER_INSTRUCTIONS_START)
|
||||
)))
|
||||
});
|
||||
if !has_user_instructions {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ui }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate function call outputs before adding to input
|
||||
let mut seen_call_ids = std::collections::HashSet::new();
|
||||
@@ -149,10 +195,10 @@ impl Prompt {
|
||||
|
||||
// Add status items at the end so they're fresh for each request
|
||||
input_with_instructions.extend(self.status_items.clone());
|
||||
|
||||
|
||||
// Limit screenshots to maximum 5 (keep first and last 4)
|
||||
limit_screenshots_in_input(&mut input_with_instructions);
|
||||
|
||||
|
||||
input_with_instructions
|
||||
}
|
||||
|
||||
@@ -449,7 +495,7 @@ mod tests {
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
@@ -477,7 +523,7 @@ mod tests {
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: false,
|
||||
reasoning: None,
|
||||
store: true,
|
||||
store: false,
|
||||
stream: true,
|
||||
include: vec![],
|
||||
prompt_cache_key: None,
|
||||
|
||||
+578
-430
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,30 @@ use super::AgentTask;
|
||||
use super::MutexExt;
|
||||
use super::Session;
|
||||
use super::TurnContext;
|
||||
use super::debug_history;
|
||||
use super::get_last_assistant_message_from_turn;
|
||||
use super::response_input_from_core_items;
|
||||
use crate::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::model_family::find_family_for_model;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::InputItem;
|
||||
use crate::protocol::InputMessageKind;
|
||||
use crate::protocol::TaskCompleteEvent;
|
||||
use crate::protocol::TaskStartedEvent;
|
||||
use crate::protocol::TurnContextItem;
|
||||
use crate::util::backoff;
|
||||
use askama::Template;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::InputMessageKind;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use futures::prelude::*;
|
||||
|
||||
pub(super) const COMPACT_TRIGGER_TEXT: &str = "Start Summarization";
|
||||
pub(super) const COMPACT_TRIGGER_TEXT: &str =
|
||||
"Start Summarization. Only include details found in the prior conversation.";
|
||||
const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -56,12 +56,12 @@ pub(super) fn spawn_compact_task(
|
||||
pub(super) async fn run_inline_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
) {
|
||||
) -> Vec<ResponseItem> {
|
||||
let sub_id = sess.next_internal_sub_id();
|
||||
let input = vec![InputItem::Text {
|
||||
text: COMPACT_TRIGGER_TEXT.to_string(),
|
||||
}];
|
||||
run_compact_task_inner(
|
||||
perform_compaction(
|
||||
sess,
|
||||
turn_context,
|
||||
sub_id,
|
||||
@@ -69,67 +69,50 @@ pub(super) async fn run_inline_auto_compact_task(
|
||||
SUMMARIZATION_PROMPT.to_string(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn run_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
compact_instructions: String,
|
||||
) {
|
||||
run_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
sub_id,
|
||||
input,
|
||||
compact_instructions,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run_compact_task_inner(
|
||||
pub(super) async fn perform_compaction(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
input: Vec<InputItem>,
|
||||
compact_instructions: String,
|
||||
remove_task_on_completion: bool,
|
||||
) {
|
||||
let model_context_window = turn_context.client.get_model_context_window();
|
||||
let start_event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window,
|
||||
}),
|
||||
};
|
||||
) -> Vec<ResponseItem> {
|
||||
sess.notify_background_event(&sub_id, "Compacting conversation...")
|
||||
.await;
|
||||
let start_event = sess.make_event(&sub_id, EventMsg::TaskStarted);
|
||||
sess.send_event(start_event).await;
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
let initial_input_for_turn = response_input_from_core_items(input);
|
||||
let instructions_override = compact_instructions;
|
||||
let turn_input = sess.turn_input_with_history(vec![initial_input_for_turn.clone().into()]);
|
||||
|
||||
let prompt = Prompt {
|
||||
let mut prompt = Prompt {
|
||||
input: turn_input,
|
||||
store: !sess.disable_response_storage,
|
||||
user_instructions: None,
|
||||
environment_context: None,
|
||||
tools: Vec::new(),
|
||||
status_items: Vec::new(),
|
||||
base_instructions_override: Some(instructions_override),
|
||||
include_additional_instructions: false,
|
||||
text_format: None,
|
||||
model_override: None,
|
||||
model_family_override: None,
|
||||
};
|
||||
|
||||
if turn_context.client.get_model() == "gpt-5-codex" {
|
||||
prompt.model_override = Some("gpt-5".to_string());
|
||||
if let Some(family) = find_family_for_model("gpt-5") {
|
||||
prompt.model_family_override = Some(family);
|
||||
}
|
||||
}
|
||||
|
||||
let max_retries = turn_context.client.get_provider().stream_max_retries();
|
||||
let mut retries = 0;
|
||||
|
||||
let rollout_item = RolloutItem::TurnContext(TurnContextItem {
|
||||
cwd: turn_context.cwd.clone(),
|
||||
approval_policy: turn_context.approval_policy,
|
||||
sandbox_policy: turn_context.sandbox_policy.clone(),
|
||||
model: turn_context.client.get_model(),
|
||||
effort: turn_context.client.get_reasoning_effort(),
|
||||
summary: turn_context.client.get_reasoning_summary(),
|
||||
});
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
|
||||
loop {
|
||||
let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await;
|
||||
|
||||
@@ -138,7 +121,7 @@ async fn run_compact_task_inner(
|
||||
break;
|
||||
}
|
||||
Err(CodexErr::Interrupted) => {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
Err(e) => {
|
||||
if retries < max_retries {
|
||||
@@ -154,14 +137,14 @@ async fn run_compact_task_inner(
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
let event = sess.make_event(
|
||||
&sub_id,
|
||||
EventMsg::Error(ErrorEvent {
|
||||
message: e.to_string(),
|
||||
}),
|
||||
};
|
||||
);
|
||||
sess.send_event(event).await;
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +163,13 @@ async fn run_compact_task_inner(
|
||||
let new_history = build_compacted_history(initial_context, &user_messages, &summary_text);
|
||||
{
|
||||
let mut state = sess.state.lock_unchecked();
|
||||
state.history.replace(new_history);
|
||||
state.history = super::ConversationHistory::new();
|
||||
}
|
||||
sess.record_conversation_items(&new_history).await;
|
||||
{
|
||||
let state = sess.state.lock_unchecked();
|
||||
let snapshot = state.history.contents();
|
||||
debug_history("after_compact_record", &snapshot);
|
||||
}
|
||||
|
||||
let rollout_item = RolloutItem::Compacted(CompactedItem {
|
||||
@@ -188,20 +177,45 @@ async fn run_compact_task_inner(
|
||||
});
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Compact task completed".to_string(),
|
||||
}),
|
||||
let message = if summary_text.trim().is_empty() {
|
||||
"Compact task completed.".to_string()
|
||||
} else {
|
||||
summary_text.clone()
|
||||
};
|
||||
let event = sess.make_event(
|
||||
&sub_id,
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: message.clone(),
|
||||
}),
|
||||
);
|
||||
sess.send_event(event).await;
|
||||
let event = Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
let assistant_summary = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText { text: message }],
|
||||
};
|
||||
{
|
||||
let mut state = sess.state.lock_unchecked();
|
||||
state
|
||||
.history
|
||||
.record_items(std::slice::from_ref(&assistant_summary));
|
||||
let snapshot = state.history.contents();
|
||||
debug_history("after_compact_summary", &snapshot);
|
||||
}
|
||||
sess.persist_rollout_items(&[RolloutItem::ResponseItem(assistant_summary.clone())])
|
||||
.await;
|
||||
let event = sess.make_event(
|
||||
&sub_id,
|
||||
EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
};
|
||||
);
|
||||
sess.send_event(event).await;
|
||||
|
||||
{
|
||||
let state = sess.state.lock_unchecked();
|
||||
state.history.contents()
|
||||
}
|
||||
}
|
||||
|
||||
fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
|
||||
@@ -289,7 +303,7 @@ async fn drain_to_completed(
|
||||
));
|
||||
};
|
||||
match event {
|
||||
Ok(ResponseEvent::OutputItemDone(item)) => {
|
||||
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
|
||||
let mut state = sess.state.lock_unchecked();
|
||||
state.history.record_items(std::slice::from_ref(&item));
|
||||
}
|
||||
|
||||
+132
-55
@@ -31,9 +31,10 @@ use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::io::ErrorKind;
|
||||
use std::sync::OnceLock;
|
||||
use tempfile::NamedTempFile;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::Array as TomlArray;
|
||||
@@ -42,7 +43,7 @@ use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
|
||||
const OPENAI_DEFAULT_MODEL: &str = "gpt-5";
|
||||
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5";
|
||||
const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5-codex";
|
||||
pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5-codex";
|
||||
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
@@ -119,7 +120,8 @@ pub struct Config {
|
||||
/// appends one extra argument containing a JSON payload describing the
|
||||
/// event.
|
||||
///
|
||||
/// Example `~/.codex/config.toml` snippet:
|
||||
/// Example `~/.code/config.toml` snippet (Code also reads legacy
|
||||
/// `~/.codex/config.toml`):
|
||||
///
|
||||
/// ```toml
|
||||
/// notify = ["notify-send", "Codex"]
|
||||
@@ -155,11 +157,12 @@ pub struct Config {
|
||||
/// Maximum number of bytes to include from an AGENTS.md project doc file.
|
||||
pub project_doc_max_bytes: usize,
|
||||
|
||||
/// Directory containing all Codex state (defaults to `~/.codex` but can be
|
||||
/// overridden by the `CODEX_HOME` environment variable).
|
||||
/// Directory containing all Codex state (defaults to `~/.code`; can be
|
||||
/// overridden by the `CODE_HOME` or `CODEX_HOME` environment variables).
|
||||
pub codex_home: PathBuf,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
/// Settings that govern if and what will be written to `~/.code/history.jsonl`
|
||||
/// (Code still reads legacy `~/.codex/history.jsonl`).
|
||||
pub history: History,
|
||||
|
||||
/// Optional URI-based file opener. If set, citations to files in the model
|
||||
@@ -238,7 +241,7 @@ impl Config {
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
overrides: ConfigOverrides,
|
||||
) -> std::io::Result<Self> {
|
||||
// Resolve the directory that stores Codex state (e.g. ~/.codex or the
|
||||
// Resolve the directory that stores Codex state (e.g. ~/.code or the
|
||||
// value of $CODEX_HOME) so we can embed it into the resulting
|
||||
// `Config` instance.
|
||||
let codex_home = find_codex_home()?;
|
||||
@@ -284,8 +287,8 @@ pub fn load_config_as_toml_with_cli_overrides(
|
||||
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
|
||||
/// an empty TOML table when the file does not exist.
|
||||
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
match std::fs::read_to_string(&read_path) {
|
||||
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => {
|
||||
@@ -323,7 +326,8 @@ pub fn write_global_mcp_servers(
|
||||
servers: &BTreeMap<String, McpServerConfig>,
|
||||
) -> std::io::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(contents) => contents
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
|
||||
@@ -397,7 +401,8 @@ pub async fn persist_model_selection(
|
||||
use tokio::fs;
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let existing = match fs::read_to_string(&config_path).await {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let existing = match fs::read_to_string(&read_path).await {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(err.into()),
|
||||
@@ -473,7 +478,8 @@ pub async fn persist_model_selection(
|
||||
pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -559,7 +565,8 @@ pub fn set_tui_theme_name(codex_home: &Path, theme: ThemeName) -> anyhow::Result
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -613,7 +620,8 @@ pub fn set_tui_spinner_name(codex_home: &Path, spinner_name: &str) -> anyhow::Re
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -645,7 +653,8 @@ pub fn set_custom_spinner(
|
||||
frames: &[String],
|
||||
) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -678,7 +687,8 @@ pub fn set_custom_theme(
|
||||
is_dark: Option<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -742,7 +752,8 @@ pub fn set_tui_alternate_screen(codex_home: &Path, enabled: bool) -> anyhow::Res
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -769,7 +780,8 @@ pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Res
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -802,7 +814,8 @@ pub fn set_project_access_mode(
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Parse existing config if present; otherwise start a new document.
|
||||
let mut doc = match std::fs::read_to_string(config_path.clone()) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -810,17 +823,30 @@ pub fn set_project_access_mode(
|
||||
|
||||
// Ensure projects table and the per-project table exist
|
||||
let project_key = project_path.to_string_lossy().to_string();
|
||||
if !doc.as_table().contains_key("projects") {
|
||||
// Ensure `projects` is a table; if key exists but is not a table, replace it.
|
||||
let has_projects_table = doc
|
||||
.as_table()
|
||||
.get("projects")
|
||||
.and_then(|i| i.as_table())
|
||||
.is_some();
|
||||
if !has_projects_table {
|
||||
doc["projects"] = TomlItem::Table(toml_edit::Table::new());
|
||||
}
|
||||
let projects_tbl = doc["projects"].as_table_mut().unwrap();
|
||||
if !projects_tbl.contains_key(project_key.as_str()) {
|
||||
let Some(projects_tbl) = doc["projects"].as_table_mut() else {
|
||||
return Err(anyhow::anyhow!("failed to prepare projects table"));
|
||||
};
|
||||
// Ensure per-project entry exists and is a table; replace if wrong type.
|
||||
let needs_proj_table = projects_tbl
|
||||
.get(project_key.as_str())
|
||||
.and_then(|i| i.as_table())
|
||||
.is_none();
|
||||
if needs_proj_table {
|
||||
projects_tbl.insert(project_key.as_str(), TomlItem::Table(toml_edit::Table::new()));
|
||||
}
|
||||
let proj_tbl = projects_tbl
|
||||
.get_mut(project_key.as_str())
|
||||
.and_then(|i| i.as_table_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to create projects.{} table", project_key))?;
|
||||
.ok_or_else(|| anyhow::anyhow!(format!("failed to create projects.{} table", project_key)))?;
|
||||
|
||||
// Write fields
|
||||
proj_tbl.insert(
|
||||
@@ -864,8 +890,8 @@ pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
|
||||
Vec<(String, McpServerConfig)>,
|
||||
Vec<(String, McpServerConfig)>,
|
||||
)> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let doc_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let doc_str = std::fs::read_to_string(&read_path).unwrap_or_default();
|
||||
let doc = doc_str.parse::<DocumentMut>().unwrap_or_else(|_| DocumentMut::new());
|
||||
|
||||
fn table_to_list(tbl: &toml_edit::Table) -> Vec<(String, McpServerConfig)> {
|
||||
@@ -937,7 +963,8 @@ pub fn add_mcp_server(
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -994,7 +1021,8 @@ pub fn set_mcp_server_enabled(
|
||||
enabled: bool,
|
||||
) -> anyhow::Result<bool> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match std::fs::read_to_string(&config_path) {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -1086,7 +1114,7 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
/// Base config deserialized from ~/.code/config.toml (legacy ~/.codex/config.toml is still read).
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct ConfigToml {
|
||||
/// Optional override of model selection.
|
||||
@@ -1155,7 +1183,8 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ConfigProfile>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
/// Settings that govern if and what will be written to `~/.code/history.jsonl`
|
||||
/// (Code still reads legacy `~/.codex/history.jsonl`).
|
||||
#[serde(default)]
|
||||
pub history: Option<History>,
|
||||
|
||||
@@ -1731,26 +1760,82 @@ fn default_review_model() -> String {
|
||||
OPENAI_DEFAULT_REVIEW_MODEL.to_string()
|
||||
}
|
||||
|
||||
fn env_path(var: &str) -> std::io::Result<Option<PathBuf>> {
|
||||
match std::env::var(var) {
|
||||
Ok(val) if !val.trim().is_empty() => {
|
||||
let canonical = PathBuf::from(val).canonicalize()?;
|
||||
Ok(Some(canonical))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_overrides_present() -> bool {
|
||||
matches!(std::env::var("CODE_HOME"), Ok(ref v) if !v.trim().is_empty())
|
||||
|| matches!(std::env::var("CODEX_HOME"), Ok(ref v) if !v.trim().is_empty())
|
||||
}
|
||||
|
||||
fn legacy_codex_home_dir() -> Option<PathBuf> {
|
||||
static LEGACY: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
LEGACY
|
||||
.get_or_init(|| {
|
||||
if env_overrides_present() {
|
||||
return None;
|
||||
}
|
||||
let Some(home) = home_dir() else {
|
||||
return None;
|
||||
};
|
||||
let candidate = home.join(".codex");
|
||||
if path_exists(&candidate) {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn path_exists(path: &Path) -> bool {
|
||||
std::fs::metadata(path).is_ok()
|
||||
}
|
||||
|
||||
/// Resolve the filesystem path used for *reading* Codex state that may live in
|
||||
/// a legacy `~/.codex` directory. Writes should continue targeting `codex_home`.
|
||||
pub fn resolve_codex_path_for_read(codex_home: &Path, relative: &Path) -> PathBuf {
|
||||
let default_path = codex_home.join(relative);
|
||||
|
||||
if env_overrides_present() {
|
||||
return default_path;
|
||||
}
|
||||
|
||||
if path_exists(&default_path) {
|
||||
return default_path;
|
||||
}
|
||||
|
||||
if let Some(legacy) = legacy_codex_home_dir() {
|
||||
let candidate = legacy.join(relative);
|
||||
if path_exists(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
default_path
|
||||
}
|
||||
|
||||
/// Returns the path to the Code/Codex configuration directory, which can be
|
||||
/// specified by the `CODE_HOME` or `CODEX_HOME` environment variables. If not set,
|
||||
/// defaults to `~/.code` (falling back to `~/.codex` if it exists for compatibility).
|
||||
/// defaults to `~/.code` for the fork.
|
||||
///
|
||||
/// - If `CODE_HOME` or `CODEX_HOME` is set, the value will be canonicalized and this
|
||||
/// function will Err if the path does not exist.
|
||||
/// - If neither is set, this function does not verify that the directory exists.
|
||||
pub fn find_codex_home() -> std::io::Result<PathBuf> {
|
||||
// First check CODE_HOME for the fork
|
||||
if let Ok(val) = std::env::var("CODE_HOME") {
|
||||
if !val.is_empty() {
|
||||
return PathBuf::from(val).canonicalize();
|
||||
}
|
||||
if let Some(path) = env_path("CODE_HOME")? {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
// Fall back to CODEX_HOME for compatibility
|
||||
if let Ok(val) = std::env::var("CODEX_HOME") {
|
||||
if !val.is_empty() {
|
||||
return PathBuf::from(val).canonicalize();
|
||||
}
|
||||
if let Some(path) = env_path("CODEX_HOME")? {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let home = home_dir().ok_or_else(|| {
|
||||
@@ -1760,17 +1845,9 @@ pub fn find_codex_home() -> std::io::Result<PathBuf> {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Check if ~/.codex exists for backward compatibility
|
||||
let mut codex_path = home.clone();
|
||||
codex_path.push(".codex");
|
||||
if codex_path.exists() {
|
||||
return Ok(codex_path);
|
||||
}
|
||||
|
||||
// Otherwise use ~/.code for the fork
|
||||
let mut p = home;
|
||||
p.push(".code");
|
||||
Ok(p)
|
||||
let mut write_path = home;
|
||||
write_path.push(".code");
|
||||
Ok(write_path)
|
||||
}
|
||||
|
||||
/// Returns the path to the folder where Codex logs are stored. Does not verify
|
||||
@@ -2202,7 +2279,7 @@ model_verbosity = "high"
|
||||
assert_eq!(
|
||||
Config {
|
||||
model: "o3".to_string(),
|
||||
review_model: "gpt-5".to_string(),
|
||||
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
|
||||
model_family: find_family_for_model("o3").expect("known model slug"),
|
||||
model_context_window: Some(200_000),
|
||||
model_max_output_tokens: Some(100_000),
|
||||
@@ -2265,7 +2342,7 @@ model_verbosity = "high"
|
||||
)?;
|
||||
let expected_gpt3_profile_config = Config {
|
||||
model: "gpt-3.5-turbo".to_string(),
|
||||
review_model: "gpt-5".to_string(),
|
||||
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
|
||||
model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"),
|
||||
model_context_window: Some(16_385),
|
||||
model_max_output_tokens: Some(4_096),
|
||||
@@ -2344,7 +2421,7 @@ model_verbosity = "high"
|
||||
)?;
|
||||
let expected_zdr_profile_config = Config {
|
||||
model: "o3".to_string(),
|
||||
review_model: "gpt-5".to_string(),
|
||||
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
|
||||
model_family: find_family_for_model("o3").expect("known model slug"),
|
||||
model_context_window: Some(200_000),
|
||||
model_max_output_tokens: Some(100_000),
|
||||
@@ -2409,7 +2486,7 @@ model_verbosity = "high"
|
||||
)?;
|
||||
let expected_gpt5_profile_config = Config {
|
||||
model: "gpt-5".to_string(),
|
||||
review_model: "gpt-5".to_string(),
|
||||
review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(),
|
||||
model_family: find_family_for_model("gpt-5").expect("known model slug"),
|
||||
model_context_window: Some(400_000),
|
||||
model_max_output_tokens: Some(128_000),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::resolve_codex_path_for_read;
|
||||
use crate::config_types::SubagentCommandConfig;
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
@@ -99,7 +100,8 @@ fn apply_toml_edit_override_segments(
|
||||
pub async fn upsert_subagent_command(codex_home: &Path, cmd: &SubagentCommandConfig) -> Result<()> {
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match tokio::fs::read_to_string(&config_path).await {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match tokio::fs::read_to_string(&read_path).await {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tokio::fs::create_dir_all(codex_home).await?;
|
||||
@@ -166,7 +168,8 @@ pub async fn upsert_subagent_command(codex_home: &Path, cmd: &SubagentCommandCon
|
||||
pub async fn delete_subagent_command(codex_home: &Path, name: &str) -> Result<bool> {
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match tokio::fs::read_to_string(&config_path).await {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match tokio::fs::read_to_string(&read_path).await {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(e) => return Err(e.into()),
|
||||
@@ -207,7 +210,8 @@ pub async fn upsert_agent_config(
|
||||
) -> Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
let mut doc = match tokio::fs::read_to_string(&config_path).await {
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match tokio::fs::read_to_string(&read_path).await {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tokio::fs::create_dir_all(codex_home).await?;
|
||||
@@ -328,7 +332,8 @@ async fn persist_overrides_with_behavior(
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let read_result = tokio::fs::read_to_string(&config_path).await;
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let read_result = tokio::fs::read_to_string(&read_path).await;
|
||||
let mut doc = match read_result {
|
||||
Ok(contents) => contents.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
|
||||
@@ -248,7 +248,8 @@ impl UriBasedFileOpener {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
/// Settings that govern if and what will be written to `~/.code/history.jsonl`
|
||||
/// (Code still reads legacy `~/.codex/history.jsonl`).
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||||
pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
|
||||
@@ -32,20 +32,6 @@ impl ConversationHistory {
|
||||
}
|
||||
}
|
||||
|
||||
// Replaced by record_items + keep_last_messages utilities.
|
||||
|
||||
/// Retain only the last `n` messages in the history (oldest-first order).
|
||||
pub(crate) fn keep_last_messages(&mut self, n: usize) {
|
||||
if n == 0 {
|
||||
self.items.clear();
|
||||
return;
|
||||
}
|
||||
let len = self.items.len();
|
||||
if len > n {
|
||||
let start = len - n;
|
||||
self.items = self.items.split_off(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything that is not a system message or "reasoning" message is considered
|
||||
@@ -58,8 +44,9 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::Reasoning { .. } => true,
|
||||
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,20 +172,13 @@ impl ConversationManager {
|
||||
.await
|
||||
.map_err(|e| CodexErr::Io(e))?;
|
||||
|
||||
// Extract only response items to seed the rollout file.
|
||||
let response_items: Vec<ResponseItem> = history
|
||||
.get_rollout_items()
|
||||
.into_iter()
|
||||
.filter_map(|ri| match ri {
|
||||
codex_protocol::protocol::RolloutItem::ResponseItem(item) => Some(item),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !response_items.is_empty() {
|
||||
// Persist rollout items to seed the resumed conversation.
|
||||
let rollout_items = history.get_rollout_items();
|
||||
if !rollout_items.is_empty() {
|
||||
recorder
|
||||
.record_items(&response_items)
|
||||
.record_items(&rollout_items)
|
||||
.await
|
||||
.map_err(|e| CodexErr::Io(e))?;
|
||||
.map_err(CodexErr::Io)?;
|
||||
}
|
||||
// Ensure data is flushed to disk before resuming.
|
||||
recorder.shutdown().await.map_err(|e| CodexErr::Io(e))?;
|
||||
|
||||
@@ -71,8 +71,33 @@ impl EnvironmentContext {
|
||||
shell,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares two environment contexts, ignoring the shell. Useful when
|
||||
/// comparing turn to turn, since the initial environment_context will
|
||||
/// include the shell, and then it is not configurable from turn to turn.
|
||||
#[cfg(test)]
|
||||
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
network_access,
|
||||
writable_roots,
|
||||
// should compare all fields except shell
|
||||
shell: _,
|
||||
} = other;
|
||||
|
||||
self.cwd == *cwd
|
||||
&& self.approval_policy == *approval_policy
|
||||
&& self.sandbox_mode == *sandbox_mode
|
||||
&& self.network_access == *network_access
|
||||
&& self.writable_roots == *writable_roots
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The core no longer exposes `TurnContext` here; callers construct
|
||||
// `EnvironmentContext` directly via `EnvironmentContext::new(...)`.
|
||||
|
||||
impl EnvironmentContext {
|
||||
/// Serializes the environment context to XML. Libraries like `quick-xml`
|
||||
/// require custom macros to handle Enums with newtypes, so we just do it
|
||||
@@ -140,6 +165,9 @@ impl From<EnvironmentContext> for ResponseItem {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::shell::BashShell;
|
||||
use crate::shell::ZshShell;
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -210,4 +238,82 @@ mod tests {
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_approval_policy() {
|
||||
// Approval policy
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::Never),
|
||||
Some(workspace_write_policy(vec!["/repo"], true)),
|
||||
None,
|
||||
);
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_read_only_policy()),
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(SandboxPolicy::new_workspace_write_policy()),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_workspace_write_policy() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_ignores_shell() {
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Some(Shell::Bash(BashShell {
|
||||
shell_path: "/bin/bash".into(),
|
||||
bashrc_path: "/home/user/.bashrc".into(),
|
||||
})),
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
Some(AskForApproval::OnRequest),
|
||||
Some(workspace_write_policy(vec!["/repo"], false)),
|
||||
Some(Shell::Zsh(ZshShell {
|
||||
shell_path: "/bin/zsh".into(),
|
||||
zshrc_path: "/home/user/.zshrc".into(),
|
||||
})),
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::resolve_codex_path_for_read;
|
||||
|
||||
pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json";
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
@@ -19,8 +21,9 @@ pub struct InternalStorage {
|
||||
impl InternalStorage {
|
||||
pub fn load(codex_home: &Path) -> Self {
|
||||
let storage_path = codex_home.join(INTERNAL_STORAGE_FILE);
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(INTERNAL_STORAGE_FILE));
|
||||
|
||||
match std::fs::read_to_string(&storage_path) {
|
||||
match std::fs::read_to_string(&read_path) {
|
||||
Ok(serialized) => match serde_json::from_str::<Self>(&serialized) {
|
||||
Ok(mut storage) => {
|
||||
storage.storage_path = storage_path;
|
||||
|
||||
@@ -52,6 +52,7 @@ pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
mod conversation_manager;
|
||||
mod event_mapping;
|
||||
pub mod review_format;
|
||||
pub use codex_protocol::protocol::InitialHistory;
|
||||
pub use conversation_manager::ConversationManager;
|
||||
pub use conversation_manager::NewConversation;
|
||||
@@ -71,8 +72,10 @@ pub mod shell;
|
||||
pub mod spawn;
|
||||
pub mod terminal;
|
||||
mod tool_apply_patch;
|
||||
mod user_instructions;
|
||||
pub mod turn_diff_tracker;
|
||||
pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
pub use rollout::list::find_conversation_path_by_id_str;
|
||||
pub use rollout::recorder::RolloutRecorder;
|
||||
mod user_notification;
|
||||
@@ -93,6 +96,8 @@ pub use crate::client_common::Prompt;
|
||||
pub use crate::client_common::TextFormat;
|
||||
pub use crate::client_common::ResponseEvent;
|
||||
pub use crate::client_common::ResponseStream;
|
||||
// Upstream also exports REVIEW_PROMPT; include it to preserve compatibility.
|
||||
pub use crate::client_common::REVIEW_PROMPT;
|
||||
pub use codex_protocol::models::ContentItem;
|
||||
pub use codex_protocol::models::ReasoningItemContent;
|
||||
pub use codex_protocol::models::ResponseItem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Persistence layer for the global, append-only *message history* file.
|
||||
//!
|
||||
//! The history is stored at `~/.codex/history.jsonl` with **one JSON object per
|
||||
//! The history is stored at `~/.code/history.jsonl` (Code still reads legacy
|
||||
//! `~/.codex/history.jsonl`) with **one JSON object per
|
||||
//! line** so that it can be efficiently appended to and parsed with standard
|
||||
//! JSON-Lines tooling. Each record has the following schema:
|
||||
//!
|
||||
@@ -35,7 +36,8 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
/// Filename that stores the message history inside `~/.codex`.
|
||||
/// Filename that stores the message history inside `~/.code` (legacy `~/.codex`
|
||||
/// is still read).
|
||||
const HISTORY_FILENAME: &str = "history.jsonl";
|
||||
|
||||
const MAX_RETRIES: usize = 10;
|
||||
@@ -70,7 +72,7 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
|
||||
|
||||
// TODO: check `text` for sensitive patterns
|
||||
|
||||
// Resolve `~/.codex/history.jsonl` and ensure the parent directory exists.
|
||||
// Resolve `~/.code/history.jsonl` and ensure the parent directory exists.
|
||||
let path = history_filepath(config);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Providers can be defined in two places:
|
||||
//! 1. Built-in defaults compiled into the binary so Codex works out-of-the-box.
|
||||
//! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers`
|
||||
//! 2. User-defined entries inside `~/.code/config.toml` under the `model_providers`
|
||||
//! table (Code also reads legacy `~/.codex/config.toml`).
|
||||
//! key. These override or extend the defaults at runtime.
|
||||
|
||||
use crate::CodexAuth;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
You have exceeded the maximum number of tokens, please stop coding and instead write a short memento message for the next agent. Your note should:
|
||||
- Summarize what you finished and what still needs work. If there was a recent update_plan call, repeat its steps verbatim.
|
||||
- List outstanding TODOs with file paths / line numbers so they're easy to find.
|
||||
- Flag code that needs more tests (edge cases, performance, integration, etc.).
|
||||
- Record any open bugs, quirks, or setup steps that will make it easier for the next agent to pick up where you left off.
|
||||
@@ -11,6 +11,7 @@ use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use mcp_types::CallToolResult;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_bytes::ByteBuf;
|
||||
@@ -25,6 +26,13 @@ use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::parse_command::ParsedCommand;
|
||||
use crate::plan_tool::UpdatePlanArgs;
|
||||
|
||||
// Re-export review types from the shared protocol crate so callers can use
|
||||
// `codex_core::protocol::ReviewFinding` and friends.
|
||||
pub use codex_protocol::protocol::ReviewCodeLocation;
|
||||
pub use codex_protocol::protocol::ReviewFinding;
|
||||
pub use codex_protocol::protocol::ReviewLineRange;
|
||||
pub use codex_protocol::protocol::ReviewOutputEvent;
|
||||
|
||||
/// Submission Queue Entry - requests from user
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Submission {
|
||||
@@ -397,6 +405,95 @@ pub struct Event {
|
||||
pub order: Option<OrderMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RecordedEvent {
|
||||
pub id: String,
|
||||
pub event_seq: u64,
|
||||
pub order: Option<OrderMeta>,
|
||||
pub msg: EventMsg,
|
||||
}
|
||||
|
||||
pub fn event_msg_to_protocol(msg: &EventMsg) -> Option<codex_protocol::protocol::EventMsg> {
|
||||
if matches!(msg, EventMsg::ReplayHistory(_)) {
|
||||
return None;
|
||||
}
|
||||
convert_value(msg)
|
||||
}
|
||||
|
||||
|
||||
pub fn event_msg_from_protocol(msg: &codex_protocol::protocol::EventMsg) -> Option<EventMsg> {
|
||||
let Some(converted) = convert_value(msg) else {
|
||||
return None;
|
||||
};
|
||||
if matches!(converted, EventMsg::ReplayHistory(_)) {
|
||||
return None;
|
||||
}
|
||||
Some(converted)
|
||||
}
|
||||
|
||||
|
||||
pub fn order_meta_to_protocol(
|
||||
order: &OrderMeta,
|
||||
) -> codex_protocol::protocol::OrderMeta {
|
||||
codex_protocol::protocol::OrderMeta {
|
||||
request_ordinal: order.request_ordinal,
|
||||
output_index: order.output_index,
|
||||
sequence_number: order.sequence_number,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn order_meta_from_protocol(
|
||||
order: &codex_protocol::protocol::OrderMeta,
|
||||
) -> OrderMeta {
|
||||
OrderMeta {
|
||||
request_ordinal: order.request_ordinal,
|
||||
output_index: order.output_index,
|
||||
sequence_number: order.sequence_number,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn recorded_event_to_protocol(
|
||||
event: &RecordedEvent,
|
||||
) -> Option<codex_protocol::protocol::RecordedEvent> {
|
||||
let msg = event_msg_to_protocol(&event.msg)?;
|
||||
let order = event
|
||||
.order
|
||||
.as_ref()
|
||||
.map(order_meta_to_protocol);
|
||||
Some(codex_protocol::protocol::RecordedEvent {
|
||||
id: event.id.clone(),
|
||||
event_seq: event.event_seq,
|
||||
order,
|
||||
msg,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
pub fn recorded_event_from_protocol(
|
||||
src: codex_protocol::protocol::RecordedEvent,
|
||||
) -> Option<RecordedEvent> {
|
||||
let msg = event_msg_from_protocol(&src.msg)?;
|
||||
let order = src.order.as_ref().map(order_meta_from_protocol);
|
||||
Some(RecordedEvent {
|
||||
id: src.id,
|
||||
event_seq: src.event_seq,
|
||||
order,
|
||||
msg,
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_value<T, U>(value: &T) -> Option<U>
|
||||
where
|
||||
T: Serialize,
|
||||
U: DeserializeOwned,
|
||||
{
|
||||
let Ok(json) = serde_json::to_value(value) else {
|
||||
return None;
|
||||
};
|
||||
serde_json::from_value(json).ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct OrderMeta {
|
||||
/// 1-based ordinal of this request/turn in the session
|
||||
@@ -581,6 +678,9 @@ pub struct ReplayHistoryEvent {
|
||||
/// Items to render in order. Front-ends should render these as static
|
||||
/// history without triggering any tool execution.
|
||||
pub items: Vec<codex_protocol::models::ResponseItem>,
|
||||
/// Previously emitted events to replay for UI restoration.
|
||||
#[serde(default)]
|
||||
pub events: Vec<RecordedEvent>,
|
||||
}
|
||||
|
||||
impl From<TokenUsage> for FinalOutput {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::protocol::ReviewFinding;
|
||||
|
||||
// Note: We keep this module UI-agnostic. It returns plain strings that
|
||||
// higher layers (e.g., TUI) may style as needed.
|
||||
|
||||
fn format_location(item: &ReviewFinding) -> String {
|
||||
let path = item.code_location.absolute_file_path.display();
|
||||
let start = item.code_location.line_range.start;
|
||||
let end = item.code_location.line_range.end;
|
||||
format!("{path}:{start}-{end}")
|
||||
}
|
||||
|
||||
/// Format a full review findings block as plain text lines.
|
||||
///
|
||||
/// - When `selection` is `Some`, each item line includes a checkbox marker:
|
||||
/// "[x]" for selected items and "[ ]" for unselected. Missing indices
|
||||
/// default to selected.
|
||||
/// - When `selection` is `None`, the marker is omitted and a simple bullet is
|
||||
/// rendered ("- Title — path:start-end").
|
||||
pub fn format_review_findings_block(
|
||||
findings: &[ReviewFinding],
|
||||
selection: Option<&[bool]>,
|
||||
) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
// Header
|
||||
let header = if findings.len() > 1 {
|
||||
"Full review comments:"
|
||||
} else {
|
||||
"Review comment:"
|
||||
};
|
||||
lines.push(header.to_string());
|
||||
|
||||
for (idx, item) in findings.iter().enumerate() {
|
||||
lines.push(String::new());
|
||||
|
||||
let title = &item.title;
|
||||
let location = format_location(item);
|
||||
|
||||
if let Some(flags) = selection {
|
||||
// Default to selected if index is out of bounds.
|
||||
let checked = flags.get(idx).copied().unwrap_or(true);
|
||||
let marker = if checked { "[x]" } else { "[ ]" };
|
||||
lines.push(format!("- {marker} {title} — {location}"));
|
||||
} else {
|
||||
lines.push(format!("- {title} — {location}"));
|
||||
}
|
||||
|
||||
for body_line in item.body.lines() {
|
||||
lines.push(format!(" {body_line}"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -15,9 +15,9 @@ use time::macros::format_description;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use codex_protocol::protocol::EventMsg as ProtocolEventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use crate::config::resolve_codex_path_for_read;
|
||||
|
||||
/// Returned page of conversation summaries.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
@@ -91,8 +91,7 @@ pub(crate) async fn get_conversations(
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
) -> io::Result<ConversationsPage> {
|
||||
let mut root = codex_home.to_path_buf();
|
||||
root.push(SESSIONS_SUBDIR);
|
||||
let root = resolve_codex_path_for_read(codex_home, Path::new(SESSIONS_SUBDIR));
|
||||
|
||||
if !root.exists() {
|
||||
return Ok(ConversationsPage {
|
||||
@@ -118,7 +117,8 @@ pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
|
||||
|
||||
/// Load conversation file paths from disk using directory traversal.
|
||||
///
|
||||
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
|
||||
/// Directory layout: `~/.code/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
|
||||
/// (Code still reads legacy `~/.codex/sessions/...`).
|
||||
/// Returned newest (latest) first.
|
||||
async fn traverse_directories_for_paths(
|
||||
root: PathBuf,
|
||||
@@ -323,9 +323,11 @@ async fn read_head_and_flags(
|
||||
head.push(val);
|
||||
}
|
||||
}
|
||||
RolloutItem::EventMsg(ev) => {
|
||||
if matches!(ev, ProtocolEventMsg::AgentMessage(_)) {
|
||||
saw_user_event = true;
|
||||
RolloutItem::Event(event) => {
|
||||
if let Some(msg) = crate::protocol::event_msg_from_protocol(&event.msg) {
|
||||
if matches!(msg, crate::protocol::EventMsg::AgentMessage(_)) {
|
||||
saw_user_event = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip variants not displayed in list summaries.
|
||||
@@ -336,6 +338,7 @@ async fn read_head_and_flags(
|
||||
Ok((head, saw_session_meta, saw_user_event))
|
||||
}
|
||||
|
||||
|
||||
/// Locate a recorded conversation rollout file by its UUID string using the existing
|
||||
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
|
||||
/// or the id is invalid.
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
use crate::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
|
||||
/// Whether a rollout `item` should be persisted in rollout files.
|
||||
#[inline]
|
||||
pub(crate) fn is_persisted_response_item(item: &RolloutItem) -> bool {
|
||||
pub(crate) fn should_persist_rollout_item(item: &RolloutItem) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
|
||||
RolloutItem::EventMsg(_ev) => {
|
||||
// We do not persist protocol EventMsg entries in this fork.
|
||||
false
|
||||
}
|
||||
RolloutItem::Event(_) => true,
|
||||
// Always persist session meta
|
||||
RolloutItem::SessionMeta(_) => true,
|
||||
// Do not persist variants not used by this fork.
|
||||
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => false,
|
||||
// Persist compacted summaries and turn context for accurate history reconstruction.
|
||||
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,53 +25,19 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. } => true,
|
||||
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an `EventMsg` should be persisted in rollout files.
|
||||
/// Whether an [`EventMsg`] should be persisted.
|
||||
#[inline]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
match ev {
|
||||
EventMsg::UserMessage(_)
|
||||
| EventMsg::AgentMessage(_)
|
||||
| EventMsg::AgentReasoning(_)
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::TurnAborted(_) => true,
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::TaskStarted
|
||||
| EventMsg::TaskComplete(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::WebSearchComplete(_)
|
||||
| EventMsg::CustomToolCallBegin(_)
|
||||
| EventMsg::CustomToolCallEnd(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::BrowserScreenshotUpdate(_)
|
||||
| EventMsg::AgentStatusUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::ConversationPath(_)
|
||||
| EventMsg::ReplayHistory(_) => false,
|
||||
}
|
||||
!matches!(
|
||||
ev,
|
||||
EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use super::SESSIONS_SUBDIR;
|
||||
use super::list::ConversationsPage;
|
||||
use super::list::Cursor;
|
||||
use super::list::get_conversations;
|
||||
use super::policy::is_persisted_response_item;
|
||||
use super::policy::{should_persist_response_item, should_persist_rollout_item};
|
||||
use crate::config::Config;
|
||||
use crate::default_client::DEFAULT_ORIGINATOR;
|
||||
use crate::git_info::collect_git_info;
|
||||
@@ -43,7 +43,9 @@ pub struct SessionStateSnapshot {}
|
||||
pub struct SavedSession {
|
||||
pub session: SessionMeta,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ResponseItem>,
|
||||
pub items: Vec<RolloutItem>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<crate::protocol::RecordedEvent>,
|
||||
#[serde(default)]
|
||||
pub state: SessionStateSnapshot,
|
||||
pub session_id: uuid::Uuid,
|
||||
@@ -55,8 +57,8 @@ pub struct SavedSession {
|
||||
/// Rollouts are recorded as JSONL and can be inspected with tools such as:
|
||||
///
|
||||
/// ```ignore
|
||||
/// $ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
|
||||
/// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
|
||||
/// $ jq -C . ~/.code/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
|
||||
/// $ fx ~/.code/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct RolloutRecorder {
|
||||
@@ -166,14 +168,30 @@ impl RolloutRecorder {
|
||||
Ok(Self { tx, rollout_path })
|
||||
}
|
||||
|
||||
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
|
||||
pub(crate) async fn record_response_items(
|
||||
&self,
|
||||
items: &[ResponseItem],
|
||||
) -> std::io::Result<()> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut rollout_items: Vec<RolloutItem> = Vec::new();
|
||||
for item in items {
|
||||
if should_persist_response_item(item) {
|
||||
rollout_items.push(RolloutItem::ResponseItem(item.clone()));
|
||||
}
|
||||
}
|
||||
if rollout_items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.record_items(&rollout_items).await
|
||||
}
|
||||
|
||||
pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
|
||||
let mut filtered: Vec<RolloutItem> = Vec::new();
|
||||
for item in items {
|
||||
// Note that function calls may look a bit strange if they are
|
||||
// "fully qualified MCP tool calls," so we could consider
|
||||
// reformatting them in that case.
|
||||
if super::policy::should_persist_response_item(item) {
|
||||
filtered.push(RolloutItem::ResponseItem(item.clone()));
|
||||
if should_persist_rollout_item(item) {
|
||||
filtered.push(item.clone());
|
||||
}
|
||||
}
|
||||
if filtered.is_empty() {
|
||||
@@ -185,6 +203,24 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
|
||||
}
|
||||
|
||||
pub(crate) async fn record_events(
|
||||
&self,
|
||||
events: &[codex_protocol::protocol::RecordedEvent],
|
||||
) -> std::io::Result<()> {
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let filtered = events
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(RolloutItem::Event)
|
||||
.collect::<Vec<_>>();
|
||||
self.tx
|
||||
.send(RolloutCmd::AddItems(filtered))
|
||||
.await
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout events: {e}")))
|
||||
}
|
||||
|
||||
/// No-op compatibility shim for older APIs expecting a state snapshot.
|
||||
pub async fn record_state(&self, _snapshot: SessionStateSnapshot) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
@@ -194,17 +230,24 @@ impl RolloutRecorder {
|
||||
pub async fn resume(config: &Config, path: &Path) -> std::io::Result<(Self, SavedSession)> {
|
||||
let recorder = Self::new(config, RolloutRecorderParams::Resume { path: path.to_path_buf() }).await?;
|
||||
let history = Self::get_rollout_history(path).await?;
|
||||
let (session_id, items) = match history {
|
||||
InitialHistory::Resumed(resumed) => (resumed.conversation_id.0, resumed
|
||||
.history
|
||||
.into_iter()
|
||||
.filter_map(|ri| match ri { RolloutItem::ResponseItem(it) => Some(it), _ => None })
|
||||
.collect::<Vec<ResponseItem>>()),
|
||||
_ => (uuid::Uuid::new_v4(), Vec::new()),
|
||||
let (session_id, items, events) = match history {
|
||||
InitialHistory::Resumed(resumed) => {
|
||||
let events = resumed
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
RolloutItem::Event(ev) => crate::protocol::recorded_event_from_protocol(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(resumed.conversation_id.0, resumed.history, events)
|
||||
}
|
||||
_ => (uuid::Uuid::new_v4(), Vec::new(), Vec::new()),
|
||||
};
|
||||
let saved = SavedSession {
|
||||
session: SessionMeta::default(),
|
||||
items,
|
||||
events,
|
||||
state: SessionStateSnapshot::default(),
|
||||
session_id,
|
||||
};
|
||||
@@ -246,8 +289,8 @@ impl RolloutRecorder {
|
||||
RolloutItem::ResponseItem(item) => {
|
||||
items.push(RolloutItem::ResponseItem(item));
|
||||
}
|
||||
RolloutItem::EventMsg(_ev) => {
|
||||
items.push(RolloutItem::EventMsg(_ev));
|
||||
RolloutItem::Event(ev) => {
|
||||
items.push(RolloutItem::Event(ev));
|
||||
}
|
||||
// Ignore variants not used by this fork when resuming.
|
||||
RolloutItem::Compacted(_)
|
||||
@@ -315,7 +358,8 @@ fn create_log_file(
|
||||
config: &Config,
|
||||
conversation_id: ConversationId,
|
||||
) -> std::io::Result<LogFileInfo> {
|
||||
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
|
||||
// Resolve ~/.code/sessions/YYYY/MM/DD and create it if missing (Code still
|
||||
// reads legacy ~/.codex/sessions/ paths).
|
||||
let timestamp = OffsetDateTime::now_local()
|
||||
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
|
||||
let mut dir = config.codex_home.clone();
|
||||
@@ -376,7 +420,7 @@ async fn rollout_writer(
|
||||
match cmd {
|
||||
RolloutCmd::AddItems(items) => {
|
||||
for item in items {
|
||||
if is_persisted_response_item(&item) {
|
||||
if should_persist_rollout_item(&item) {
|
||||
writer.write_rollout_item(item).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,20 @@ use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct ZshShell {
|
||||
shell_path: String,
|
||||
zshrc_path: String,
|
||||
pub(crate) shell_path: String,
|
||||
pub(crate) zshrc_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct BashShell {
|
||||
shell_path: String,
|
||||
bashrc_path: String,
|
||||
pub(crate) shell_path: String,
|
||||
pub(crate) bashrc_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
pub struct PowerShellConfig {
|
||||
exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
|
||||
bash_exe_fallback: Option<PathBuf>, // In case the model generates a bash command.
|
||||
pub(crate) exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe".
|
||||
pub(crate) bash_exe_fallback: Option<PathBuf>, // In case the model generates a bash command.
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
use tracing::Level;
|
||||
|
||||
@@ -113,15 +112,24 @@ pub fn parse_id_token(id_token: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
|
||||
};
|
||||
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)?;
|
||||
let raw_claims: Value = serde_json::from_slice(&payload_bytes)?;
|
||||
if tracing::enabled!(Level::INFO) {
|
||||
tracing::info!(id_token_claims = %raw_claims, "decoded ChatGPT id_token claims");
|
||||
let claims: IdClaims = serde_json::from_slice(&payload_bytes)?;
|
||||
if tracing::enabled!(Level::DEBUG) {
|
||||
let plan = claims
|
||||
.auth
|
||||
.as_ref()
|
||||
.and_then(|a| a.chatgpt_plan_type.as_ref())
|
||||
.map(|plan| plan.as_string());
|
||||
tracing::debug!(
|
||||
email = claims.email.as_deref().unwrap_or("<missing>"),
|
||||
chatgpt_plan_type = plan.as_deref().unwrap_or("unknown"),
|
||||
"decoded ChatGPT id_token claims"
|
||||
);
|
||||
}
|
||||
let claims: IdClaims = serde_json::from_value(raw_claims)?;
|
||||
let IdClaims { email, auth } = claims;
|
||||
|
||||
Ok(IdTokenInfo {
|
||||
email: claims.email,
|
||||
chatgpt_plan_type: claims.auth.and_then(|a| a.chatgpt_plan_type),
|
||||
email,
|
||||
chatgpt_plan_type: auth.and_then(|a| a.chatgpt_plan_type),
|
||||
raw_jwt: id_token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use codex_core::config::ConfigToml;
|
||||
|
||||
/// Returns a default `Config` whose on-disk state is confined to the provided
|
||||
/// temporary directory. Using a per-test directory keeps tests hermetic and
|
||||
/// avoids clobbering a developer’s real `~/.codex`.
|
||||
/// avoids clobbering a developer’s real `~/.code` directory (legacy `~/.codex`
|
||||
/// is still read).
|
||||
pub fn load_default_config_for_test(codex_home: &TempDir) -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
|
||||
@@ -12,6 +12,7 @@ mod model_overrides;
|
||||
mod prompt_caching;
|
||||
mod review;
|
||||
mod rollout_list_find;
|
||||
mod rollout_resume;
|
||||
mod seatbelt;
|
||||
mod stream_error_allows_next_turn;
|
||||
mod stream_no_completed;
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_core::shell::Shell;
|
||||
use codex_core::shell::default_user_shell;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
@@ -23,6 +24,30 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn text_user_input(text: String) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": text } ]
|
||||
})
|
||||
}
|
||||
|
||||
fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
|
||||
format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
{}</environment_context>"#,
|
||||
cwd,
|
||||
match shell.name() {
|
||||
Some(name) => format!(" <shell>{name}</shell>\n"),
|
||||
None => String::new(),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Build minimal SSE stream with completed marker using the JSON fixture.
|
||||
fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
@@ -553,12 +578,262 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
let expected_env_text_2 = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
<writable_roots>
|
||||
<root>{}</root>
|
||||
</writable_roots>
|
||||
</environment_context>"#,
|
||||
new_cwd.path().to_string_lossy(),
|
||||
writable.path().to_string_lossy(),
|
||||
);
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_user_message_2].as_slice(),
|
||||
[expected_env_msg_2, expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
assert_eq!(body2["input"], expected_body2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_user_turn_with_no_changes_does_not_send_environment_context() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse = sse_completed("resp");
|
||||
let template = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(template)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
|
||||
let default_cwd = config.cwd.clone();
|
||||
let default_approval_policy = config.approval_policy;
|
||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let default_model = config.model.clone();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2, "expected two POST requests");
|
||||
|
||||
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let body2 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
let shell = default_user_shell().await;
|
||||
let expected_ui_text =
|
||||
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
|
||||
let expected_ui_msg = text_user_input(expected_ui_text.to_string());
|
||||
|
||||
let expected_env_msg_1 = text_user_input(default_env_context_str(
|
||||
&cwd.path().to_string_lossy(),
|
||||
&shell,
|
||||
));
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
]);
|
||||
assert_eq!(body1["input"], expected_input_1);
|
||||
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_user_message_1,
|
||||
expected_user_message_2,
|
||||
]);
|
||||
assert_eq!(body2["input"], expected_input_2);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn send_user_turn_with_changes_sends_environment_context() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let sse = sse_completed("resp");
|
||||
let template = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse, "text/event-stream");
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.respond_with(template)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.cwd = cwd.path().to_path_buf();
|
||||
config.model_provider = model_provider;
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
|
||||
let default_cwd = config.cwd.clone();
|
||||
let default_approval_policy = config.approval_policy;
|
||||
let default_sandbox_policy = config.sandbox_policy.clone();
|
||||
let default_model = config.model.clone();
|
||||
let default_effort = config.model_reasoning_effort;
|
||||
let default_summary = config.model_reasoning_summary;
|
||||
|
||||
let conversation_manager =
|
||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
||||
let codex = conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.conversation;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: default_approval_policy,
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
cwd: default_cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2, "expected two POST requests");
|
||||
|
||||
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let body2 = requests[1].body_json::<serde_json::Value>().unwrap();
|
||||
|
||||
let shell = default_user_shell().await;
|
||||
let expected_ui_text =
|
||||
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
|
||||
let expected_ui_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_ui_text } ]
|
||||
});
|
||||
let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell);
|
||||
let expected_env_msg_1 = text_user_input(expected_env_text_1);
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
]);
|
||||
assert_eq!(body1["input"], expected_input_1);
|
||||
|
||||
let expected_env_msg_2 = text_user_input(format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>danger-full-access</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
</environment_context>"#,
|
||||
default_cwd.to_string_lossy()
|
||||
));
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_user_message_1,
|
||||
expected_env_msg_2,
|
||||
expected_user_message_2,
|
||||
]);
|
||||
assert_eq!(body2["input"], expected_input_2);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ContentItem;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::REVIEW_PROMPT;
|
||||
use codex_core::ResponseItem;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::InputItem;
|
||||
@@ -13,6 +18,8 @@ use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewLineRange;
|
||||
use codex_core::protocol::ReviewOutputEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id_from_str;
|
||||
@@ -115,6 +122,46 @@ async fn review_op_emits_lifecycle_and_review_output() {
|
||||
assert_eq!(expected, review);
|
||||
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Also verify that a user message with the header and a formatted finding
|
||||
// was recorded back in the parent session's rollout.
|
||||
codex.submit(Op::GetPath).await.unwrap();
|
||||
let history_event =
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await;
|
||||
let path = match history_event {
|
||||
EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path,
|
||||
other => panic!("expected ConversationPath event, got {other:?}"),
|
||||
};
|
||||
let text = std::fs::read_to_string(&path).expect("read rollout file");
|
||||
|
||||
let mut saw_header = false;
|
||||
let mut saw_finding_line = false;
|
||||
for line in text.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line");
|
||||
let rl: RolloutLine = serde_json::from_value(v).expect("rollout line");
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item
|
||||
&& role == "user"
|
||||
{
|
||||
for c in content {
|
||||
if let ContentItem::InputText { text } = c {
|
||||
if text.contains("full review output from reviewer model") {
|
||||
saw_header = true;
|
||||
}
|
||||
if text.contains("- Prefer Stylize helpers — /tmp/file.rs:10-20") {
|
||||
saw_finding_line = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(saw_header, "user header missing from rollout");
|
||||
assert!(
|
||||
saw_finding_line,
|
||||
"formatted finding line missing from rollout"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
@@ -419,17 +466,73 @@ async fn review_input_isolated_from_parent_history() {
|
||||
.await;
|
||||
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
// Assert the request `input` contains only the single review user message.
|
||||
// Assert the request `input` contains the environment context followed by the review prompt.
|
||||
let request = &server.received_requests().await.unwrap()[0];
|
||||
let body = request.body_json::<serde_json::Value>().unwrap();
|
||||
let expected_input = serde_json::json!([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": review_prompt}]
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
assert_eq!(
|
||||
input.len(),
|
||||
2,
|
||||
"expected environment context and review prompt"
|
||||
);
|
||||
|
||||
let env_msg = &input[0];
|
||||
assert_eq!(env_msg["type"].as_str().unwrap(), "message");
|
||||
assert_eq!(env_msg["role"].as_str().unwrap(), "user");
|
||||
let env_text = env_msg["content"][0]["text"].as_str().expect("env text");
|
||||
assert!(
|
||||
env_text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG),
|
||||
"environment context must be the first item"
|
||||
);
|
||||
assert!(
|
||||
env_text.contains("<cwd>"),
|
||||
"environment context should include cwd"
|
||||
);
|
||||
|
||||
let review_msg = &input[1];
|
||||
assert_eq!(review_msg["type"].as_str().unwrap(), "message");
|
||||
assert_eq!(review_msg["role"].as_str().unwrap(), "user");
|
||||
assert_eq!(
|
||||
review_msg["content"][0]["text"].as_str().unwrap(),
|
||||
format!("{REVIEW_PROMPT}\n\n---\n\nNow, here's your task: Please review only this",)
|
||||
);
|
||||
|
||||
// Also verify that a user interruption note was recorded in the rollout.
|
||||
codex.submit(Op::GetPath).await.unwrap();
|
||||
let history_event =
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ConversationPath(_))).await;
|
||||
let path = match history_event {
|
||||
EventMsg::ConversationPath(ConversationPathResponseEvent { path, .. }) => path,
|
||||
other => panic!("expected ConversationPath event, got {other:?}"),
|
||||
};
|
||||
let text = std::fs::read_to_string(&path).expect("read rollout file");
|
||||
let mut saw_interruption_message = false;
|
||||
for line in text.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
]);
|
||||
assert_eq!(body["input"], expected_input);
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("jsonl line");
|
||||
let rl: RolloutLine = serde_json::from_value(v).expect("rollout line");
|
||||
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = rl.item
|
||||
&& role == "user"
|
||||
{
|
||||
for c in content {
|
||||
if let ContentItem::InputText { text } = c
|
||||
&& text.contains("User initiated a review task, but was interrupted.")
|
||||
{
|
||||
saw_interruption_message = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if saw_interruption_message {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_interruption_message,
|
||||
"expected user interruption message in rollout"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use codex_core::protocol::{event_msg_to_protocol, AgentMessageEvent, EventMsg, RecordedEvent};
|
||||
use codex_core::rollout::RolloutRecorder;
|
||||
use codex_core::rollout::recorder::RolloutRecorderParams;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use codex_protocol::models::{ContentItem, ResponseItem};
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::load_default_config_for_test;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_restores_recorded_events() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.cwd = codex_home.path().to_path_buf();
|
||||
|
||||
let conversation_id = ConversationId(Uuid::new_v4());
|
||||
let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::new(conversation_id, None))
|
||||
.await
|
||||
.expect("create recorder");
|
||||
|
||||
let response_item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "hello from resume".to_string(),
|
||||
}],
|
||||
};
|
||||
recorder
|
||||
.record_response_items(std::slice::from_ref(&response_item))
|
||||
.await
|
||||
.expect("record response");
|
||||
|
||||
let recorded_event = RecordedEvent {
|
||||
id: "turn-1".to_string(),
|
||||
event_seq: 1,
|
||||
order: None,
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "assistant says hi".to_string(),
|
||||
}),
|
||||
};
|
||||
let protocol_event = codex_protocol::protocol::RecordedEvent {
|
||||
id: recorded_event.id.clone(),
|
||||
event_seq: recorded_event.event_seq,
|
||||
order: None,
|
||||
msg: event_msg_to_protocol(&recorded_event.msg).expect("convert event"),
|
||||
};
|
||||
recorder
|
||||
.record_events(std::slice::from_ref(&protocol_event))
|
||||
.await
|
||||
.expect("record event");
|
||||
|
||||
let rollout_path = recorder.rollout_path.clone();
|
||||
recorder.shutdown().await.expect("shutdown recorder");
|
||||
|
||||
let (_rec, saved) = RolloutRecorder::resume(&config, &rollout_path)
|
||||
.await
|
||||
.expect("resume recorder");
|
||||
|
||||
assert_eq!(saved.items, vec![response_item]);
|
||||
assert_eq!(saved.events, vec![recorded_event]);
|
||||
}
|
||||
@@ -405,12 +405,40 @@ pub enum InputItem {
|
||||
}
|
||||
|
||||
/// Event Queue Entry - events from agent
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct Event {
|
||||
/// Submission `id` that this event is correlated with.
|
||||
pub id: String,
|
||||
/// Monotonic, per-turn sequence for ordering within a submission id.
|
||||
/// Resets to 0 at TaskStarted and increments for each subsequent event.
|
||||
pub event_seq: u64,
|
||||
/// Payload
|
||||
pub msg: EventMsg,
|
||||
/// Optional model-provided ordering metadata (when applicable)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<OrderMeta>,
|
||||
}
|
||||
|
||||
/// Lightweight representation of an event suitable for persistence and replay.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct RecordedEvent {
|
||||
pub id: String,
|
||||
pub event_seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<OrderMeta>,
|
||||
pub msg: EventMsg,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct OrderMeta {
|
||||
/// 1-based ordinal of this request/turn in the session
|
||||
pub request_ordinal: u64,
|
||||
/// Model-provided output_index for the top-level item
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_index: Option<u32>,
|
||||
/// Model-provided sequence_number within the output_index stream
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sequence_number: Option<u64>,
|
||||
}
|
||||
|
||||
/// Response event from the agent
|
||||
@@ -591,6 +619,14 @@ pub struct TokenCountEvent {
|
||||
pub info: Option<TokenUsageInfo>,
|
||||
}
|
||||
|
||||
/// Payload for `ReplayHistory` containing prior `ResponseItem`s and optional events.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
pub struct ReplayHistoryEvent {
|
||||
pub items: Vec<crate::models::ResponseItem>,
|
||||
#[serde(default)]
|
||||
pub events: Vec<RecordedEvent>,
|
||||
}
|
||||
|
||||
// Includes prompts, tools and space to call compact.
|
||||
const BASELINE_TOKENS: u64 = 12000;
|
||||
|
||||
@@ -878,7 +914,7 @@ impl InitialHistory {
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|ri| match ri {
|
||||
RolloutItem::EventMsg(ev) => Some(ev.clone()),
|
||||
RolloutItem::Event(ev) => Some(ev.msg.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
@@ -887,7 +923,7 @@ impl InitialHistory {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|ri| match ri {
|
||||
RolloutItem::EventMsg(ev) => Some(ev.clone()),
|
||||
RolloutItem::Event(ev) => Some(ev.msg.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
@@ -921,7 +957,7 @@ pub enum RolloutItem {
|
||||
ResponseItem(ResponseItem),
|
||||
Compacted(CompactedItem),
|
||||
TurnContext(TurnContextItem),
|
||||
EventMsg(EventMsg),
|
||||
Event(RecordedEvent),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
|
||||
@@ -1256,6 +1292,7 @@ mod tests {
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let event = Event {
|
||||
id: "1234".to_string(),
|
||||
event_seq: 1,
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
@@ -1265,6 +1302,7 @@ mod tests {
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
}),
|
||||
order: None,
|
||||
};
|
||||
|
||||
let expected = json!({
|
||||
|
||||
@@ -89,6 +89,7 @@ futures = "0.3"
|
||||
syntect = { version = "5", features = ["yaml-load", "plist-load", "parsing", "default-syntaxes"] }
|
||||
which = "6"
|
||||
urlencoding = "2.1"
|
||||
pulldown-cmark = "0.13"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1537,7 +1537,10 @@ impl App<'_> {
|
||||
id: "fork".to_string(),
|
||||
event_seq: 0,
|
||||
msg: codex_core::protocol::EventMsg::ReplayHistory(
|
||||
codex_core::protocol::ReplayHistoryEvent { items: prefix_items }
|
||||
codex_core::protocol::ReplayHistoryEvent {
|
||||
items: prefix_items,
|
||||
events: Vec::new(),
|
||||
}
|
||||
),
|
||||
order: None,
|
||||
};
|
||||
@@ -1749,16 +1752,14 @@ impl App<'_> {
|
||||
}
|
||||
|
||||
fn should_show_onboarding(
|
||||
_login_status: crate::LoginStatus,
|
||||
login_status: crate::LoginStatus,
|
||||
_config: &Config,
|
||||
show_trust_screen: bool,
|
||||
) -> bool {
|
||||
if show_trust_screen {
|
||||
return true;
|
||||
}
|
||||
// Defer login screen visibility decision to onboarding screen logic.
|
||||
// Here we only gate on trust flow.
|
||||
false
|
||||
matches!(login_status, crate::LoginStatus::NotAuthenticated)
|
||||
}
|
||||
|
||||
fn should_show_login_screen(login_status: crate::LoginStatus, _config: &Config) -> bool {
|
||||
|
||||
@@ -179,6 +179,25 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the input starts with a slash command and the cursor
|
||||
/// is positioned within the command head (i.e., before the first
|
||||
/// whitespace on the first line). Used to decide whether to keep the
|
||||
/// slash-command popup active and to suppress file completion.
|
||||
fn is_cursor_in_slash_command_head(&self) -> bool {
|
||||
let text = self.textarea.text();
|
||||
if text.is_empty() { return false; }
|
||||
let cursor = self.textarea.cursor();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let first_line = &text[..first_line_end];
|
||||
if !first_line.starts_with('/') { return false; }
|
||||
let head_end = first_line
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(first_line_end);
|
||||
cursor <= head_end
|
||||
}
|
||||
|
||||
pub fn set_has_chat_history(&mut self, has_history: bool) {
|
||||
self.has_chat_history = has_history;
|
||||
}
|
||||
@@ -1168,11 +1187,10 @@ impl ChatComposer {
|
||||
// Tab-press file search when not using @ or ./ and not in slash cmd
|
||||
// -------------------------------------------------------------
|
||||
KeyEvent { code: KeyCode::Tab, .. } => {
|
||||
// Do not trigger if composing a slash command
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
let starts_with_slash_cmd = first_line.trim_start().starts_with('/');
|
||||
|
||||
if starts_with_slash_cmd {
|
||||
// Suppress Tab completion only while the cursor is within the
|
||||
// slash command head (before the first space). Allow Tab-based
|
||||
// file search in the arguments of /plan, /solve, etc.
|
||||
if self.is_cursor_in_slash_command_head() {
|
||||
return (InputResult::None, false);
|
||||
}
|
||||
|
||||
@@ -1383,16 +1401,20 @@ impl ChatComposer {
|
||||
fn sync_command_popup(&mut self) {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
let input_starts_with_slash = first_line.starts_with('/');
|
||||
// Keep the slash popup only while the cursor is within the command head
|
||||
// (before the first space). This allows @-file completion for arguments
|
||||
// in commands like "/plan" and "/solve".
|
||||
let in_slash_head = self.is_cursor_in_slash_command_head();
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
if input_starts_with_slash {
|
||||
if input_starts_with_slash && in_slash_head {
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else {
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_slash {
|
||||
if input_starts_with_slash && in_slash_head {
|
||||
let mut command_popup = CommandPopup::new_with_filter(self.using_chatgpt_auth);
|
||||
// Load saved subagent commands to include in autocomplete (exclude built-ins)
|
||||
if let Ok(cfg) = codex_core::config::Config::load_with_cli_overrides(vec![], codex_core::config::ConfigOverrides::default()) {
|
||||
|
||||
+288
-65
@@ -9,6 +9,7 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -81,6 +82,7 @@ use ratatui::widgets::WidgetRef;
|
||||
use ratatui_image::picker::Picker;
|
||||
use std::cell::RefCell;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use tracing::info;
|
||||
// use image::GenericImageView;
|
||||
@@ -113,6 +115,7 @@ use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_browser::BrowserManager;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::resolve_codex_path_for_read;
|
||||
use codex_core::config::set_github_check_on_push;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -140,7 +143,7 @@ struct CachedConnection {
|
||||
|
||||
async fn read_cached_connection() -> Option<(Option<u16>, Option<String>)> {
|
||||
let codex_home = find_codex_home().ok()?;
|
||||
let path = codex_home.join("cache.json");
|
||||
let path = resolve_codex_path_for_read(&codex_home, std::path::Path::new("cache.json"));
|
||||
let bytes = tokio::fs::read(path).await.ok()?;
|
||||
let parsed: CachedConnection = serde_json::from_slice(&bytes).ok()?;
|
||||
Some((parsed.port, parsed.ws))
|
||||
@@ -219,6 +222,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
agent_task: Option<String>,
|
||||
overall_task_status: String,
|
||||
active_plan_title: Option<String>,
|
||||
/// Runtime timing per-agent (by id) to improve visibility in the HUD
|
||||
agent_runtime: HashMap<String, AgentRuntime>,
|
||||
// Sparkline data for showing agent activity (using RefCell for interior mutability)
|
||||
// Each tuple is (value, is_completed) where is_completed indicates if any agent was complete at that time
|
||||
sparkline_data: std::cell::RefCell<Vec<(u64, bool)>>,
|
||||
@@ -342,6 +347,16 @@ struct PendingJumpBack {
|
||||
removed_cells: Vec<Box<dyn HistoryCell>>, // cells removed from the end (from selected user message onward)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct AgentRuntime {
|
||||
/// First time this agent entered Running
|
||||
started_at: Option<Instant>,
|
||||
/// Time of the latest status update we observed
|
||||
last_update: Option<Instant>,
|
||||
/// Time the agent reached a terminal state (Completed/Failed)
|
||||
completed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
// ---------- Stable ordering & routing helpers ----------
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OrderKey {
|
||||
@@ -386,10 +401,19 @@ use self::perf::PerfStats;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AgentInfo {
|
||||
// Stable id to correlate updates
|
||||
id: String,
|
||||
// Display name
|
||||
name: String,
|
||||
// Current status
|
||||
status: AgentStatus,
|
||||
// Optional model name
|
||||
model: Option<String>,
|
||||
// Final success message when completed
|
||||
result: Option<String>,
|
||||
// Final error message when failed
|
||||
error: Option<String>,
|
||||
// Most recent progress line from core
|
||||
last_progress: Option<String>,
|
||||
}
|
||||
|
||||
@@ -512,6 +536,19 @@ enum SystemPlacement {
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
fn fmt_short_duration(&self, d: Duration) -> String {
|
||||
let s = d.as_secs();
|
||||
let h = s / 3600;
|
||||
let m = (s % 3600) / 60;
|
||||
let sec = s % 60;
|
||||
if h > 0 {
|
||||
format!("{}h{}m", h, m)
|
||||
} else if m > 0 {
|
||||
format!("{}m{}s", m, sec)
|
||||
} else {
|
||||
format!("{}s", sec)
|
||||
}
|
||||
}
|
||||
fn is_branch_worktree_path(path: &std::path::Path) -> bool {
|
||||
for ancestor in path.ancestors() {
|
||||
if ancestor
|
||||
@@ -1142,37 +1179,30 @@ impl ChatWidget<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Show internal system status messages (rendered with markdown) so
|
||||
// code blocks and formatting are consistent with assistant output.
|
||||
if text.contains("== System Status ==") {
|
||||
use ratatui::text::Line as RLine;
|
||||
let mut lines: Vec<RLine<'static>> = Vec::new();
|
||||
crate::markdown::append_markdown(&text, &mut lines, &self.config);
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global(
|
||||
Box::new(crate::history_cell::PlainHistoryCell {
|
||||
lines,
|
||||
kind: crate::history_cell::HistoryCellType::Notice,
|
||||
}),
|
||||
key,
|
||||
);
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if text.starts_with("== System Status ==") {
|
||||
return;
|
||||
}
|
||||
if role == "assistant" {
|
||||
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
|
||||
crate::markdown::append_markdown(text, &mut lines, &self.config);
|
||||
self.insert_final_answer_with_id(None, lines, text.to_string());
|
||||
return;
|
||||
}
|
||||
if role == "user" {
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global(
|
||||
Box::new(crate::history_cell::new_user_prompt(text)),
|
||||
Box::new(crate::history_cell::new_user_prompt(text.to_string())),
|
||||
key,
|
||||
);
|
||||
} else {
|
||||
// Build a PlainHistoryCell with Assistant kind; header line hidden by renderer
|
||||
use crate::history_cell::HistoryCellType;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
let mut lines = Vec::new();
|
||||
lines.push(ratatui::text::Line::from("assistant"));
|
||||
for l in text.lines() {
|
||||
lines.push(ratatui::text::Line::from(l.to_string()));
|
||||
}
|
||||
crate::markdown::append_markdown(text, &mut lines, &self.config);
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global(
|
||||
Box::new(PlainHistoryCell {
|
||||
@@ -1183,6 +1213,24 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCall { name, arguments, call_id, .. } => {
|
||||
let pretty_args = serde_json::from_str::<JsonValue>(&arguments)
|
||||
.and_then(|v| serde_json::to_string_pretty(&v))
|
||||
.unwrap_or_else(|_| arguments.clone());
|
||||
let mut message = format!("🔧 Tool call: {}", name);
|
||||
if !pretty_args.trim().is_empty() {
|
||||
message.push_str("\n");
|
||||
message.push_str(&pretty_args);
|
||||
}
|
||||
if !call_id.is_empty() {
|
||||
message.push_str(&format!("\ncall_id: {}", call_id));
|
||||
}
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global(
|
||||
Box::new(crate::history_cell::new_background_event(message)),
|
||||
key,
|
||||
);
|
||||
}
|
||||
ResponseItem::Reasoning { summary, .. } => {
|
||||
for s in summary {
|
||||
let codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { text } =
|
||||
@@ -1198,17 +1246,50 @@ impl ChatWidget<'_> {
|
||||
.finalize(crate::streaming::StreamKind::Reasoning, true, &sink);
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||
// Try to unwrap common JSON wrapper {"output": "...", ...}
|
||||
let mut content = output.content;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
ResponseItem::FunctionCallOutput { output, call_id, .. } => {
|
||||
let mut content = output.content.clone();
|
||||
let mut metadata_summary = String::new();
|
||||
if let Ok(v) = serde_json::from_str::<JsonValue>(&content) {
|
||||
if let Some(s) = v.get("output").and_then(|x| x.as_str()) {
|
||||
content = s.to_string();
|
||||
}
|
||||
if let Some(meta) = v.get("metadata").and_then(|m| m.as_object()) {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(code) = meta.get("exit_code").and_then(|x| x.as_i64()) {
|
||||
parts.push(format!("exit_code={}", code));
|
||||
}
|
||||
if let Some(duration) =
|
||||
meta.get("duration_seconds").and_then(|x| x.as_f64())
|
||||
{
|
||||
parts.push(format!("duration={:.2}s", duration));
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
metadata_summary = parts.join(", ");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut message = String::new();
|
||||
if !content.trim().is_empty() {
|
||||
message.push_str(content.trim_end());
|
||||
}
|
||||
if !metadata_summary.is_empty() {
|
||||
if !message.is_empty() {
|
||||
message.push_str("\n\n");
|
||||
}
|
||||
message.push_str(&format!("({})", metadata_summary));
|
||||
}
|
||||
if !call_id.is_empty() {
|
||||
if !message.is_empty() {
|
||||
message.push_str("\n");
|
||||
}
|
||||
message.push_str(&format!("call_id: {}", call_id));
|
||||
}
|
||||
if message.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = self.next_internal_key();
|
||||
let _ = self.history_insert_with_key_global(
|
||||
Box::new(crate::history_cell::new_background_event(content)),
|
||||
Box::new(crate::history_cell::new_background_event(message)),
|
||||
key,
|
||||
);
|
||||
}
|
||||
@@ -1792,15 +1873,25 @@ impl ChatWidget<'_> {
|
||||
let config_for_agent_loop = config.clone();
|
||||
tokio::spawn(async move {
|
||||
// Use ConversationManager with an AuthManager (API key by default)
|
||||
let conversation_manager = ConversationManager::new(AuthManager::shared(
|
||||
let auth_manager = AuthManager::shared(
|
||||
config_for_agent_loop.codex_home.clone(),
|
||||
AuthMode::ApiKey,
|
||||
config_for_agent_loop.responses_originator_header.clone(),
|
||||
));
|
||||
let new_conversation = match conversation_manager
|
||||
.new_conversation(config_for_agent_loop)
|
||||
.await
|
||||
{
|
||||
);
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone());
|
||||
let resume_path = config_for_agent_loop.experimental_resume.clone();
|
||||
let new_conversation = match resume_path {
|
||||
Some(path) => conversation_manager
|
||||
.resume_conversation_from_rollout(
|
||||
config_for_agent_loop,
|
||||
path,
|
||||
auth_manager,
|
||||
)
|
||||
.await,
|
||||
None => conversation_manager.new_conversation(config_for_agent_loop).await,
|
||||
};
|
||||
|
||||
let new_conversation = match new_conversation {
|
||||
Ok(conv) => conv,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to initialize conversation: {e}");
|
||||
@@ -1901,6 +1992,7 @@ impl ChatWidget<'_> {
|
||||
agent_task: None,
|
||||
overall_task_status: "preparing".to_string(),
|
||||
active_plan_title: None,
|
||||
agent_runtime: HashMap::new(),
|
||||
sparkline_data: std::cell::RefCell::new(Vec::new()),
|
||||
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
|
||||
stream: crate::streaming::controller::StreamController::new(config.clone()),
|
||||
@@ -1986,16 +2078,20 @@ impl ChatWidget<'_> {
|
||||
// don't wait for MCP initialization to finish.
|
||||
let mut w = new_widget;
|
||||
w.standard_terminal_mode = !config.tui.alternate_screen;
|
||||
w.history_push_top_next_req(history_cell::new_animated_welcome()); // tag: prelude
|
||||
let connecting_mcp = !w.config.mcp_servers.is_empty();
|
||||
w.history_push_top_next_req(history_cell::new_popular_commands_notice(false)); // tag: prelude
|
||||
if connecting_mcp {
|
||||
// Render connecting status as a separate cell with standard gutter and spacing
|
||||
w.history_push_top_next_req(history_cell::new_connecting_mcp_status());
|
||||
if config.experimental_resume.is_none() {
|
||||
w.history_push_top_next_req(history_cell::new_animated_welcome()); // tag: prelude
|
||||
let connecting_mcp = !w.config.mcp_servers.is_empty();
|
||||
w.history_push_top_next_req(history_cell::new_popular_commands_notice(false)); // tag: prelude
|
||||
if connecting_mcp {
|
||||
// Render connecting status as a separate cell with standard gutter and spacing
|
||||
w.history_push_top_next_req(history_cell::new_connecting_mcp_status());
|
||||
}
|
||||
// Mark welcome as shown to avoid duplicating the Popular commands section
|
||||
// when SessionConfigured arrives shortly after.
|
||||
w.welcome_shown = true;
|
||||
} else {
|
||||
w.welcome_shown = true;
|
||||
}
|
||||
// Mark welcome as shown to avoid duplicating the Popular commands section
|
||||
// when SessionConfigured arrives shortly after.
|
||||
w.welcome_shown = true;
|
||||
w
|
||||
}
|
||||
|
||||
@@ -2083,6 +2179,7 @@ impl ChatWidget<'_> {
|
||||
agent_task: None,
|
||||
overall_task_status: "preparing".to_string(),
|
||||
active_plan_title: None,
|
||||
agent_runtime: HashMap::new(),
|
||||
sparkline_data: std::cell::RefCell::new(Vec::new()),
|
||||
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
|
||||
stream: crate::streaming::controller::StreamController::new(config.clone()),
|
||||
@@ -3448,9 +3545,37 @@ impl ChatWidget<'_> {
|
||||
self.maybe_hide_spinner();
|
||||
}
|
||||
EventMsg::ReplayHistory(ev) => {
|
||||
// Render prior transcript items statically without executing tools
|
||||
for item in ev.items {
|
||||
self.render_replay_item(item);
|
||||
let codex_core::protocol::ReplayHistoryEvent { items, events } = ev;
|
||||
let mut max_req = self.last_seen_request_index;
|
||||
if events.is_empty() {
|
||||
for item in &items {
|
||||
self.render_replay_item(item.clone());
|
||||
}
|
||||
} else {
|
||||
for recorded in events {
|
||||
if matches!(recorded.msg, EventMsg::ReplayHistory(_)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(order) = recorded.order.as_ref() {
|
||||
max_req = max_req.max(order.request_ordinal);
|
||||
}
|
||||
let event = Event {
|
||||
id: recorded.id,
|
||||
event_seq: recorded.event_seq,
|
||||
msg: recorded.msg,
|
||||
order: recorded.order,
|
||||
};
|
||||
self.handle_codex_event(event);
|
||||
}
|
||||
}
|
||||
if !items.is_empty() {
|
||||
// History items were inserted using synthetic keys; promote current request
|
||||
// index so subsequent messages append to the end instead of the top.
|
||||
self.last_seen_request_index = self.last_seen_request_index.max(self.current_request_index);
|
||||
}
|
||||
if max_req > 0 {
|
||||
self.last_seen_request_index = self.last_seen_request_index.max(max_req);
|
||||
self.current_request_index = self.last_seen_request_index;
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -4381,15 +4506,34 @@ impl ChatWidget<'_> {
|
||||
.update_status_text("using browser (CDP)".to_string());
|
||||
}
|
||||
}
|
||||
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
|
||||
agents,
|
||||
context,
|
||||
task,
|
||||
}) => {
|
||||
// Update the active agents list from the event
|
||||
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents, context, task }) => {
|
||||
// Update the active agents list from the event and track timing
|
||||
self.active_agents.clear();
|
||||
let now = Instant::now();
|
||||
for agent in agents {
|
||||
// Update runtime map
|
||||
let entry = self
|
||||
.agent_runtime
|
||||
.entry(agent.id.clone())
|
||||
.or_insert_with(AgentRuntime::default);
|
||||
entry.last_update = Some(now);
|
||||
match agent.status.as_str() {
|
||||
"running" => {
|
||||
if entry.started_at.is_none() {
|
||||
entry.started_at = Some(now);
|
||||
}
|
||||
}
|
||||
"completed" | "failed" => {
|
||||
if entry.completed_at.is_none() {
|
||||
entry.completed_at = entry.completed_at.or(Some(now));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Mirror agent list for rendering
|
||||
self.active_agents.push(AgentInfo {
|
||||
id: agent.id.clone(),
|
||||
name: agent.name.clone(),
|
||||
status: match agent.status.as_str() {
|
||||
"pending" => AgentStatus::Pending,
|
||||
@@ -4398,6 +4542,7 @@ impl ChatWidget<'_> {
|
||||
"failed" => AgentStatus::Failed,
|
||||
_ => AgentStatus::Pending,
|
||||
},
|
||||
model: agent.model,
|
||||
result: agent.result,
|
||||
error: agent.error,
|
||||
last_progress: agent.last_progress,
|
||||
@@ -10931,13 +11076,26 @@ impl ChatWidget<'_> {
|
||||
} else {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for a in self.active_agents.iter().take(3) {
|
||||
let s = match a.status {
|
||||
AgentStatus::Pending => "pending",
|
||||
AgentStatus::Running => "running",
|
||||
AgentStatus::Completed => "done",
|
||||
AgentStatus::Failed => "failed",
|
||||
let state = match a.status {
|
||||
AgentStatus::Pending => "pending".to_string(),
|
||||
AgentStatus::Running => {
|
||||
// Show elapsed running time when available
|
||||
if let Some(rt) = self.agent_runtime.get(&a.id) {
|
||||
if let Some(start) = rt.started_at {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(start);
|
||||
format!("running {}", self.fmt_short_duration(elapsed))
|
||||
} else {
|
||||
"running".to_string()
|
||||
}
|
||||
} else {
|
||||
"running".to_string()
|
||||
}
|
||||
}
|
||||
AgentStatus::Completed => "done".to_string(),
|
||||
AgentStatus::Failed => "failed".to_string(),
|
||||
};
|
||||
let mut label = format!("{} ({})", a.name, s);
|
||||
let mut label = format!("{} ({})", a.name, state);
|
||||
if matches!(a.status, AgentStatus::Running) {
|
||||
if let Some(lp) = &a.last_progress {
|
||||
let mut lp_trim = lp.trim().to_string();
|
||||
@@ -11231,23 +11389,88 @@ impl ChatWidget<'_> {
|
||||
AgentStatus::Failed => crate::colors::error(),
|
||||
};
|
||||
|
||||
// Build status + timing suffix where available
|
||||
let status_text = match agent.status {
|
||||
AgentStatus::Pending => "pending",
|
||||
AgentStatus::Running => "running",
|
||||
AgentStatus::Completed => "completed",
|
||||
AgentStatus::Failed => "failed",
|
||||
AgentStatus::Pending => "pending".to_string(),
|
||||
AgentStatus::Running => {
|
||||
if let Some(rt) = self.agent_runtime.get(&agent.id) {
|
||||
if let Some(start) = rt.started_at {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(start);
|
||||
format!("running {}", self.fmt_short_duration(elapsed))
|
||||
} else {
|
||||
"running".to_string()
|
||||
}
|
||||
} else {
|
||||
"running".to_string()
|
||||
}
|
||||
}
|
||||
AgentStatus::Completed | AgentStatus::Failed => {
|
||||
if let Some(rt) = self.agent_runtime.get(&agent.id) {
|
||||
if let (Some(start), Some(done)) = (rt.started_at, rt.completed_at) {
|
||||
let dur = done.saturating_duration_since(start);
|
||||
let base = if matches!(agent.status, AgentStatus::Completed) {
|
||||
"completed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
format!("{} {}", base, self.fmt_short_duration(dur))
|
||||
} else {
|
||||
match agent.status {
|
||||
AgentStatus::Completed => "completed".to_string(),
|
||||
AgentStatus::Failed => "failed".to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match agent.status {
|
||||
AgentStatus::Completed => "completed".to_string(),
|
||||
AgentStatus::Failed => "failed".to_string(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
text_content.push(RLine::from(vec![
|
||||
Span::from(" "),
|
||||
let mut line_spans: Vec<Span> = Vec::new();
|
||||
line_spans.push(Span::from(" "));
|
||||
line_spans.push(
|
||||
Span::styled(
|
||||
format!("{}: ", agent.name),
|
||||
format!("{}", agent.name),
|
||||
Style::default()
|
||||
.fg(crate::colors::text())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(status_text, Style::default().fg(status_color)),
|
||||
]));
|
||||
);
|
||||
if let Some(ref model) = agent.model {
|
||||
if !model.is_empty() {
|
||||
line_spans.push(Span::styled(
|
||||
format!(" ({})", model),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
));
|
||||
}
|
||||
}
|
||||
line_spans.push(Span::from(": "));
|
||||
line_spans.push(Span::styled(status_text, Style::default().fg(status_color)));
|
||||
text_content.push(RLine::from(line_spans));
|
||||
|
||||
// For running agents, show latest progress hint if available
|
||||
if matches!(agent.status, AgentStatus::Running) {
|
||||
if let Some(ref lp) = agent.last_progress {
|
||||
let mut lp_trim = lp.trim().to_string();
|
||||
if lp_trim.len() > 120 {
|
||||
lp_trim.truncate(120);
|
||||
lp_trim.push('…');
|
||||
}
|
||||
text_content.push(RLine::from(vec![
|
||||
Span::from(" "),
|
||||
Span::styled(
|
||||
lp_trim,
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
// For completed/failed agents, show their final message or error
|
||||
match agent.status {
|
||||
|
||||
@@ -571,7 +571,11 @@ pub(super) fn handle_exec_end_now(
|
||||
let action = history_cell::action_enum_from_parsed(&parsed);
|
||||
let status = match (exit_code, action) {
|
||||
(0, _) => history_cell::ExploreEntryStatus::Success,
|
||||
// No matches for searches
|
||||
(1, history_cell::ExecAction::Search) => history_cell::ExploreEntryStatus::NotFound,
|
||||
// Missing file/dir for list operations (e.g., ls path)
|
||||
(1, history_cell::ExecAction::List) => history_cell::ExploreEntryStatus::NotFound,
|
||||
// Anything else is an error; preserve exit code
|
||||
_ => history_cell::ExploreEntryStatus::Error {
|
||||
exit_code: Some(exit_code),
|
||||
},
|
||||
@@ -599,9 +603,16 @@ pub(super) fn handle_exec_end_now(
|
||||
history_cell::ExecAction::Read => "files read".to_string(),
|
||||
_ => "exploration updated".to_string(),
|
||||
},
|
||||
history_cell::ExploreEntryStatus::NotFound => "no matches found".to_string(),
|
||||
history_cell::ExploreEntryStatus::NotFound => match action {
|
||||
history_cell::ExecAction::List => "path not found".to_string(),
|
||||
_ => "no matches found".to_string(),
|
||||
},
|
||||
history_cell::ExploreEntryStatus::Error { .. } => match action {
|
||||
history_cell::ExecAction::Read => format!("read failed (exit {exit_code})"),
|
||||
history_cell::ExecAction::Search => {
|
||||
if exit_code == 2 { "invalid pattern".to_string() } else { format!("search failed (exit {exit_code})") }
|
||||
}
|
||||
history_cell::ExecAction::List => format!("list failed (exit {exit_code})"),
|
||||
_ => format!("exploration failed (exit {exit_code})"),
|
||||
},
|
||||
history_cell::ExploreEntryStatus::Running => "exploring…".to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_protocol::models::{ContentItem, ResponseItem};
|
||||
use codex_protocol::protocol::{RolloutItem, RolloutLine};
|
||||
use codex_core::Cursor;
|
||||
use codex_core::RolloutRecorder;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use tokio::runtime::{Builder, Handle};
|
||||
|
||||
/// One candidate session for the picker
|
||||
pub struct ResumeCandidate {
|
||||
@@ -17,17 +24,32 @@ pub struct ResumeCandidate {
|
||||
|
||||
// No fallback scan: meta parsing for rollout headers no longer needed here.
|
||||
|
||||
/// Return rollout files under ~/.codex/sessions matching the provided cwd.
|
||||
/// Return rollout files under ~/.code/sessions matching the provided cwd (Code
|
||||
/// still reads legacy ~/.codex/sessions directories).
|
||||
/// Reads only the first line of each file to avoid heavy IO.
|
||||
pub fn list_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandidate> {
|
||||
// First: try per-directory index
|
||||
if let Some(mut v) = read_dir_index(codex_home, cwd) {
|
||||
v.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
|
||||
return v.into_iter().take(200).collect();
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_RESULTS: usize = 200;
|
||||
|
||||
let mut by_path: HashMap<PathBuf, ResumeCandidate> = HashMap::new();
|
||||
|
||||
if let Some(index_rows) = read_dir_index(codex_home, cwd) {
|
||||
for row in index_rows {
|
||||
by_path.entry(row.path.clone()).or_insert(row);
|
||||
}
|
||||
}
|
||||
|
||||
// No index found for this directory
|
||||
Vec::new()
|
||||
for row in fallback_scan_sessions_for_cwd(cwd, codex_home) {
|
||||
by_path.entry(row.path.clone()).or_insert(row);
|
||||
}
|
||||
|
||||
let mut results: Vec<ResumeCandidate> = by_path.into_values().collect();
|
||||
results.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
|
||||
if results.len() > MAX_RESULTS {
|
||||
results.truncate(MAX_RESULTS);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -109,3 +131,237 @@ fn super_sanitize_dir_index_path(codex_home: &Path, cwd: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
// Removed fallback slow scan; the fast per-directory index is authoritative.
|
||||
|
||||
fn fallback_scan_sessions_for_cwd(cwd: &Path, codex_home: &Path) -> Vec<ResumeCandidate> {
|
||||
const MAX_RESULTS: usize = 200;
|
||||
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
let cwd = cwd.to_path_buf();
|
||||
|
||||
let fetch = async move {
|
||||
let mut collected: Vec<ResumeCandidate> = Vec::new();
|
||||
let mut cursor: Option<Cursor> = None;
|
||||
while collected.len() < MAX_RESULTS {
|
||||
let page = match RolloutRecorder::list_conversations(codex_home.as_path(), 256, cursor.as_ref()).await {
|
||||
Ok(page) => page,
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to list conversations for resume fallback: {err}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if page.items.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for item in page.items {
|
||||
if let Some(candidate) = parse_rollout_candidate(&item.path, &cwd) {
|
||||
collected.push(candidate);
|
||||
if collected.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if collected.len() >= MAX_RESULTS || page.next_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
}
|
||||
|
||||
collected
|
||||
};
|
||||
|
||||
// Execute the async fetch, reusing an existing runtime when available.
|
||||
let mut sessions = match Handle::try_current() {
|
||||
Ok(handle) => {
|
||||
let handle = handle.clone();
|
||||
match thread::spawn(move || handle.block_on(fetch)).join() {
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
tracing::warn!("resume fallback thread panicked while listing conversations");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => match Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt.block_on(fetch),
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to build tokio runtime for resume fallback: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
sessions.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
|
||||
if sessions.len() > MAX_RESULTS {
|
||||
sessions.truncate(MAX_RESULTS);
|
||||
}
|
||||
sessions
|
||||
}
|
||||
|
||||
fn parse_rollout_candidate(path: &Path, target_cwd: &Path) -> Option<ResumeCandidate> {
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut created_ts: Option<String> = None;
|
||||
let mut modified_ts: Option<String> = None;
|
||||
let mut message_count: usize = 0;
|
||||
let mut branch: Option<String> = None;
|
||||
let mut last_user_snippet: Option<String> = None;
|
||||
let mut instructions: Option<String> = None;
|
||||
let mut path_checked = false;
|
||||
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { continue };
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: RolloutLine = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
modified_ts = Some(parsed.timestamp.clone());
|
||||
match parsed.item {
|
||||
RolloutItem::SessionMeta(meta_line) => {
|
||||
if !path_checked {
|
||||
path_checked = true;
|
||||
if !paths_match(&meta_line.meta.cwd, target_cwd) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if created_ts.is_none() {
|
||||
created_ts = Some(meta_line.meta.timestamp.clone());
|
||||
}
|
||||
instructions = instructions.or_else(|| meta_line.meta.instructions.clone());
|
||||
if branch.is_none() {
|
||||
if let Some(git) = &meta_line.git {
|
||||
if let Some(b) = &git.branch {
|
||||
if !b.is_empty() {
|
||||
branch = Some(b.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(response_item) => {
|
||||
message_count = message_count.saturating_add(1);
|
||||
if let Some(snippet) = extract_user_snippet_from_response(&response_item) {
|
||||
last_user_snippet = Some(snippet);
|
||||
}
|
||||
}
|
||||
RolloutItem::Event(recorded) => {
|
||||
if let Some(event) = codex_core::protocol::event_msg_from_protocol(&recorded.msg) {
|
||||
if let Some(snippet) = extract_user_snippet_from_event(&event) {
|
||||
last_user_snippet = Some(snippet);
|
||||
}
|
||||
}
|
||||
}
|
||||
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !path_checked {
|
||||
return None;
|
||||
}
|
||||
|
||||
if message_count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modified_ts = modified_ts.or_else(|| created_ts.clone());
|
||||
let snippet = last_user_snippet.or_else(|| instructions.clone());
|
||||
|
||||
Some(ResumeCandidate {
|
||||
path: path.to_path_buf(),
|
||||
subtitle: snippet.clone(),
|
||||
sort_key: modified_ts.clone().unwrap_or_default(),
|
||||
created_ts,
|
||||
modified_ts,
|
||||
message_count,
|
||||
branch,
|
||||
snippet,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_user_snippet_from_response(item: &ResponseItem) -> Option<String> {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => content
|
||||
.iter()
|
||||
.find_map(|c| match c {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() || trimmed.contains("== System Status ==") {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_user_snippet_from_event(event: &EventMsg) -> Option<String> {
|
||||
match event {
|
||||
EventMsg::UserMessage(ev) => {
|
||||
let trimmed = ev.message.trim();
|
||||
if trimmed.is_empty() || trimmed.contains("== System Status ==") {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn paths_match(meta: &Path, target: &Path) -> bool {
|
||||
if meta == target {
|
||||
return true;
|
||||
}
|
||||
|
||||
let meta_norm = normalize_path(meta);
|
||||
let target_norm = normalize_path(target);
|
||||
if meta_norm == target_norm || meta_norm.eq_ignore_ascii_case(&target_norm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let (Ok(meta_real), Ok(target_real)) = (fs::canonicalize(meta), fs::canonicalize(target)) {
|
||||
let meta_real_norm = normalize_path(&meta_real);
|
||||
let target_real_norm = normalize_path(&target_real);
|
||||
if meta_real_norm == target_real_norm
|
||||
|| meta_real_norm.eq_ignore_ascii_case(&target_real_norm)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If both paths belong to the same Git project (including worktrees) consider them equivalent.
|
||||
if let (Some(meta_root), Some(target_root)) = (
|
||||
codex_core::git_info::resolve_root_git_project_for_trust(&meta_real)
|
||||
.and_then(|p| fs::canonicalize(p).ok()),
|
||||
codex_core::git_info::resolve_root_git_project_for_trust(&target_real)
|
||||
.and_then(|p| fs::canonicalize(p).ok()),
|
||||
) {
|
||||
let meta_root_norm = normalize_path(&meta_root);
|
||||
let target_root_norm = normalize_path(&target_root);
|
||||
if meta_root_norm == target_root_norm
|
||||
|| meta_root_norm.eq_ignore_ascii_case(&target_root_norm)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
let mut s = path.to_string_lossy().replace('\\', "/");
|
||||
while s.ends_with('/') && s.len() > 1 {
|
||||
s.pop();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::env;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
@@ -317,6 +318,9 @@ pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui:
|
||||
}
|
||||
|
||||
fn enable_alternate_scroll_mode() -> Result<()> {
|
||||
if !should_enable_alternate_scroll_mode() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut handle = stdout();
|
||||
handle.write_all(b"\x1b[?1007h")?;
|
||||
handle.flush()?;
|
||||
@@ -324,12 +328,21 @@ fn enable_alternate_scroll_mode() -> Result<()> {
|
||||
}
|
||||
|
||||
fn disable_alternate_scroll_mode() -> Result<()> {
|
||||
if !should_enable_alternate_scroll_mode() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut handle = stdout();
|
||||
handle.write_all(b"\x1b[?1007l")?;
|
||||
handle.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_enable_alternate_scroll_mode() -> bool {
|
||||
// macOS Terminal hijacks scrolling when 1007h is set without also enabling
|
||||
// mouse reporting, so skip the escape in that environment.
|
||||
!matches!(env::var("TERM_PROGRAM"), Ok(value) if value.eq_ignore_ascii_case("Apple_Terminal"))
|
||||
}
|
||||
|
||||
/// Clear the current screen (normal buffer) with the theme background and reset cursor.
|
||||
// Removed: clear_screen_with_theme — we no longer hard-clear the normal buffer in terminal mode.
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::config::resolve_codex_path_for_read;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::create_client;
|
||||
|
||||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
let version_file = version_filepath(config);
|
||||
let info = read_version_info(&version_file).ok();
|
||||
let read_path = resolve_codex_path_for_read(&config.codex_home, Path::new(VERSION_FILENAME));
|
||||
let info = read_version_info(&read_path).ok();
|
||||
let originator = config.responses_originator_header.clone();
|
||||
|
||||
// Always refresh the cached latest version in the background so TUI startup
|
||||
|
||||
+3
-3
@@ -50,10 +50,10 @@ Notes:
|
||||
|
||||
Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior.
|
||||
|
||||
The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written:
|
||||
The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `~/.code/log/codex-tui.log` (Code still reads the legacy `~/.codex/log/` path), so you can leave the following running in a separate terminal to monitor log messages as they are written:
|
||||
|
||||
```
|
||||
tail -F ~/.codex/log/codex-tui.log
|
||||
tail -F ~/.code/log/codex-tui.log
|
||||
```
|
||||
|
||||
By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file.
|
||||
@@ -62,7 +62,7 @@ See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env
|
||||
|
||||
## Model Context Protocol (MCP)
|
||||
|
||||
The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./config.md#mcp_servers) section in `~/.codex/config.toml`. It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.:
|
||||
The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./config.md#mcp_servers) section in `~/.code/config.toml` (Code will also read a legacy `~/.codex/config.toml`). It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.:
|
||||
|
||||
```toml
|
||||
# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`.
|
||||
|
||||
@@ -15,7 +15,7 @@ This key must, at minimum, have write access to the Responses API.
|
||||
If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps:
|
||||
|
||||
1. Update the CLI and ensure `codex --version` is `0.20.0` or later
|
||||
2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`)
|
||||
2. Delete `~/.code/auth.json` (and remove the legacy `~/.codex/auth.json` if it exists; on Windows these live under `C:\\Users\\USERNAME\\.code\\auth.json` and `C:\\Users\\USERNAME\\.codex\\auth.json`)
|
||||
3. Run `codex login` again
|
||||
|
||||
## Forcing a specific auth method (advanced)
|
||||
@@ -25,7 +25,7 @@ You can explicitly choose which authentication Codex should prefer when both are
|
||||
- To always use your API key (even when ChatGPT auth exists), set:
|
||||
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
# ~/.code/config.toml (Code also reads legacy ~/.codex/config.toml)
|
||||
preferred_auth_method = "apikey"
|
||||
```
|
||||
|
||||
@@ -38,7 +38,7 @@ codex --config preferred_auth_method="apikey"
|
||||
- To prefer ChatGPT auth (default), set:
|
||||
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
# ~/.code/config.toml (Code also reads legacy ~/.codex/config.toml)
|
||||
preferred_auth_method = "chatgpt"
|
||||
```
|
||||
|
||||
@@ -71,28 +71,28 @@ Today, the login process entails running a server on `localhost:1455`. If you ar
|
||||
|
||||
### Authenticate locally and copy your credentials to the "headless" machine
|
||||
|
||||
The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\\.codex`).
|
||||
The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODE_HOME/auth.json` (defaults to `~/.code/auth.json`; Code will still read `$CODEX_HOME`/`~/.codex/auth.json` if present).
|
||||
|
||||
Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do:
|
||||
|
||||
```shell
|
||||
# substitute MY_CONTAINER with the name or id of your Docker container:
|
||||
CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME)
|
||||
docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex"
|
||||
docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json"
|
||||
docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.code"
|
||||
docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.code/auth.json"
|
||||
```
|
||||
|
||||
whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol):
|
||||
|
||||
```shell
|
||||
ssh user@remote 'mkdir -p ~/.codex'
|
||||
scp ~/.codex/auth.json user@remote:~/.codex/auth.json
|
||||
ssh user@remote 'mkdir -p ~/.code'
|
||||
scp ~/.code/auth.json user@remote:~/.code/auth.json
|
||||
```
|
||||
|
||||
or try this one-liner:
|
||||
|
||||
```shell
|
||||
ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json
|
||||
ssh user@remote 'mkdir -p ~/.code && cat > ~/.code/auth.json' < ~/.code/auth.json
|
||||
```
|
||||
|
||||
### Connecting through VPS or remote
|
||||
|
||||
+19
-14
@@ -12,7 +12,7 @@ Codex supports several mechanisms for setting config values:
|
||||
- If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that `-c model='"o3"'` and `-c model=o3` are equivalent.
|
||||
- In the first case, the value is the TOML string `"o3"`, while in the second the value is `o3`, which is not valid TOML and therefore treated as the TOML string `"o3"`.
|
||||
- Because quotes are interpreted by one's shell, `-c key="true"` will be correctly interpreted in TOML as `key = true` (a boolean) and not `key = "true"` (a string). If for some reason you needed the string `"true"`, you would need to use `-c key='"true"'` (note the two sets of quotes).
|
||||
- The `$CODEX_HOME/config.toml` configuration file where the `CODEX_HOME` environment value defaults to `~/.codex`. (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.)
|
||||
- The `$CODE_HOME/config.toml` configuration file. `CODE_HOME` defaults to `~/.code`; Code also reads from `$CODEX_HOME`/`~/.codex` for backwards compatibility but only writes to `~/.code`. (Logs and other state use the same directory.)
|
||||
|
||||
Both the `--config` flag and the `config.toml` file support the following options:
|
||||
|
||||
@@ -69,17 +69,6 @@ base_url = "https://api.mistral.ai/v1"
|
||||
env_key = "MISTRAL_API_KEY"
|
||||
```
|
||||
|
||||
Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider:
|
||||
|
||||
```toml
|
||||
[model_providers.azure]
|
||||
name = "Azure"
|
||||
# Make sure you set the appropriate subdomain for this URL.
|
||||
base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
|
||||
env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use.
|
||||
query_params = { api-version = "2025-04-01-preview" }
|
||||
```
|
||||
|
||||
It is also possible to configure a provider to include extra HTTP headers with a request. These can be hardcoded values (`http_headers`) or values read from environment variables (`env_http_headers`):
|
||||
|
||||
```toml
|
||||
@@ -96,6 +85,22 @@ http_headers = { "X-Example-Header" = "example-value" }
|
||||
env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" }
|
||||
```
|
||||
|
||||
### Azure model provider example
|
||||
|
||||
Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider:
|
||||
|
||||
```toml
|
||||
[model_providers.azure]
|
||||
name = "Azure"
|
||||
# Make sure you set the appropriate subdomain for this URL.
|
||||
base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai"
|
||||
env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use.
|
||||
query_params = { api-version = "2025-04-01-preview" }
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
Export your key before launching Codex: `export AZURE_OPENAI_API_KEY=…`
|
||||
|
||||
### Per-provider network tuning
|
||||
|
||||
The following optional settings control retry behaviour and streaming idle timeouts **per model provider**. They must be specified inside the corresponding `[model_providers.<id>]` block in `config.toml`. (Older releases accepted top‑level keys; those are now ignored.)
|
||||
@@ -360,7 +365,7 @@ This config option is comparable to how Claude and Cursor define `mcpServers` in
|
||||
}
|
||||
```
|
||||
|
||||
Should be represented as follows in `~/.codex/config.toml`:
|
||||
Should be represented as follows in `~/.code/config.toml` (Code will also read the legacy `~/.codex/config.toml` if it exists):
|
||||
|
||||
```toml
|
||||
# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`.
|
||||
@@ -513,7 +518,7 @@ if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
To have Codex use this script for notifications, you would configure it via `notify` in `~/.codex/config.toml` using the appropriate path to `notify.py` on your computer:
|
||||
To have Codex use this script for notifications, you would configure it via `notify` in `~/.code/config.toml` (legacy `~/.codex/config.toml` is still read) using the appropriate path to `notify.py` on your computer:
|
||||
|
||||
```toml
|
||||
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
|
||||
|
||||
@@ -44,7 +44,7 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote
|
||||
|
||||
You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down:
|
||||
|
||||
1. `~/.codex/AGENTS.md` - personal global guidance
|
||||
1. `~/.code/AGENTS.md` - personal global guidance (Code will also read a legacy `~/.codex/AGENTS.md` if present)
|
||||
2. `AGENTS.md` at repo root - shared project notes
|
||||
3. `AGENTS.md` in the current working directory - sub-folder/feature specifics
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Save frequently used prompts as Markdown files and reuse them quickly from the slash menu.
|
||||
|
||||
- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`).
|
||||
- Location: Put files in `$CODE_HOME/prompts/` (defaults to `~/.code/prompts/`; Code also reads legacy `~/.codex/prompts/`).
|
||||
- File type: Only Markdown files with the `.md` extension are recognized.
|
||||
- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`.
|
||||
- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter.
|
||||
|
||||
@@ -23,14 +23,7 @@ When the workflow finishes, the GitHub Release is "done," but you still have to
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
After the GitHub Release is done, you can publish to npm. Note the GitHub Release includes the appropriate artifact for npm (which is the output of `npm pack`), which should be named `codex-npm-VERSION.tgz`. To publish to npm, run:
|
||||
|
||||
```
|
||||
VERSION=0.21.0
|
||||
./scripts/publish_to_npm.py "$VERSION"
|
||||
```
|
||||
|
||||
Note that you must have permissions to publish to https://www.npmjs.com/package/@openai/codex for this to succeed.
|
||||
The GitHub Action is responsible for publishing to npm.
|
||||
|
||||
## Publishing to Homebrew
|
||||
|
||||
|
||||
+2
-2
@@ -6,10 +6,10 @@ Codex CLI **does** support OpenAI organizations with [Zero Data Retention (ZDR)]
|
||||
OpenAI rejected the request. Error details: Status: 400, Code: unsupported_parameter, Type: invalid_request_error, Message: 400 Previous response cannot be used for this organization due to Zero Data Retention.
|
||||
```
|
||||
|
||||
Ensure you are running `codex` with `--config disable_response_storage=true` or add this line to `~/.codex/config.toml` to avoid specifying the command line option each time:
|
||||
Ensure you are running `codex` with `--config disable_response_storage=true` or add this line to `~/.code/config.toml` (Code still reads legacy `~/.codex/config.toml`) to avoid specifying the command line option each time:
|
||||
|
||||
```toml
|
||||
disable_response_storage = true
|
||||
```
|
||||
|
||||
See [the configuration documentation on `disable_response_storage`](./config.md#disable_response_storage) for details.
|
||||
See [the configuration documentation on `disable_response_storage`](./config.md#disable_response_storage) for details.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
## @just-every/code v0.2.151
|
||||
## @just-every/code v0.2.155
|
||||
|
||||
This release refines TUI history messaging and improves parameter input robustness.
|
||||
This release includes small fixes and improvements across auth, TUI, and docs.
|
||||
|
||||
### Changes
|
||||
- TUI/History: append merge completion banner for clearer post-merge status.
|
||||
- TUI: add intersection checks for parameter inputs in AgentEditorView.
|
||||
- Auth: fix onboarding auth prompt gating.
|
||||
- CLI: add long-run calculator script.
|
||||
- TUI: add pulldown-cmark dependency to fix build.
|
||||
- Docs: clarify config directories.
|
||||
|
||||
### Install
|
||||
```
|
||||
@@ -12,4 +14,5 @@ npm install -g @just-every/code@latest
|
||||
code
|
||||
```
|
||||
|
||||
Compare: https://github.com/just-every/code/compare/v0.2.150...v0.2.151
|
||||
Compare: https://github.com/just-every/code/compare/v0.2.154...v0.2.155
|
||||
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Helper script that performs sustained heavy math for roughly one minute."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
def _positive_int(env_value: str, default: int) -> int:
|
||||
"""Parse a positive integer from the environment or fall back to default."""
|
||||
try:
|
||||
parsed = int(env_value)
|
||||
except ValueError:
|
||||
return default
|
||||
return parsed if parsed > 0 else default
|
||||
|
||||
|
||||
def _heavy_transform(seed: int, scale: int) -> int:
|
||||
"""Iteratively mix the seed with modular multiplications to grow large numbers."""
|
||||
modulus = (1 << 1024) - 109 # large odd modulus guarantees big intermediates
|
||||
value = seed or 1
|
||||
base = 65537 + (scale * 17)
|
||||
for counter in range(1, scale + 1):
|
||||
value = (value * (base + counter)) % modulus
|
||||
value ^= (value >> 7)
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
target_seconds = _positive_int(os.getenv("LONG_RUN_TOTAL_SECONDS", "60"), 60)
|
||||
interval_seconds = _positive_int(os.getenv("LONG_RUN_INTERVAL_SECONDS", "5"), 5)
|
||||
work_scale = _positive_int(os.getenv("LONG_RUN_WORK_SCALE", "250000"), 250000)
|
||||
|
||||
steps = max(1, target_seconds // interval_seconds)
|
||||
if steps * interval_seconds < target_seconds:
|
||||
steps += 1
|
||||
|
||||
start = time.perf_counter()
|
||||
accumulator = 1
|
||||
|
||||
for step in range(1, steps + 1):
|
||||
chunk_scale = work_scale + (step * 257)
|
||||
chunk_start = time.perf_counter()
|
||||
accumulator = _heavy_transform(accumulator, chunk_scale)
|
||||
compute_elapsed = time.perf_counter() - chunk_start
|
||||
total_elapsed = time.perf_counter() - start
|
||||
|
||||
checksum = accumulator & ((1 << 64) - 1)
|
||||
print(
|
||||
f"[step {step}/{steps}] t={total_elapsed:6.2f}s "
|
||||
f"compute={compute_elapsed:5.2f}s checksum=0x{checksum:016x}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
target_elapsed = step * interval_seconds
|
||||
now = time.perf_counter()
|
||||
remaining = target_elapsed - (now - start)
|
||||
if step < steps and remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
final_digest = pow(accumulator, 5, (1 << 2048) - 159)
|
||||
total_duration = time.perf_counter() - start
|
||||
digest_bits = final_digest.bit_length()
|
||||
|
||||
print(
|
||||
f"Completed in {total_duration:6.2f}s with final digest bit length {digest_bits}.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user