Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd030e0
Add ephemeral side conversations
etraut-openai Apr 16, 2026
513998a
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 16, 2026
a83aa7c
Hide parent transcript in side conversations
etraut-openai Apr 16, 2026
24be0fd
codex: address PR review feedback (#18190)
etraut-openai Apr 17, 2026
fca718e
codex: suppress side conversation preamble (#18190)
etraut-openai Apr 17, 2026
732ed5d
codex: address side conversation review feedback (#18190)
etraut-openai Apr 17, 2026
41a5ed5
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 17, 2026
3ce0841
codex: tighten side conversation boundary
etraut-openai Apr 17, 2026
48fa6f9
codex: address side teardown review feedback
etraut-openai Apr 17, 2026
08ff218
codex: handle closed side failover
etraut-openai Apr 17, 2026
4a12713
codex: hide parent state from side conversations
etraut-openai Apr 17, 2026
3566528
codex: keep failed side cleanup visible
etraut-openai Apr 17, 2026
1b77a1c
codex: clear full terminal on thread switch
etraut-openai Apr 17, 2026
823d34e
Merge branch 'main' into etraut/side-conversation
etraut-openai Apr 17, 2026
ccb693f
codex: simplify side conversation tool handling
etraut-openai Apr 17, 2026
06a7171
Refine side conversation behavior
etraut-openai Apr 17, 2026
0e15258
codex: address PR review feedback (#18190)
etraut-openai Apr 17, 2026
b29707f
codex: fix CI failure on PR #18190
etraut-openai Apr 17, 2026
f761411
codex: stabilize side snapshot on windows
etraut-openai Apr 17, 2026
45e434b
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 17, 2026
745bdc6
Merge branch 'main' into etraut/side-conversation
etraut-openai Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions codex-rs/app-server-protocol/schema/json/ClientRequest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2784,6 +2784,16 @@
},
"threadId": {
"type": "string"
},
"toolAccessPolicy": {
"anyOf": [
{
"$ref": "#/definitions/ToolAccessPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down Expand Up @@ -3383,6 +3393,13 @@
],
"type": "object"
},
"ToolAccessPolicy": {
"enum": [
"default",
"noExternalTools"
],
"type": "string"
},
"TurnInterruptParams": {
"properties": {
"threadId": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12985,6 +12985,16 @@
},
"threadId": {
"type": "string"
},
"toolAccessPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/ToolAccessPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down Expand Up @@ -15024,6 +15034,13 @@
],
"type": "object"
},
"ToolAccessPolicy": {
"enum": [
"default",
"noExternalTools"
],
"type": "string"
},
"ToolsV2": {
"properties": {
"view_image": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10829,6 +10829,16 @@
},
"threadId": {
"type": "string"
},
"toolAccessPolicy": {
"anyOf": [
{
"$ref": "#/definitions/ToolAccessPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down Expand Up @@ -12868,6 +12878,13 @@
],
"type": "object"
},
"ToolAccessPolicy": {
"enum": [
"default",
"noExternalTools"
],
"type": "string"
},
"ToolsV2": {
"properties": {
"view_image": {
Expand Down
17 changes: 17 additions & 0 deletions codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@
"flex"
],
"type": "string"
},
"ToolAccessPolicy": {
"enum": [
"default",
"noExternalTools"
],
"type": "string"
}
},
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
Expand Down Expand Up @@ -168,6 +175,16 @@
},
"threadId": {
"type": "string"
},
"toolAccessPolicy": {
"anyOf": [
{
"$ref": "#/definitions/ToolAccessPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
import type { ToolAccessPolicy } from "./ToolAccessPolicy";

/**
* There are two ways to fork a thread:
Expand All @@ -27,7 +28,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, toolAccessPolicy?: ToolAccessPolicy | null, ephemeral?: boolean, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!

// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ToolAccessPolicy = "default" | "noExternalTools";
1 change: 1 addition & 0 deletions codex-rs/app-server-protocol/schema/typescript/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export type { ThreadUnsubscribeParams } from "./ThreadUnsubscribeParams";
export type { ThreadUnsubscribeResponse } from "./ThreadUnsubscribeResponse";
export type { ThreadUnsubscribeStatus } from "./ThreadUnsubscribeStatus";
export type { TokenUsageBreakdown } from "./TokenUsageBreakdown";
export type { ToolAccessPolicy } from "./ToolAccessPolicy";
export type { ToolRequestUserInputAnswer } from "./ToolRequestUserInputAnswer";
export type { ToolRequestUserInputOption } from "./ToolRequestUserInputOption";
export type { ToolRequestUserInputParams } from "./ToolRequestUserInputParams";
Expand Down
30 changes: 30 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode as CoreSandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ToolAccessPolicy as CoreToolAccessPolicy;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
Expand Down Expand Up @@ -358,6 +359,13 @@ impl From<CoreSandboxMode> for SandboxMode {
}
}

v2_enum_from_core!(
pub enum ToolAccessPolicy from CoreToolAccessPolicy {
Default,
NoExternalTools
}
);

v2_enum_from_core!(
pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery {
Inline, Detached
Expand Down Expand Up @@ -2919,6 +2927,8 @@ pub struct ThreadForkParams {
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
pub developer_instructions: Option<String>,
#[ts(optional = nullable)]
pub tool_access_policy: Option<ToolAccessPolicy>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ephemeral: bool,
/// If true, persist additional rollout EventMsg variants required to
Expand Down Expand Up @@ -8714,6 +8724,26 @@ mod tests {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}

#[test]
fn thread_fork_params_round_trip_tool_access_policy() {
let params: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thr_123",
"ephemeral": true,
"toolAccessPolicy": "noExternalTools"
}))
.expect("params should deserialize");

assert_eq!(
params.tool_access_policy,
Some(ToolAccessPolicy::NoExternalTools)
);
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("toolAccessPolicy"),
Some(&json!("noExternalTools"))
);
}

#[test]
fn thread_lifecycle_responses_default_missing_instruction_sources() {
let response = json!({
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { … } } }
```

Ephemeral forks can also pass `toolAccessPolicy: "noExternalTools"` to disable MCP servers, app connector tools, and dynamic external tools for the fork while leaving built-in Codex tools available. This policy is rejected for non-ephemeral forks.

Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously.

### Example: List threads (with pagination & filters)
Expand Down
24 changes: 23 additions & 1 deletion codex-rs/app-server/src/codex_message_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ use codex_app_server_protocol::ThreadUnarchivedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::ToolAccessPolicy;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
Expand Down Expand Up @@ -4563,10 +4564,21 @@ impl CodexMessageProcessor {
config: cli_overrides,
base_instructions,
developer_instructions,
tool_access_policy,
ephemeral,
persist_extended_history,
} = params;

if matches!(tool_access_policy, Some(ToolAccessPolicy::NoExternalTools)) && !ephemeral {
self.send_invalid_request_error(
request_id,
"toolAccessPolicy=noExternalTools is only supported for ephemeral forks"
.to_string(),
)
.await;
return;
}

let (rollout_path, source_thread_id) = if let Some(path) = path {
(path, None)
} else {
Expand Down Expand Up @@ -4686,6 +4698,9 @@ impl CodexMessageProcessor {
config,
rollout_path.clone(),
persist_extended_history,
tool_access_policy
.map(ToolAccessPolicy::to_core)
.unwrap_or_default(),
self.request_trace_context(&request_id).await,
)
.await
Expand Down Expand Up @@ -4869,7 +4884,11 @@ impl CodexMessageProcessor {
)
.await;

let notif = ThreadStartedNotification { thread };
let mut notification_thread = thread;
notification_thread.turns.clear();
let notif = ThreadStartedNotification {
thread: notification_thread,
};
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
Expand Down Expand Up @@ -7418,6 +7437,7 @@ impl CodexMessageProcessor {
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
}
let parent_tool_access_policy = parent_thread.config_snapshot().await.tool_access_policy;

let NewThread {
thread_id,
Expand All @@ -7431,6 +7451,7 @@ impl CodexMessageProcessor {
config,
rollout_path,
/*persist_extended_history*/ false,
parent_tool_access_policy,
self.request_trace_context(request_id).await,
)
.await
Expand Down Expand Up @@ -9832,6 +9853,7 @@ mod tests {
reasoning_effort: None,
personality: None,
session_source: SessionSource::Cli,
tool_access_policy: Default::default(),
};

assert_eq!(
Expand Down
49 changes: 47 additions & 2 deletions codex-rs/app-server/tests/suite/v2/thread_fork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::ToolAccessPolicy;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
Expand Down Expand Up @@ -183,7 +184,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
);
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
assert_eq!(started.thread, thread);
let mut expected_started_thread = thread.clone();
expected_started_thread.turns.clear();
assert_eq!(started.thread, expected_started_thread);

Ok(())
}
Expand Down Expand Up @@ -427,6 +430,45 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
Ok(())
}

#[tokio::test]
async fn thread_fork_rejects_external_tool_policy_for_persistent_forks() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;

let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;

let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
tool_access_policy: Some(ToolAccessPolicy::NoExternalTools),
..Default::default()
})
.await?;
let fork_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
)
.await??;

assert_eq!(
fork_err.error.message,
"toolAccessPolicy=noExternalTools is only supported for ephemeral forks"
);

Ok(())
}

#[tokio::test]
async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
Expand All @@ -450,6 +492,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id.clone(),
ephemeral: true,
tool_access_policy: Some(ToolAccessPolicy::NoExternalTools),
..Default::default()
})
.await?;
Expand Down Expand Up @@ -536,7 +579,9 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
);
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
assert_eq!(started.thread, thread);
let mut expected_started_thread = thread.clone();
expected_started_thread.turns.clear();
assert_eq!(started.thread, expected_started_thread);

let list_id = mcp
.send_thread_list_request(ThreadListParams {
Expand Down
Loading
Loading