mirror of
https://github.com/openai/codex.git
synced 2026-02-07 01:13:40 +00:00
Compare commits
2 Commits
compact-op
...
models-not
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a07b59dc6 | ||
|
|
23579e2a76 |
24
.github/dotslash-config.json
vendored
24
.github/dotslash-config.json
vendored
@@ -55,30 +55,6 @@
|
||||
"path": "codex-responses-api-proxy.exe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex-command-runner": {
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-command-runner.exe"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-command-runner.exe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex-windows-sandbox-setup": {
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-windows-sandbox-setup.exe"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-windows-sandbox-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
.github/workflows/rust-release-prepare.yml
vendored
52
.github/workflows/rust-release-prepare.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: rust-release-prepare
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */4 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update models.json
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
client_version="99.99.99"
|
||||
terminal_info="github-actions"
|
||||
user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}"
|
||||
base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}"
|
||||
|
||||
headers=(
|
||||
-H "Authorization: Bearer ${OPENAI_API_KEY}"
|
||||
-H "User-Agent: ${user_agent}"
|
||||
)
|
||||
|
||||
url="${base_url%/}/models?client_version=${client_version}"
|
||||
curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json
|
||||
|
||||
- name: Open pull request (if changed)
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: "Update models.json"
|
||||
title: "Update models.json"
|
||||
body: "Automated update of models.json."
|
||||
branch: "bot/update-models-json"
|
||||
reviewers: "pakrym-oai,aibrahim-oai"
|
||||
delete-branch: true
|
||||
path: codex-rs
|
||||
@@ -75,7 +75,6 @@ If you don’t have the tool:
|
||||
|
||||
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
|
||||
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
|
||||
- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above.
|
||||
|
||||
### Integration tests (core)
|
||||
|
||||
|
||||
@@ -20,14 +20,9 @@ PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
|
||||
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
|
||||
"codex-sdk": ["codex"],
|
||||
}
|
||||
WINDOWS_ONLY_COMPONENTS: dict[str, list[str]] = {
|
||||
"codex": ["codex-windows-sandbox-setup", "codex-command-runner"],
|
||||
}
|
||||
COMPONENT_DEST_DIR: dict[str, str] = {
|
||||
"codex": "codex",
|
||||
"codex-responses-api-proxy": "codex-responses-api-proxy",
|
||||
"codex-windows-sandbox-setup": "codex",
|
||||
"codex-command-runner": "codex",
|
||||
"rg": "path",
|
||||
}
|
||||
|
||||
@@ -108,7 +103,7 @@ def main() -> int:
|
||||
"pointing to a directory containing pre-installed binaries."
|
||||
)
|
||||
|
||||
copy_native_binaries(vendor_src, staging_dir, package, native_components)
|
||||
copy_native_binaries(vendor_src, staging_dir, native_components)
|
||||
|
||||
if release_version:
|
||||
staging_dir_str = str(staging_dir)
|
||||
@@ -237,12 +232,7 @@ def stage_codex_sdk_sources(staging_dir: Path) -> None:
|
||||
shutil.copy2(license_src, staging_dir / "LICENSE")
|
||||
|
||||
|
||||
def copy_native_binaries(
|
||||
vendor_src: Path,
|
||||
staging_dir: Path,
|
||||
package: str,
|
||||
components: list[str],
|
||||
) -> None:
|
||||
def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None:
|
||||
vendor_src = vendor_src.resolve()
|
||||
if not vendor_src.exists():
|
||||
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
|
||||
@@ -260,9 +250,6 @@ def copy_native_binaries(
|
||||
if not target_dir.is_dir():
|
||||
continue
|
||||
|
||||
if "windows" in target_dir.name:
|
||||
components_set.update(WINDOWS_ONLY_COMPONENTS.get(package, []))
|
||||
|
||||
dest_target_dir = vendor_dest / target_dir.name
|
||||
dest_target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -36,11 +36,8 @@ class BinaryComponent:
|
||||
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
|
||||
dest_dir: str # directory under vendor/<target>/ where the binary is installed
|
||||
binary_basename: str # executable name inside dest_dir (before optional .exe)
|
||||
targets: tuple[str, ...] | None = None # limit installation to specific targets
|
||||
|
||||
|
||||
WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target)
|
||||
|
||||
BINARY_COMPONENTS = {
|
||||
"codex": BinaryComponent(
|
||||
artifact_prefix="codex",
|
||||
@@ -52,18 +49,6 @@ BINARY_COMPONENTS = {
|
||||
dest_dir="codex-responses-api-proxy",
|
||||
binary_basename="codex-responses-api-proxy",
|
||||
),
|
||||
"codex-windows-sandbox-setup": BinaryComponent(
|
||||
artifact_prefix="codex-windows-sandbox-setup",
|
||||
dest_dir="codex",
|
||||
binary_basename="codex-windows-sandbox-setup",
|
||||
targets=WINDOWS_TARGETS,
|
||||
),
|
||||
"codex-command-runner": BinaryComponent(
|
||||
artifact_prefix="codex-command-runner",
|
||||
dest_dir="codex",
|
||||
binary_basename="codex-command-runner",
|
||||
targets=WINDOWS_TARGETS,
|
||||
),
|
||||
}
|
||||
|
||||
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
|
||||
@@ -94,8 +79,7 @@ def parse_args() -> argparse.Namespace:
|
||||
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
|
||||
help=(
|
||||
"Limit installation to the specified components."
|
||||
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
|
||||
" codex-command-runner, and rg."
|
||||
" May be repeated. Defaults to 'codex' and 'rg'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -117,12 +101,7 @@ def main() -> int:
|
||||
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
components = args.components or [
|
||||
"codex",
|
||||
"codex-windows-sandbox-setup",
|
||||
"codex-command-runner",
|
||||
"rg",
|
||||
]
|
||||
components = args.components or ["codex", "rg"]
|
||||
|
||||
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
|
||||
if not workflow_url:
|
||||
@@ -137,7 +116,8 @@ def main() -> int:
|
||||
install_binary_components(
|
||||
artifacts_dir,
|
||||
vendor_dir,
|
||||
[BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS],
|
||||
BINARY_TARGETS,
|
||||
[name for name in components if name in BINARY_COMPONENTS],
|
||||
)
|
||||
|
||||
if "rg" in components:
|
||||
@@ -226,19 +206,23 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
|
||||
def install_binary_components(
|
||||
artifacts_dir: Path,
|
||||
vendor_dir: Path,
|
||||
selected_components: Sequence[BinaryComponent],
|
||||
targets: Iterable[str],
|
||||
component_names: Sequence[str],
|
||||
) -> None:
|
||||
selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS]
|
||||
if not selected_components:
|
||||
return
|
||||
|
||||
for component in selected_components:
|
||||
component_targets = list(component.targets or BINARY_TARGETS)
|
||||
targets = list(targets)
|
||||
if not targets:
|
||||
return
|
||||
|
||||
for component in selected_components:
|
||||
print(
|
||||
f"Installing {component.binary_basename} binaries for targets: "
|
||||
+ ", ".join(component_targets)
|
||||
+ ", ".join(targets)
|
||||
)
|
||||
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
|
||||
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
@@ -248,7 +232,7 @@ def install_binary_components(
|
||||
target,
|
||||
component,
|
||||
): target
|
||||
for target in component_targets
|
||||
for target in targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
installed_path = future.result()
|
||||
|
||||
@@ -133,10 +133,6 @@ client_request_definitions! {
|
||||
params: v2::ReviewStartParams,
|
||||
response: v2::ReviewStartResponse,
|
||||
},
|
||||
CompactStart => "thread/compact" {
|
||||
params: v2::CompactStartParams,
|
||||
response: v2::TurnStartResponse,
|
||||
},
|
||||
|
||||
ModelList => "model/list" {
|
||||
params: v2::ModelListParams,
|
||||
@@ -148,9 +144,9 @@ client_request_definitions! {
|
||||
response: v2::McpServerOauthLoginResponse,
|
||||
},
|
||||
|
||||
McpServerStatusList => "mcpServerStatus/list" {
|
||||
params: v2::ListMcpServerStatusParams,
|
||||
response: v2::ListMcpServerStatusResponse,
|
||||
McpServersList => "mcpServers/list" {
|
||||
params: v2::ListMcpServersParams,
|
||||
response: v2::ListMcpServersResponse,
|
||||
},
|
||||
|
||||
LoginAccount => "account/login/start" {
|
||||
@@ -529,8 +525,6 @@ server_notification_definitions! {
|
||||
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
|
||||
ItemStarted => "item/started" (v2::ItemStartedNotification),
|
||||
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
||||
/// This event is internal-only. Used by Codex Cloud.
|
||||
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
|
||||
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
@@ -538,6 +532,7 @@ server_notification_definitions! {
|
||||
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
|
||||
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
|
||||
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
||||
ModelPresetsUpdated => "model/presets/updated" (v2::ModelPresetsUpdatedNotification),
|
||||
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
||||
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
|
||||
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
|
||||
|
||||
@@ -751,17 +751,12 @@ pub struct ReasoningEffortOption {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ModelListResponse {
|
||||
pub data: Vec<Model>,
|
||||
/// Opaque cursor to pass to the next call to continue after the last item.
|
||||
/// If None, there are no more items to return.
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
pub struct ModelListResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ListMcpServerStatusParams {
|
||||
pub struct ListMcpServersParams {
|
||||
/// Opaque pagination cursor returned by a previous call.
|
||||
pub cursor: Option<String>,
|
||||
/// Optional page size; defaults to a server-defined value.
|
||||
@@ -771,7 +766,7 @@ pub struct ListMcpServerStatusParams {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct McpServerStatus {
|
||||
pub struct McpServer {
|
||||
pub name: String,
|
||||
pub tools: std::collections::HashMap<String, McpTool>,
|
||||
pub resources: Vec<McpResource>,
|
||||
@@ -782,8 +777,8 @@ pub struct McpServerStatus {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ListMcpServerStatusResponse {
|
||||
pub data: Vec<McpServerStatus>,
|
||||
pub struct ListMcpServersResponse {
|
||||
pub data: Vec<McpServer>,
|
||||
/// Opaque cursor to pass to the next call to continue after the last item.
|
||||
/// If None, there are no more items to return.
|
||||
pub next_cursor: Option<String>,
|
||||
@@ -860,12 +855,6 @@ pub struct ThreadStartParams {
|
||||
pub config: Option<HashMap<String, JsonValue>>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
/// If true, opt into emitting raw response items on the event stream.
|
||||
///
|
||||
/// This is for internal use only (e.g. Codex Cloud).
|
||||
/// (TODO): Figure out a better way to categorize internal / experimental events & protocols.
|
||||
#[serde(default)]
|
||||
pub experimental_raw_events: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1080,6 +1069,13 @@ pub struct AccountUpdatedNotification {
|
||||
pub auth_mode: Option<AuthMode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ModelPresetsUpdatedNotification {
|
||||
pub models: Vec<Model>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1203,13 +1199,6 @@ pub struct TurnStartParams {
|
||||
pub summary: Option<ReasoningSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CompactStartParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1594,15 +1583,6 @@ pub struct ItemCompletedNotification {
|
||||
pub turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RawResponseItemCompletedNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item: ResponseItem,
|
||||
}
|
||||
|
||||
// Item-specific progress notifications
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Protocol](#protocol)
|
||||
- [Message Schema](#message-schema)
|
||||
- [Core Primitives](#core-primitives)
|
||||
@@ -29,7 +28,6 @@ codex app-server generate-json-schema --out DIR
|
||||
## Core Primitives
|
||||
|
||||
The API exposes three top level primitives representing an interaction between a user and Codex:
|
||||
|
||||
- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns.
|
||||
- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items.
|
||||
- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc.
|
||||
@@ -51,23 +49,13 @@ Clients must send a single `initialize` request before invoking any other method
|
||||
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
|
||||
|
||||
Example (from OpenAI's official VSCode extension):
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "initialize",
|
||||
"id": 0,
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex-vscode",
|
||||
"title": "Codex VS Code Extension",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
{ "method": "initialize", "id": 0, "params": {
|
||||
"clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" }
|
||||
} }
|
||||
```
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
|
||||
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
|
||||
@@ -75,12 +63,11 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
|
||||
- `thread/compact` — compact (summarize) a thread’s history to reduce context usage; responds like `turn/start` and emits `thread/compacted` when the new compacted history is installed.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
- `model/list` — list available models (with reasoning effort options).
|
||||
- `model/list` — request the available models; responds with `{}` and asynchronously emits `model/presets/updated` containing the catalog.
|
||||
- `skills/list` — list skills for one or more `cwd` values.
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||
@@ -121,7 +108,6 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev
|
||||
### Example: List threads (with pagination & filters)
|
||||
|
||||
`thread/list` lets you render a history UI. Pass any combination of:
|
||||
|
||||
- `cursor` — opaque string from a prior response; omit for the first page.
|
||||
- `limit` — server defaults to a reasonable page size if unset.
|
||||
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
|
||||
@@ -242,32 +228,22 @@ Codex streams the usual `turn/started` notification followed by an `item/started
|
||||
with an `enteredReviewMode` item so clients can show progress:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "item/started",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "enteredReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "current changes"
|
||||
}
|
||||
}
|
||||
}
|
||||
{ "method": "item/started", "params": { "item": {
|
||||
"type": "enteredReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "current changes"
|
||||
} } }
|
||||
```
|
||||
|
||||
When the reviewer finishes, the server emits `item/started` and `item/completed`
|
||||
containing an `exitedReviewMode` item with the final review text:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"item": {
|
||||
"type": "exitedReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
{ "method": "item/completed", "params": { "item": {
|
||||
"type": "exitedReviewMode",
|
||||
"id": "turn_900",
|
||||
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
|
||||
} } }
|
||||
```
|
||||
|
||||
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
|
||||
@@ -287,7 +263,6 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Empty `command` arrays are rejected.
|
||||
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags).
|
||||
- When omitted, `timeoutMs` falls back to the server default.
|
||||
@@ -310,7 +285,6 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
#### Items
|
||||
|
||||
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
|
||||
|
||||
- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
|
||||
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
|
||||
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
|
||||
@@ -324,48 +298,37 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
- `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically.
|
||||
|
||||
All items emit two shared lifecycle events:
|
||||
|
||||
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
|
||||
- `item/completed` — sends the final `item` once that work finishes (e.g., after a tool call or message completes); treat this as the authoritative state.
|
||||
|
||||
There are additional item-specific events:
|
||||
|
||||
#### agentMessage
|
||||
|
||||
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
|
||||
|
||||
#### reasoning
|
||||
|
||||
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
|
||||
- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`.
|
||||
- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI.
|
||||
|
||||
#### commandExecution
|
||||
|
||||
- `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item.
|
||||
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
|
||||
|
||||
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
|
||||
#### fileChange
|
||||
|
||||
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
|
||||
|
||||
### Errors
|
||||
|
||||
`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
|
||||
|
||||
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
|
||||
|
||||
- `ContextWindowExceeded`
|
||||
- `UsageLimitExceeded`
|
||||
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
|
||||
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
|
||||
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
|
||||
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
|
||||
- `BadRequest`
|
||||
- `Unauthorized`
|
||||
- `SandboxError`
|
||||
- `InternalServerError`
|
||||
- `Other`: all unclassified errors
|
||||
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
|
||||
- `ContextWindowExceeded`
|
||||
- `UsageLimitExceeded`
|
||||
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
|
||||
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
|
||||
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
|
||||
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
|
||||
- `BadRequest`
|
||||
- `Unauthorized`
|
||||
- `SandboxError`
|
||||
- `InternalServerError`
|
||||
- `Other`: all unclassified errors
|
||||
|
||||
When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
|
||||
|
||||
@@ -379,7 +342,6 @@ Certain actions (shell commands or modifying files) may require explicit user ap
|
||||
### Command execution approvals
|
||||
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `reason` or `risk`, plus `parsedCmd` for friendly display.
|
||||
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
|
||||
@@ -388,7 +350,6 @@ Order of messages:
|
||||
### File change approvals
|
||||
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user.
|
||||
2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`.
|
||||
3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`.
|
||||
@@ -401,7 +362,6 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
|
||||
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
|
||||
|
||||
### API Overview
|
||||
|
||||
- `account/read` — fetch current account info; optionally refresh tokens.
|
||||
- `account/login/start` — begin login (`apiKey` or `chatgpt`).
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
@@ -415,13 +375,11 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
|
||||
### 1) Check auth state
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{ "method": "account/read", "id": 1, "params": { "refreshToken": false } }
|
||||
```
|
||||
|
||||
Response examples:
|
||||
|
||||
```json
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
|
||||
@@ -430,7 +388,6 @@ Response examples:
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `refreshToken` (bool): set `true` to force a token refresh.
|
||||
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
|
||||
|
||||
@@ -438,11 +395,7 @@ Field notes:
|
||||
|
||||
1. Send:
|
||||
```json
|
||||
{
|
||||
"method": "account/login/start",
|
||||
"id": 2,
|
||||
"params": { "type": "apiKey", "apiKey": "sk-…" }
|
||||
}
|
||||
{ "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } }
|
||||
```
|
||||
2. Expect:
|
||||
```json
|
||||
@@ -492,7 +445,6 @@ Field notes:
|
||||
```
|
||||
|
||||
Field notes:
|
||||
|
||||
- `usedPercent` is current usage within the OpenAI quota window.
|
||||
- `windowDurationMins` is the quota window length.
|
||||
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
|
||||
|
||||
@@ -31,7 +31,6 @@ use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
|
||||
use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
|
||||
use codex_app_server_protocol::ReasoningTextDeltaNotification;
|
||||
@@ -452,16 +451,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::ItemCompleted(completed))
|
||||
.await;
|
||||
}
|
||||
EventMsg::RawResponseItem(raw_response_item_event) => {
|
||||
maybe_emit_raw_response_item_completed(
|
||||
api_version,
|
||||
conversation_id,
|
||||
&event_turn_id,
|
||||
raw_response_item_event.item,
|
||||
outgoing.as_ref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventMsg::PatchApplyBegin(patch_begin_event) => {
|
||||
// Until we migrate the core to be aware of a first class FileChangeItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
@@ -831,27 +820,6 @@ async fn complete_command_execution_item(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn maybe_emit_raw_response_item_completed(
|
||||
api_version: ApiVersion,
|
||||
conversation_id: ConversationId,
|
||||
turn_id: &str,
|
||||
item: codex_protocol::models::ResponseItem,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
) {
|
||||
let ApiVersion::V2 = api_version else {
|
||||
return;
|
||||
};
|
||||
|
||||
let notification = RawResponseItemCompletedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::RawResponseItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn find_and_remove_turn_summary(
|
||||
conversation_id: ConversationId,
|
||||
turn_summary_store: &TurnSummaryStore,
|
||||
|
||||
@@ -23,7 +23,6 @@ use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::CancelLoginChatGptResponse;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::CompactStartParams;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::ExecOneOffCommandResponse;
|
||||
@@ -47,8 +46,8 @@ use codex_app_server_protocol::InterruptConversationParams;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ListConversationsParams;
|
||||
use codex_app_server_protocol::ListConversationsResponse;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::ListMcpServersParams;
|
||||
use codex_app_server_protocol::ListMcpServersResponse;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::LoginApiKeyParams;
|
||||
use codex_app_server_protocol::LoginApiKeyResponse;
|
||||
@@ -56,12 +55,13 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_app_server_protocol::LoginChatGptResponse;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::LogoutChatGptResponse;
|
||||
use codex_app_server_protocol::McpServer;
|
||||
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
|
||||
use codex_app_server_protocol::McpServerOauthLoginParams;
|
||||
use codex_app_server_protocol::McpServerOauthLoginResponse;
|
||||
use codex_app_server_protocol::McpServerStatus;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::ModelPresetsUpdatedNotification;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
use codex_app_server_protocol::NewConversationResponse;
|
||||
use codex_app_server_protocol::RemoveConversationListenerParams;
|
||||
@@ -232,6 +232,19 @@ pub(crate) enum ApiVersion {
|
||||
V2,
|
||||
}
|
||||
|
||||
fn spawn_model_presets_notification(
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
config: Arc<Config>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let models = supported_models(conversation_manager, &config).await;
|
||||
let notification =
|
||||
ServerNotification::ModelPresetsUpdated(ModelPresetsUpdatedNotification { models });
|
||||
outgoing.send_server_notification(notification).await;
|
||||
});
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
async fn conversation_from_thread_id(
|
||||
&self,
|
||||
@@ -282,6 +295,14 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_model_presets_notification(&self) {
|
||||
spawn_model_presets_notification(
|
||||
self.outgoing.clone(),
|
||||
self.conversation_manager.clone(),
|
||||
self.config.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
|
||||
Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default())
|
||||
.await
|
||||
@@ -381,9 +402,6 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::ReviewStart { request_id, params } => {
|
||||
self.review_start(request_id, params).await;
|
||||
}
|
||||
ClientRequest::CompactStart { request_id, params } => {
|
||||
self.compact_start(request_id, params).await;
|
||||
}
|
||||
ClientRequest::NewConversation { request_id, params } => {
|
||||
// Do not tokio::spawn() to process new_conversation()
|
||||
// asynchronously because we need to ensure the conversation is
|
||||
@@ -402,8 +420,8 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::McpServerOauthLogin { request_id, params } => {
|
||||
self.mcp_server_oauth_login(request_id, params).await;
|
||||
}
|
||||
ClientRequest::McpServerStatusList { request_id, params } => {
|
||||
self.list_mcp_server_status(request_id, params).await;
|
||||
ClientRequest::McpServersList { request_id, params } => {
|
||||
self.list_mcp_servers(request_id, params).await;
|
||||
}
|
||||
ClientRequest::LoginAccount { request_id, params } => {
|
||||
self.login_v2(request_id, params).await;
|
||||
@@ -577,6 +595,7 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
self.spawn_model_presets_notification();
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -607,6 +626,7 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
|
||||
.await;
|
||||
self.spawn_model_presets_notification();
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -663,6 +683,8 @@ impl CodexMessageProcessor {
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let conversation_manager = self.conversation_manager.clone();
|
||||
let config = self.config.clone();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -703,6 +725,11 @@ impl CodexMessageProcessor {
|
||||
payload,
|
||||
))
|
||||
.await;
|
||||
spawn_model_presets_notification(
|
||||
outgoing_clone,
|
||||
conversation_manager,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
|
||||
@@ -753,6 +780,8 @@ impl CodexMessageProcessor {
|
||||
let outgoing_clone = self.outgoing.clone();
|
||||
let active_login = self.active_login.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let conversation_manager = self.conversation_manager.clone();
|
||||
let config = self.config.clone();
|
||||
let auth_url = server.auth_url.clone();
|
||||
tokio::spawn(async move {
|
||||
let (success, error_msg) = match tokio::time::timeout(
|
||||
@@ -793,6 +822,11 @@ impl CodexMessageProcessor {
|
||||
payload_v2,
|
||||
))
|
||||
.await;
|
||||
spawn_model_presets_notification(
|
||||
outgoing_clone,
|
||||
conversation_manager,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
|
||||
@@ -912,6 +946,7 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
self.spawn_model_presets_notification();
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -932,6 +967,7 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
|
||||
.await;
|
||||
self.spawn_model_presets_notification();
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
@@ -1377,13 +1413,9 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
|
||||
// Auto-attach a conversation listener when starting a thread.
|
||||
// Use the same behavior as the v1 API, with opt-in support for raw item events.
|
||||
// Use the same behavior as the v1 API with experimental_raw_events=false.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(
|
||||
conversation_id,
|
||||
params.experimental_raw_events,
|
||||
ApiVersion::V2,
|
||||
)
|
||||
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
@@ -1901,59 +1933,11 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
|
||||
let ModelListParams { limit, cursor } = params;
|
||||
let models = supported_models(self.conversation_manager.clone(), &self.config).await;
|
||||
let total = models.len();
|
||||
|
||||
if total == 0 {
|
||||
let response = ModelListResponse {
|
||||
data: Vec::new(),
|
||||
next_cursor: None,
|
||||
};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
|
||||
let effective_limit = effective_limit.min(total);
|
||||
let start = match cursor {
|
||||
Some(cursor) => match cursor.parse::<usize>() {
|
||||
Ok(idx) => idx,
|
||||
Err(_) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid cursor: {cursor}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => 0,
|
||||
};
|
||||
|
||||
if start > total {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("cursor {start} exceeds total models {total}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let end = start.saturating_add(effective_limit).min(total);
|
||||
let items = models[start..end].to_vec();
|
||||
let next_cursor = if end < total {
|
||||
Some(end.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let response = ModelListResponse {
|
||||
data: items,
|
||||
next_cursor,
|
||||
};
|
||||
let _ = params;
|
||||
let response = ModelListResponse {};
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
|
||||
self.spawn_model_presets_notification();
|
||||
}
|
||||
|
||||
async fn mcp_server_oauth_login(
|
||||
@@ -2060,11 +2044,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_mcp_server_status(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
params: ListMcpServerStatusParams,
|
||||
) {
|
||||
async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) {
|
||||
let config = match self.load_latest_config().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
@@ -2119,9 +2099,9 @@ impl CodexMessageProcessor {
|
||||
|
||||
let end = start.saturating_add(effective_limit).min(total);
|
||||
|
||||
let data: Vec<McpServerStatus> = server_names[start..end]
|
||||
let data: Vec<McpServer> = server_names[start..end]
|
||||
.iter()
|
||||
.map(|name| McpServerStatus {
|
||||
.map(|name| McpServer {
|
||||
name: name.clone(),
|
||||
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
|
||||
resources: snapshot.resources.get(name).cloned().unwrap_or_default(),
|
||||
@@ -2145,7 +2125,7 @@ impl CodexMessageProcessor {
|
||||
None
|
||||
};
|
||||
|
||||
let response = ListMcpServerStatusResponse { data, next_cursor };
|
||||
let response = ListMcpServersResponse { data, next_cursor };
|
||||
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
@@ -2760,52 +2740,6 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn compact_start(&self, request_id: RequestId, params: CompactStartParams) {
|
||||
let (_, conversation) = match self.conversation_from_thread_id(¶ms.thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let thread_id = params.thread_id;
|
||||
let turn_id = conversation.submit(Op::Compact).await;
|
||||
|
||||
match turn_id {
|
||||
Ok(turn_id) => {
|
||||
let turn = Turn {
|
||||
id: turn_id,
|
||||
items: vec![],
|
||||
error: None,
|
||||
status: TurnStatus::InProgress,
|
||||
};
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, TurnStartResponse { turn: turn.clone() })
|
||||
.await;
|
||||
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::TurnStarted(
|
||||
TurnStartedNotification { thread_id, turn },
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to start compact: {err}"),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_review_turn(turn_id: String, display_text: &str) -> Turn {
|
||||
let items = if display_text.is_empty() {
|
||||
Vec::new()
|
||||
|
||||
@@ -128,6 +128,8 @@ impl MessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
|
||||
self.initialized = true;
|
||||
self.codex_message_processor
|
||||
.spawn_model_presets_notification();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginChatGptParams;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientNotification;
|
||||
use codex_app_server_protocol::CompactStartParams;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigReadParams;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -391,15 +390,6 @@ impl McpProcess {
|
||||
self.send_request("review/start", params).await
|
||||
}
|
||||
|
||||
/// Send a `thread/compact` JSON-RPC request (v2).
|
||||
pub async fn send_compact_start_request(
|
||||
&mut self,
|
||||
params: CompactStartParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("thread/compact", params).await
|
||||
}
|
||||
|
||||
/// Send a `cancelLoginChatGpt` JSON-RPC request.
|
||||
pub async fn send_cancel_login_chat_gpt_request(
|
||||
&mut self,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_chat_completions_server_unchecked;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CompactStartParams;
|
||||
use codex_app_server_protocol::ContextCompactedNotification;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_start_emits_context_compacted_notification() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response(
|
||||
"compacted summary",
|
||||
)?];
|
||||
let server = create_mock_chat_completions_server_unchecked(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_id = start_default_thread(&mut mcp).await?;
|
||||
|
||||
let compact_req = mcp
|
||||
.send_compact_start_request(CompactStartParams {
|
||||
thread_id: thread_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let compact_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(compact_req)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(compact_resp)?;
|
||||
assert_eq!(turn.status, TurnStatus::InProgress);
|
||||
let turn_id = turn.id.clone();
|
||||
|
||||
let compacted_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("thread/compacted"),
|
||||
)
|
||||
.await??;
|
||||
let compacted: ContextCompactedNotification =
|
||||
serde_json::from_value(compacted_notif.params.expect("params must be present"))?;
|
||||
assert_eq!(compacted.thread_id, thread_id);
|
||||
assert_eq!(compacted.turn_id, turn_id);
|
||||
|
||||
let completed_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification =
|
||||
serde_json::from_value(completed_notif.params.expect("params must be present"))?;
|
||||
assert_eq!(completed.thread_id, compacted.thread_id);
|
||||
assert_eq!(completed.turn.id, turn_id);
|
||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_default_thread(mcp: &mut McpProcess) -> Result<String> {
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
Ok(thread.id)
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "chat"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
mod account;
|
||||
mod compact;
|
||||
mod config_rpc;
|
||||
mod model_list;
|
||||
mod rate_limits;
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_models_cache;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::Model;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
async fn list_models_returns_empty_response_and_notification() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -30,8 +29,8 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(100),
|
||||
cursor: None,
|
||||
limit: Some(1),
|
||||
cursor: Some("ignored".to_string()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -41,12 +40,24 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: items,
|
||||
next_cursor,
|
||||
} = to_response::<ModelListResponse>(response)?;
|
||||
let ModelListResponse {} = to_response::<ModelListResponse>(response)?;
|
||||
|
||||
let expected_models = vec![
|
||||
let notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("model/presets/updated"),
|
||||
)
|
||||
.await??;
|
||||
let server_notification: ServerNotification = notification.try_into()?;
|
||||
let ServerNotification::ModelPresetsUpdated(payload) = server_notification else {
|
||||
unreachable!("expected model/presets/updated notification");
|
||||
};
|
||||
|
||||
assert_eq!(payload.models, expected_models());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_models() -> Vec<Model> {
|
||||
vec![
|
||||
Model {
|
||||
id: "gpt-5.1-codex-max".to_string(),
|
||||
model: "gpt-5.1-codex-max".to_string(),
|
||||
@@ -176,156 +187,5 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
default_reasoning_effort: ReasoningEffort::Medium,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(items, expected_models);
|
||||
assert!(next_cursor.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_pagination_works() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let first_request = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(1),
|
||||
cursor: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let first_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(first_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: first_items,
|
||||
next_cursor: first_cursor,
|
||||
} = to_response::<ModelListResponse>(first_response)?;
|
||||
|
||||
assert_eq!(first_items.len(), 1);
|
||||
assert_eq!(first_items[0].id, "gpt-5.1-codex-max");
|
||||
let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?;
|
||||
|
||||
let second_request = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(1),
|
||||
cursor: Some(next_cursor.clone()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let second_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(second_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: second_items,
|
||||
next_cursor: second_cursor,
|
||||
} = to_response::<ModelListResponse>(second_response)?;
|
||||
|
||||
assert_eq!(second_items.len(), 1);
|
||||
assert_eq!(second_items[0].id, "gpt-5.1-codex");
|
||||
let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?;
|
||||
|
||||
let third_request = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(1),
|
||||
cursor: Some(third_cursor.clone()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let third_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(third_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: third_items,
|
||||
next_cursor: third_cursor,
|
||||
} = to_response::<ModelListResponse>(third_response)?;
|
||||
|
||||
assert_eq!(third_items.len(), 1);
|
||||
assert_eq!(third_items[0].id, "gpt-5.1-codex-mini");
|
||||
let fourth_cursor = third_cursor.ok_or_else(|| anyhow!("cursor for fourth page"))?;
|
||||
|
||||
let fourth_request = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(1),
|
||||
cursor: Some(fourth_cursor.clone()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let fourth_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(fourth_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: fourth_items,
|
||||
next_cursor: fourth_cursor,
|
||||
} = to_response::<ModelListResponse>(fourth_response)?;
|
||||
|
||||
assert_eq!(fourth_items.len(), 1);
|
||||
assert_eq!(fourth_items[0].id, "gpt-5.2");
|
||||
let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?;
|
||||
|
||||
let fifth_request = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: Some(1),
|
||||
cursor: Some(fifth_cursor.clone()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let fifth_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(fifth_request)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let ModelListResponse {
|
||||
data: fifth_items,
|
||||
next_cursor: fifth_cursor,
|
||||
} = to_response::<ModelListResponse>(fifth_response)?;
|
||||
|
||||
assert_eq!(fifth_items.len(), 1);
|
||||
assert_eq!(fifth_items[0].id, "gpt-5.1");
|
||||
assert!(fifth_cursor.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_rejects_invalid_cursor() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_list_models_request(ModelListParams {
|
||||
limit: None,
|
||||
cursor: Some("invalid".to_string()),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.id, RequestId::Integer(request_id));
|
||||
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(error.error.message, "invalid cursor: invalid");
|
||||
Ok(())
|
||||
]
|
||||
}
|
||||
|
||||
@@ -367,447 +367,3 @@ fn extract_apply_patch_from_bash(
|
||||
|
||||
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::string::ToString;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Helper to construct a patch with the given body.
|
||||
fn wrap_patch(body: &str) -> String {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
|
||||
strs.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition when building bash -lc heredoc scripts
|
||||
fn args_bash(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn args_powershell(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_powershell_no_profile(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_pwsh(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_cmd(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["cmd.exe", "/c", script])
|
||||
}
|
||||
|
||||
fn heredoc_script(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
||||
)
|
||||
}
|
||||
|
||||
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_single_add() -> Vec<Hunk> {
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
assert_eq!(hunks, expected_single_add());
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
assert_match_args(args, expected_workdir);
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
let args = vec![patch];
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal() {
|
||||
let args = strs_to_strings(&[
|
||||
"apply_patch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"applypatch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc() {
|
||||
assert_match(&heredoc_script(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_non_login_shell() {
|
||||
let script = heredoc_script("");
|
||||
let args = strs_to_strings(&["bash", "-c", &script]);
|
||||
assert_match_args(args, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_powershell_heredoc_no_profile() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell_no_profile(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_pwsh_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_pwsh(&script), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
assert_match_args(args_cmd(&script), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_with_leading_cd() {
|
||||
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_with_semicolon_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo; "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_or_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar || "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar | "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_single_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_double_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_and_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("echo foo && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_with_arg_is_ignored() {
|
||||
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
||||
assert_not_match(script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_two_args_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
||||
assert_not_match(&script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
||||
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_last_line_replacement() {
|
||||
// Replace the very last line of the file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("last.txt");
|
||||
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
foo
|
||||
bar
|
||||
-baz
|
||||
+BAZ
|
||||
"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let patch = parse_patch(&patch).unwrap();
|
||||
let chunks = match patch.hunks.as_slice() {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let expected_diff = r#"@@ -2,2 +2,2 @@
|
||||
bar
|
||||
-baz
|
||||
+BAZ
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nBAZ\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff_insert_at_eof() {
|
||||
// Insert a new line at end‑of‑file.
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("insert.txt");
|
||||
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
+quux
|
||||
*** End of File
|
||||
"#,
|
||||
path.display()
|
||||
));
|
||||
|
||||
let patch = parse_patch(&patch).unwrap();
|
||||
let chunks = match patch.hunks.as_slice() {
|
||||
[Hunk::UpdateFile { chunks, .. }] => chunks,
|
||||
_ => panic!("Expected a single UpdateFile hunk"),
|
||||
};
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
let expected_diff = r#"@@ -3 +3,2 @@
|
||||
baz
|
||||
+quux
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
// Note that we need this file to exist for the patch to be "verified"
|
||||
// and parsed correctly.
|
||||
let session_file_path = session_dir.path().join(relative_path);
|
||||
fs::write(&session_file_path, "session directory content\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
assert_eq!(
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
"#
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
new_content: "updated session directory content\n".to_string(),
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: session_dir.path().to_path_buf(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let worktree_rel = "alt";
|
||||
let worktree_dir = session_dir.path().join(worktree_rel);
|
||||
fs::create_dir_all(&worktree_dir).unwrap();
|
||||
|
||||
let source_name = "old.txt";
|
||||
let dest_name = "renamed.txt";
|
||||
let source_path = worktree_dir.join(source_name);
|
||||
fs::write(&source_path, "before\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {source_name}
|
||||
*** Move to: {dest_name}
|
||||
@@
|
||||
-before
|
||||
+after"#
|
||||
));
|
||||
|
||||
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
||||
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let action = match result {
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected verified body, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(action.cwd, worktree_dir);
|
||||
|
||||
let change = action
|
||||
.changes()
|
||||
.get(&worktree_dir.join(source_name))
|
||||
.expect("source file change present");
|
||||
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
assert_eq!(
|
||||
move_path.as_deref(),
|
||||
Some(worktree_dir.join(dest_name).as_path())
|
||||
);
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +544,11 @@ pub fn print_summary(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::invocation::MaybeApplyPatch;
|
||||
use crate::invocation::maybe_parse_apply_patch;
|
||||
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::string::ToString;
|
||||
@@ -555,6 +559,270 @@ mod tests {
|
||||
format!("*** Begin Patch\n{body}\n*** End Patch")
|
||||
}
|
||||
|
||||
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
|
||||
strs.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
// Test helpers to reduce repetition when building bash -lc heredoc scripts
|
||||
fn args_bash(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["bash", "-lc", script])
|
||||
}
|
||||
|
||||
fn args_powershell(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_powershell_no_profile(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_pwsh(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
|
||||
}
|
||||
|
||||
fn args_cmd(script: &str) -> Vec<String> {
|
||||
strs_to_strings(&["cmd.exe", "/c", script])
|
||||
}
|
||||
|
||||
fn heredoc_script(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
|
||||
)
|
||||
}
|
||||
|
||||
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_single_add() -> Vec<Hunk> {
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir.as_deref(), expected_workdir);
|
||||
assert_eq!(hunks, expected_single_add());
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_match(script: &str, expected_workdir: Option<&str>) {
|
||||
let args = args_bash(script);
|
||||
assert_match_args(args, expected_workdir);
|
||||
}
|
||||
|
||||
fn assert_not_match(script: &str) {
|
||||
let args = args_bash(script);
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch(&args),
|
||||
MaybeApplyPatch::NotApplyPatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_single_arg_is_error() {
|
||||
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
|
||||
let args = vec![patch];
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_implicit_patch_bash_script_is_error() {
|
||||
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
|
||||
let args = args_bash(script);
|
||||
let dir = tempdir().unwrap();
|
||||
assert_matches!(
|
||||
maybe_parse_apply_patch_verified(&args, dir.path()),
|
||||
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal() {
|
||||
let args = strs_to_strings(&[
|
||||
"apply_patch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_literal_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"applypatch",
|
||||
r#"*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc() {
|
||||
assert_match(&heredoc_script(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_non_login_shell() {
|
||||
let script = heredoc_script("");
|
||||
let args = strs_to_strings(&["bash", "-c", &script]);
|
||||
assert_match_args(args, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_applypatch() {
|
||||
let args = strs_to_strings(&[
|
||||
"bash",
|
||||
"-lc",
|
||||
r#"applypatch <<'PATCH'
|
||||
*** Begin Patch
|
||||
*** Add File: foo
|
||||
+hi
|
||||
*** End Patch
|
||||
PATCH"#,
|
||||
]);
|
||||
|
||||
match maybe_parse_apply_patch(&args) {
|
||||
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
|
||||
assert_eq!(workdir, None);
|
||||
assert_eq!(
|
||||
hunks,
|
||||
vec![Hunk::AddFile {
|
||||
path: PathBuf::from("foo"),
|
||||
contents: "hi\n".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_powershell_heredoc_no_profile() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_powershell_no_profile(&script), None);
|
||||
}
|
||||
#[test]
|
||||
fn test_pwsh_heredoc() {
|
||||
let script = heredoc_script("");
|
||||
assert_match_args(args_pwsh(&script), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_heredoc_with_cd() {
|
||||
let script = heredoc_script("cd foo && ");
|
||||
assert_match_args(args_cmd(&script), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heredoc_with_leading_cd() {
|
||||
assert_match(&heredoc_script("cd foo && "), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_with_semicolon_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo; "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_or_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar || "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_pipe_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd bar | "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_single_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_double_quoted_path_with_spaces() {
|
||||
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_and_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("echo foo && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_with_arg_is_ignored() {
|
||||
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
|
||||
assert_not_match(script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_cd_then_apply_patch_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo && cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_two_args_is_ignored() {
|
||||
assert_not_match(&heredoc_script("cd foo bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cd_then_apply_patch_then_extra_is_ignored() {
|
||||
let script = heredoc_script_ps("cd bar && ", " && echo done");
|
||||
assert_not_match(&script);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_echo_then_cd_and_apply_patch_is_ignored() {
|
||||
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
|
||||
assert_not_match(&heredoc_script("echo foo; cd bar && "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_file_hunk_creates_file_with_contents() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -1043,6 +1311,99 @@ g
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
// Note that we need this file to exist for the patch to be "verified"
|
||||
// and parsed correctly.
|
||||
let session_file_path = session_dir.path().join(relative_path);
|
||||
fs::write(&session_file_path, "session directory content\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
assert_eq!(
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
"#
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
new_content: "updated session directory content\n".to_string(),
|
||||
},
|
||||
)]),
|
||||
patch: argv[1].clone(),
|
||||
cwd: session_dir.path().to_path_buf(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let worktree_rel = "alt";
|
||||
let worktree_dir = session_dir.path().join(worktree_rel);
|
||||
fs::create_dir_all(&worktree_dir).unwrap();
|
||||
|
||||
let source_name = "old.txt";
|
||||
let dest_name = "renamed.txt";
|
||||
let source_path = worktree_dir.join(source_name);
|
||||
fs::write(&source_path, "before\n").unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {source_name}
|
||||
*** Move to: {dest_name}
|
||||
@@
|
||||
-before
|
||||
+after"#
|
||||
));
|
||||
|
||||
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
|
||||
let argv = vec!["bash".into(), "-lc".into(), shell_script];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
let action = match result {
|
||||
MaybeApplyPatchVerified::Body(action) => action,
|
||||
other => panic!("expected verified body, got {other:?}"),
|
||||
};
|
||||
|
||||
assert_eq!(action.cwd, worktree_dir);
|
||||
|
||||
let change = action
|
||||
.changes()
|
||||
.get(&worktree_dir.join(source_name))
|
||||
.expect("source file change present");
|
||||
|
||||
match change {
|
||||
ApplyPatchFileChange::Update { move_path, .. } => {
|
||||
assert_eq!(
|
||||
move_path.as_deref(),
|
||||
Some(worktree_dir.join(dest_name).as_path())
|
||||
);
|
||||
}
|
||||
other => panic!("expected update change, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_fails_on_write_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1 +0,0 @@
|
||||
stable
|
||||
@@ -1,3 +0,0 @@
|
||||
line1
|
||||
naïve café ✅
|
||||
line3
|
||||
@@ -1,3 +0,0 @@
|
||||
line1
|
||||
naïve café
|
||||
line3
|
||||
@@ -1,7 +0,0 @@
|
||||
*** Begin Patch
|
||||
*** Update File: foo.txt
|
||||
@@
|
||||
line1
|
||||
-naïve café
|
||||
+naïve café ✅
|
||||
*** End Patch
|
||||
@@ -7,7 +7,6 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::path_utils;
|
||||
use codex_app_server_protocol::Config as ApiConfig;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
use codex_app_server_protocol::ConfigLayerMetadata;
|
||||
@@ -471,10 +470,9 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
|
||||
}
|
||||
|
||||
fn paths_match(expected: &Path, provided: &Path) -> bool {
|
||||
if let (Ok(expanded_expected), Ok(expanded_provided)) = (
|
||||
path_utils::normalize_for_path_comparison(expected),
|
||||
path_utils::normalize_for_path_comparison(provided),
|
||||
) {
|
||||
if let (Ok(expanded_expected), Ok(expanded_provided)) =
|
||||
(expected.canonicalize(), provided.canonicalize())
|
||||
{
|
||||
return expanded_expected == expanded_provided;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ mod mcp_tool_call;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod parse_command;
|
||||
pub mod path_utils;
|
||||
pub mod powershell;
|
||||
pub mod sandboxing;
|
||||
mod stream_events_utils;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::env;
|
||||
|
||||
pub fn normalize_for_path_comparison(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let canonical = path.canonicalize()?;
|
||||
Ok(normalize_for_wsl(canonical))
|
||||
}
|
||||
|
||||
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
|
||||
normalize_for_wsl_with_flag(path, env::is_wsl())
|
||||
}
|
||||
|
||||
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
|
||||
if !is_wsl {
|
||||
return path;
|
||||
}
|
||||
|
||||
if !is_wsl_case_insensitive_path(&path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
lower_ascii_path(path)
|
||||
}
|
||||
|
||||
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Component;
|
||||
|
||||
let mut components = path.components();
|
||||
let Some(Component::RootDir) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(Component::Normal(mnt)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
|
||||
return false;
|
||||
}
|
||||
let Some(Component::Normal(drive)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let drive_bytes = drive.as_bytes();
|
||||
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.zip(right)
|
||||
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
|
||||
let bytes = path.as_os_str().as_bytes();
|
||||
let mut lowered = Vec::with_capacity(bytes.len());
|
||||
for byte in bytes {
|
||||
lowered.push(byte.to_ascii_lowercase());
|
||||
}
|
||||
PathBuf::from(OsString::from_vec(lowered))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wsl {
|
||||
use super::super::normalize_for_wsl_with_flag;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn wsl_mnt_drive_paths_lowercase() {
|
||||
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
|
||||
|
||||
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_drive_paths_unchanged() {
|
||||
let path = PathBuf::from("/mnt/cc/Users/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_mnt_paths_unchanged() {
|
||||
let path = PathBuf::from("/home/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ At a glance:
|
||||
- Configuration and info
|
||||
- `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo`
|
||||
- `model/list` → enumerate available models and reasoning options
|
||||
- notifications: `model/presets/updated`
|
||||
- Auth
|
||||
- `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read`
|
||||
- notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated`
|
||||
@@ -78,21 +79,21 @@ List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversa
|
||||
|
||||
## Models
|
||||
|
||||
Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs:
|
||||
Request the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs (currently ignored by the server):
|
||||
|
||||
- `pageSize` – number of models to return (defaults to a server-selected value)
|
||||
- `pageSize` – number of models to return
|
||||
- `cursor` – opaque string from the previous response’s `nextCursor`
|
||||
|
||||
Each response yields:
|
||||
The response is an empty JSON object `{}`. The server asynchronously emits a
|
||||
`model/presets/updated` notification containing the full model catalog. The payload is:
|
||||
|
||||
- `items` – ordered list of models. A model includes:
|
||||
- `models` – the full list of available models. Each model includes:
|
||||
- `id`, `model`, `displayName`, `description`
|
||||
- `supportedReasoningEfforts` – array of objects with:
|
||||
- `reasoningEffort` – one of `minimal|low|medium|high`
|
||||
- `description` – human-friendly label for the effort
|
||||
- `defaultReasoningEffort` – suggested effort for the UI
|
||||
- `isDefault` – whether the model is recommended for most users
|
||||
- `nextCursor` – pass into the next request to continue paging (optional)
|
||||
|
||||
## Event stream
|
||||
|
||||
@@ -100,6 +101,7 @@ While a conversation runs, the server sends notifications:
|
||||
|
||||
- `codex/event` with the serialized Codex event payload. The shape matches `core/src/protocol.rs`’s `Event` and `EventMsg` types. Some notifications include a `_meta.requestId` to correlate with the originating request.
|
||||
- Auth notifications via method names `loginChatGptComplete` and `authStatusChange`.
|
||||
- Model catalog notifications via method name `model/presets/updated`.
|
||||
|
||||
Clients should render events and, when present, surface approval requests (see next section).
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use codex_core::ConversationsPage;
|
||||
use codex_core::Cursor;
|
||||
use codex_core::INTERACTIVE_SESSION_SOURCES;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::path_utils;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -671,10 +670,7 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
if let (Ok(ca), Ok(cb)) = (
|
||||
path_utils::normalize_for_path_comparison(a),
|
||||
path_utils::normalize_for_path_comparison(b),
|
||||
) {
|
||||
if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) {
|
||||
return ca == cb;
|
||||
}
|
||||
a == b
|
||||
|
||||
@@ -25,12 +25,10 @@ Historically, the legacy TUI tried to “cooperate” with the terminal’s own
|
||||
This had several failure modes:
|
||||
|
||||
- **Terminal‑dependent behavior.**
|
||||
|
||||
- Different terminals handle scroll regions, clears, and resize semantics differently.
|
||||
- What looked correct in one terminal could drop or duplicate content in another.
|
||||
|
||||
- **Resizes and layout churn.**
|
||||
|
||||
- The TUI reacts to resizes, focus changes, and overlay transitions.
|
||||
- When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the
|
||||
in‑memory history could go out of sync.
|
||||
@@ -59,28 +57,24 @@ order, and appears exactly once” across terminals, resizes, suspend/resume, an
|
||||
The redesign is guided by a few explicit goals:
|
||||
|
||||
1. **Codex, not the terminal, owns the viewport.**
|
||||
|
||||
- The in‑memory transcript (a list of history entries) is the single source of truth for what’s
|
||||
on screen.
|
||||
- The TUI decides how to map that transcript into the current viewport; scrollback becomes an
|
||||
output target, not an extra data structure we try to maintain.
|
||||
|
||||
2. **History must be correct, ordered, and never silently dropped.**
|
||||
|
||||
- Every logical history cell should either:
|
||||
- Be visible in the TUI, or
|
||||
- Have been printed into scrollback as part of a suspend/exit flow.
|
||||
- We would rather (rarely) duplicate content than risk losing it.
|
||||
|
||||
3. **Avoid unnecessary duplication.**
|
||||
|
||||
- When emitting history to scrollback (on suspend or exit), print each logical cell’s content at
|
||||
most once.
|
||||
- Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep
|
||||
reappearing.
|
||||
|
||||
4. **Behave sensibly under resizes.**
|
||||
|
||||
- TUI rendering should reflow to the current width on every frame.
|
||||
- History printed to scrollback may have been wrapped at different widths over time; that is
|
||||
acceptable, but it must not cause missing content or unbounded duplication.
|
||||
@@ -142,12 +136,10 @@ The TUI uses the terminal’s alternate screen for:
|
||||
Conceptually:
|
||||
|
||||
- Entering alt screen:
|
||||
|
||||
- Switches the terminal into alt screen and expands the viewport to cover the full terminal.
|
||||
- Clears that alt‑screen buffer.
|
||||
|
||||
- Leaving alt screen:
|
||||
|
||||
- Disables “alternate scroll” so mouse wheel events behave predictably.
|
||||
- Returns to the normal screen.
|
||||
|
||||
@@ -167,13 +159,11 @@ is the in‑memory state.
|
||||
Mouse interaction is a first‑class part of the new design:
|
||||
|
||||
- **Scrolling.**
|
||||
|
||||
- Mouse wheel scrolls the transcript in fixed line increments.
|
||||
- Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show
|
||||
consistent hints regardless of input device.
|
||||
|
||||
- **Selection.**
|
||||
|
||||
- A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript
|
||||
lines (not raw buffer coordinates).
|
||||
- Selection tracks the _content_ rather than a fixed screen row. When the transcript scrolls, the
|
||||
@@ -420,103 +410,16 @@ prints those lines before the token usage and resume hints.
|
||||
|
||||
## 10. Future Work and Open Questions
|
||||
|
||||
### 10.1 Current status
|
||||
|
||||
This design shipped behind the `tui2` feature flag (as a separate crate, duplicating the legacy
|
||||
`tui` crate to enable rollout without breaking existing behavior). The following items from early
|
||||
feedback are already implemented:
|
||||
|
||||
- Bottom pane positioning is pegged high with an empty transcript and moves down as the transcript
|
||||
fills (including on resume).
|
||||
- Wheel-based transcript scrolling is enabled on top of the new scroll model.
|
||||
- While a selection is active, streaming stops “follow latest output” so the selection remains
|
||||
stable, and follow mode resumes after the selection is cleared.
|
||||
|
||||
### 10.2 Roadmap (prioritized)
|
||||
|
||||
This section captures a prioritized list of improvements we want to add to TUI2 based on early
|
||||
feedback, with the goal of making scrolling/selection/copy feel as close to “native terminal” (and
|
||||
Vim) behavior as we can while still owning the viewport.
|
||||
|
||||
**P0 — must-have (usability/correctness):**
|
||||
|
||||
- **Scrolling behavior.** Default to small scroll increments (ideally 1 line per wheel tick) with
|
||||
acceleration/velocity for faster navigation, and ensure we stop scrolling when the user stops
|
||||
input (avoid redraw/event-loop backlog that makes scrolling feel “janky”).
|
||||
- **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the
|
||||
composer/footer don’t start or mutate transcript selection state.
|
||||
- **Copy includes offscreen lines.** Make copy operate on the full selection range even when part (or
|
||||
all) of the selection is outside the current viewport.
|
||||
- **Copy fidelity.** Preserve meaningful indentation (especially code blocks), treat soft-wrapped
|
||||
prose as a single logical line when copying, and copy markdown _source_ (including backticks and
|
||||
heading markers) even if we render it differently.
|
||||
|
||||
**P1 — should-have (UX polish and power user workflows):**
|
||||
|
||||
- **Streaming wrapping polish.** Ensure all streaming paths use display-time wrapping only, and add
|
||||
tests that cover resizing after streaming has started.
|
||||
- **Copy shortcut and discoverability.** Switch copy from `Ctrl+Y` to `Ctrl+Shift+C`, and add an
|
||||
on-screen copy affordance (e.g. a small button near the selection) that also displays the
|
||||
shortcut.
|
||||
- **Selection semantics.** Define and implement selection behavior across multi-step output (and
|
||||
whether step boundaries should be copy boundaries), while continuing to exclude the left gutter
|
||||
from copied text.
|
||||
- **Auto-scroll during drag.** While dragging a selection, auto-scroll when the cursor is at/near the
|
||||
top or bottom of the transcript viewport to allow selecting beyond the visible window.
|
||||
- **Width-aware selection.** Ensure selection highlighting and copy reconstruction handle wide glyphs
|
||||
correctly (emoji, CJK), matching terminal display width rather than raw character count.
|
||||
- **Multi-click selection.** Support double/triple/quad click selection (word/line/paragraph),
|
||||
implemented on top of the transcript/viewport model rather than terminal buffer coordinates.
|
||||
- **Find in transcript.** Add text search over the transcript (and consider integrating match
|
||||
markers with any future scroll indicator work).
|
||||
- **Cross-terminal behavior checks.** Validate copy/selection behavior across common terminals (incl.
|
||||
terminal-provided “override selection” modes like holding Shift) and document the tradeoffs.
|
||||
|
||||
**P2 — nice-to-have (polish, configuration, and interactivity):**
|
||||
|
||||
- **Suspend printing.** Decide whether printing history on suspend is desirable at all (it is not
|
||||
implemented yet). If we keep it, finalize the config shape/defaults, wire it through TUI startup,
|
||||
and document it in the appropriate config docs.
|
||||
- **Terminal integration.** Consider guiding (or optionally managing) terminal-emulator-specific
|
||||
settings that affect TUI behavior (for example iTerm’s clipboard opt-in prompts or Ghostty
|
||||
keybinding quirks), so the “works well out of the box” path is consistent across terminals.
|
||||
- **Interactive cells (unlocked by transcript ownership).** Because transcript entries are structured
|
||||
objects (not dead text in terminal scrollback), we can attach metadata to rendered regions and map
|
||||
mouse/keys back to the underlying cell reliably across resizes and reflow. Examples:
|
||||
- **Drill into a specific tool/command output.** Click (or press Enter) on a tool call / command
|
||||
cell to open a focused overlay that shows the command, exit status, timing, and stdout/stderr as
|
||||
separate sections, with dedicated “copy output” actions. This enables copying _just_ one command’s
|
||||
output even when multiple commands are interleaved in a turn.
|
||||
- **Copy an entire cell or entire turn.** Provide an action to copy a whole logical unit (one cell,
|
||||
or “user prompt + assistant response”), without gutters and with well-defined boundaries. This is
|
||||
hard to do with raw selection because step boundaries and padding aren’t reliably expressible in
|
||||
terminal coordinates once the viewport moves or reflows.
|
||||
- **Expand/collapse structured subregions with source-aware copy.** Tool calls, diffs, and
|
||||
markdown can render in a compact form by default and expand in place. Copy actions can choose
|
||||
between “copy rendered view” and “copy source” (e.g. raw markdown, raw JSON arguments, raw diff),
|
||||
since we retain the original source alongside the rendered lines.
|
||||
- **Cell-scoped actions.** Actions like “copy command”, “yank into composer”, “retry tool call”, or
|
||||
“open related view” (diff/pager) can be offered per cell and behave deterministically, because the
|
||||
UI can address cells by stable IDs rather than by fragile screen coordinates.
|
||||
- **Additional affordances.** Consider an ephemeral scrollbar and/or a more explicit “selecting…”
|
||||
status if footer hints aren’t sufficient.
|
||||
- **UX capture.** Maintain short “golden path” clips showing scrolling (mouse + keys), selection and
|
||||
copy, streaming under resize, and suspend/resume + exit printing.
|
||||
|
||||
### 10.3 Open questions
|
||||
|
||||
This section collects design questions that follow naturally from the current model and are worth
|
||||
explicit discussion before we commit to further UI changes.
|
||||
|
||||
- **“Scroll mode” vs “live follow” UI.**
|
||||
|
||||
- We already distinguish “scrolled away from bottom” vs “following the latest output” in the
|
||||
footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g.,
|
||||
a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome
|
||||
would be noise?
|
||||
|
||||
- **Ephemeral scroll indicator.**
|
||||
|
||||
- For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist
|
||||
scrollbar that appears while the user is actively scrolling and fades out when idle. A full
|
||||
“mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could
|
||||
@@ -524,19 +427,16 @@ explicit discussion before we commit to further UI changes.
|
||||
where text search matches are, without trying to render a full preview of the buffer.
|
||||
|
||||
- **Selection affordances.**
|
||||
|
||||
- Today, the primary hint that selection is active is the reversed text and the “Ctrl+Y copy
|
||||
selection” footer text. Do we want an explicit “Selecting… (Esc to cancel)” status while a drag
|
||||
is in progress, or would that be redundant/clutter for most users?
|
||||
|
||||
- **Suspend banners in scrollback.**
|
||||
|
||||
- When printing history on suspend, should we also emit a small banner such as
|
||||
`--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback?
|
||||
This would slightly increase noise but could make multi‑suspend sessions easier to read.
|
||||
|
||||
- **Configuring suspend printing behavior.**
|
||||
|
||||
- The design already assumes that suspend‑time printing can be gated by config. Questions to
|
||||
resolve:
|
||||
- Should printing on suspend be on or off by default?
|
||||
@@ -551,3 +451,4 @@ explicit discussion before we commit to further UI changes.
|
||||
suspend‑time printing be our escape hatch for users who care about exact de‑duplication?\*\*\*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use codex_core::ConversationsPage;
|
||||
use codex_core::Cursor;
|
||||
use codex_core::INTERACTIVE_SESSION_SOURCES;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::path_utils;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -671,10 +670,7 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
if let (Ok(ca), Ok(cb)) = (
|
||||
path_utils::normalize_for_path_comparison(a),
|
||||
path_utils::normalize_for_path_comparison(b),
|
||||
) {
|
||||
if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) {
|
||||
return ca == cb;
|
||||
}
|
||||
a == b
|
||||
|
||||
Reference in New Issue
Block a user