Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	codex-rs/core/src/tools/handlers/unified_exec.rs
This commit is contained in:
Abhinav Vedmala
2026-05-05 15:16:01 -07:00
275 changed files with 12915 additions and 3388 deletions
+10
View File
@@ -183,5 +183,15 @@ common:ci-v8 --build_metadata=TAG_os=linux
common:ci-v8 --config=remote
common:ci-v8 --strategy=remote
# Source-built Bazel V8 artifacts use the in-process sandbox by default. This
# does not affect Cargo's default prebuilt rusty_v8 path.
common --@v8//:v8_enable_pointer_compression=True
common --@v8//:v8_enable_sandbox=True
# Keep currently published rusty_v8 release artifacts non-sandboxed until the
# artifact migration ships matching Rust feature selection for Cargo consumers.
common:v8-release-compat --@v8//:v8_enable_pointer_compression=False
common:v8-release-compat --@v8//:v8_enable_sandbox=False
# Optional per-user local overrides.
try-import %workspace%/user.bazelrc
+31 -8
View File
@@ -63,8 +63,10 @@ def bazel_output_files(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> list[Path]:
expression = "set(" + " ".join(labels) + ")"
bazel_configs = bazel_configs or []
result = subprocess.run(
[
"bazel",
@@ -72,6 +74,7 @@ def bazel_output_files(
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
"--output=files",
expression,
],
@@ -87,7 +90,9 @@ def bazel_build(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> None:
bazel_configs = bazel_configs or []
subprocess.run(
[
"bazel",
@@ -95,6 +100,7 @@ def bazel_build(
"-c",
compilation_mode,
f"--platforms=@llvm//platforms:{platform}",
*[f"--config={config}" for config in bazel_configs],
*labels,
],
cwd=ROOT,
@@ -106,13 +112,14 @@ def ensure_bazel_output_files(
platform: str,
labels: list[str],
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> list[Path]:
outputs = bazel_output_files(platform, labels, compilation_mode)
outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs)
if all(path.exists() for path in outputs):
return outputs
bazel_build(platform, labels, compilation_mode)
outputs = bazel_output_files(platform, labels, compilation_mode)
bazel_build(platform, labels, compilation_mode, bazel_configs)
outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs)
missing = [str(path) for path in outputs if not path.exists()]
if missing:
raise SystemExit(f"missing built outputs for {labels}: {missing}")
@@ -187,8 +194,9 @@ def single_bazel_output_file(
platform: str,
label: str,
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> Path:
outputs = ensure_bazel_output_files(platform, [label], compilation_mode)
outputs = ensure_bazel_output_files(platform, [label], compilation_mode, bazel_configs)
if len(outputs) != 1:
raise SystemExit(f"expected exactly one output for {label}, found {outputs}")
return outputs[0]
@@ -198,11 +206,17 @@ def merged_musl_archive(
platform: str,
lib_path: Path,
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> Path:
llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode)
llvm_ranlib = single_bazel_output_file(platform, LLVM_RANLIB_LABEL, compilation_mode)
llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode, bazel_configs)
llvm_ranlib = single_bazel_output_file(
platform,
LLVM_RANLIB_LABEL,
compilation_mode,
bazel_configs,
)
runtime_archives = [
single_bazel_output_file(platform, label, compilation_mode)
single_bazel_output_file(platform, label, compilation_mode, bazel_configs)
for label in MUSL_RUNTIME_ARCHIVE_LABELS
]
@@ -233,11 +247,13 @@ def stage_release_pair(
target: str,
output_dir: Path,
compilation_mode: str = "fastbuild",
bazel_configs: list[str] | None = None,
) -> None:
outputs = ensure_bazel_output_files(
platform,
[release_pair_label(target)],
compilation_mode,
bazel_configs,
)
try:
@@ -254,7 +270,7 @@ def stage_release_pair(
staged_library = output_dir / staged_archive_name(target, lib_path)
staged_binding = output_dir / f"src_binding_release_{target}.rs"
source_archive = (
merged_musl_archive(platform, lib_path, compilation_mode)
merged_musl_archive(platform, lib_path, compilation_mode, bazel_configs)
if is_musl_archive_target(target, lib_path)
else lib_path
)
@@ -293,6 +309,12 @@ def parse_args() -> argparse.Namespace:
stage_release_pair_parser.add_argument("--platform", required=True)
stage_release_pair_parser.add_argument("--target", required=True)
stage_release_pair_parser.add_argument("--output-dir", required=True)
stage_release_pair_parser.add_argument(
"--bazel-config",
action="append",
default=[],
dest="bazel_configs",
)
stage_release_pair_parser.add_argument(
"--compilation-mode",
default="fastbuild",
@@ -330,6 +352,7 @@ def main() -> int:
target=args.target,
output_dir=Path(args.output_dir),
compilation_mode=args.compilation_mode,
bazel_configs=args.bazel_configs,
)
return 0
if args.command == "resolved-v8-crate-version":
@@ -25,7 +25,10 @@ TOP_LEVEL_NAME_EXCEPTIONS = {
UTILITY_NAME_EXCEPTIONS = {
"path-utils": "codex-utils-path",
}
MANIFEST_FEATURE_EXCEPTIONS = {}
MANIFEST_FEATURE_EXCEPTIONS = {
"codex-rs/code-mode/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)},
"codex-rs/v8-poc/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)},
}
OPTIONAL_DEPENDENCY_EXCEPTIONS = set()
INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {}
+2
View File
@@ -111,6 +111,7 @@ jobs:
-c
opt
"--platforms=@llvm//platforms:${PLATFORM}"
--config=v8-release-compat
"${pair_target}"
"${extra_targets[@]}"
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
@@ -134,6 +135,7 @@ jobs:
--platform "${PLATFORM}" \
--target "${TARGET}" \
--compilation-mode opt \
--bazel-config v8-release-compat \
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
+2
View File
@@ -105,6 +105,7 @@ jobs:
bazel_args=(
build
"--platforms=@llvm//platforms:${PLATFORM}"
--config=v8-release-compat
"${pair_target}"
"${extra_targets[@]}"
--build_metadata=COMMIT_SHA=$(git rev-parse HEAD)
@@ -127,6 +128,7 @@ jobs:
python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \
--platform "${PLATFORM}" \
--target "${TARGET}" \
--bazel-config v8-release-compat \
--output-dir "dist/${TARGET}"
- name: Upload staged musl artifacts
+12
View File
@@ -327,6 +327,18 @@ crate.annotation(
"RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)",
},
crate = "v8",
# Keep the Rust feature aligned with the source-built Bazel artifacts.
# Windows MSVC still consumes upstream non-sandboxed prebuilts.
crate_features_select = {
"aarch64-apple-darwin": ["v8_enable_sandbox"],
"aarch64-pc-windows-gnullvm": ["v8_enable_sandbox"],
"aarch64-unknown-linux-gnu": ["v8_enable_sandbox"],
"aarch64-unknown-linux-musl": ["v8_enable_sandbox"],
"x86_64-apple-darwin": ["v8_enable_sandbox"],
"x86_64-pc-windows-gnullvm": ["v8_enable_sandbox"],
"x86_64-unknown-linux-gnu": ["v8_enable_sandbox"],
"x86_64-unknown-linux-musl": ["v8_enable_sandbox"],
},
gen_build_script = "on",
patch_args = ["-p1"],
patches = [
+4
View File
@@ -2411,6 +2411,7 @@ dependencies = [
"bm25",
"chrono",
"clap",
"codex-agent-graph-store",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
@@ -2700,6 +2701,7 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"sha2",
"tempfile",
"test-case",
"thiserror 2.0.18",
@@ -2708,6 +2710,7 @@ dependencies = [
"tokio-util",
"tracing",
"uuid",
"wiremock",
]
[[package]]
@@ -4219,6 +4222,7 @@ dependencies = [
"codex-core",
"codex-exec-server",
"codex-features",
"codex-hooks",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -3,12 +3,16 @@ use crate::events::AppServerRpcTransport;
use crate::events::CodexAppMentionedEventRequest;
use crate::events::CodexAppServerClientMetadata;
use crate::events::CodexAppUsedEventRequest;
use crate::events::CodexCommandExecutionEventParams;
use crate::events::CodexCommandExecutionEventRequest;
use crate::events::CodexCompactionEventRequest;
use crate::events::CodexHookRunEventRequest;
use crate::events::CodexPluginEventRequest;
use crate::events::CodexPluginUsedEventRequest;
use crate::events::CodexRuntimeMetadata;
use crate::events::CodexToolItemEventBase;
use crate::events::CodexTurnEventRequest;
use crate::events::CommandExecutionSource;
use crate::events::GuardianApprovalRequestSource;
use crate::events::GuardianReviewDecision;
use crate::events::GuardianReviewEventParams;
@@ -17,6 +21,8 @@ use crate::events::GuardianReviewTerminalStatus;
use crate::events::GuardianReviewedAction;
use crate::events::ThreadInitializedEvent;
use crate::events::ThreadInitializedEventParams;
use crate::events::ToolItemFinalApprovalOutcome;
use crate::events::ToolItemTerminalStatus;
use crate::events::TrackEventRequest;
use crate::events::codex_app_metadata;
use crate::events::codex_hook_run_metadata;
@@ -240,6 +246,7 @@ fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload {
ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse {
turn: Turn {
id: turn_id.to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![],
status: AppServerTurnStatus::InProgress,
error: None,
@@ -255,6 +262,7 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot
thread_id: thread_id.to_string(),
turn: Turn {
id: turn_id.to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![],
status: AppServerTurnStatus::InProgress,
error: None,
@@ -289,6 +297,7 @@ fn sample_turn_completed_notification(
thread_id: thread_id.to_string(),
turn: Turn {
id: turn_id.to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![],
status,
error: codex_error_info.map(|codex_error_info| AppServerTurnError {
@@ -884,6 +893,103 @@ fn thread_initialized_event_serializes_expected_shape() {
);
}
#[test]
fn command_execution_event_serializes_expected_shape() {
let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest {
event_type: "codex_command_execution_event",
event_params: CodexCommandExecutionEventParams {
base: CodexToolItemEventBase {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
app_server_client: CodexAppServerClientMetadata {
product_client_id: "codex_tui".to_string(),
client_name: Some("codex-tui".to_string()),
client_version: Some("1.2.3".to_string()),
rpc_transport: AppServerRpcTransport::Websocket,
experimental_api_enabled: Some(true),
},
runtime: CodexRuntimeMetadata {
codex_rs_version: "0.99.0".to_string(),
runtime_os: "macos".to_string(),
runtime_os_version: "15.3.1".to_string(),
runtime_arch: "aarch64".to_string(),
},
thread_source: Some("user"),
subagent_source: None,
parent_thread_id: None,
tool_name: "shell".to_string(),
started_at_ms: 123_000,
completed_at_ms: 125_000,
duration_ms: Some(2000),
review_count: 0,
guardian_review_count: 0,
user_review_count: 0,
final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded,
terminal_status: ToolItemTerminalStatus::Completed,
failure_kind: None,
requested_additional_permissions: false,
requested_network_access: false,
},
command_execution_source: CommandExecutionSource::Agent,
exit_code: Some(0),
command_total_action_count: 4,
command_read_action_count: 1,
command_list_files_action_count: 1,
command_search_action_count: 1,
command_unknown_action_count: 1,
},
});
let payload = serde_json::to_value(&event).expect("serialize command execution event");
assert_eq!(
payload,
json!({
"event_type": "codex_command_execution_event",
"event_params": {
"thread_id": "thread-1",
"turn_id": "turn-1",
"item_id": "item-1",
"app_server_client": {
"product_client_id": "codex_tui",
"client_name": "codex-tui",
"client_version": "1.2.3",
"rpc_transport": "websocket",
"experimental_api_enabled": true
},
"runtime": {
"codex_rs_version": "0.99.0",
"runtime_os": "macos",
"runtime_os_version": "15.3.1",
"runtime_arch": "aarch64"
},
"thread_source": "user",
"subagent_source": null,
"parent_thread_id": null,
"tool_name": "shell",
"started_at_ms": 123000,
"completed_at_ms": 125000,
"duration_ms": 2000,
"review_count": 0,
"guardian_review_count": 0,
"user_review_count": 0,
"final_approval_outcome": "not_needed",
"terminal_status": "completed",
"failure_kind": null,
"requested_additional_permissions": false,
"requested_network_access": false,
"command_execution_source": "agent",
"exit_code": 0,
"command_total_action_count": 4,
"command_read_action_count": 1,
"command_list_files_action_count": 1,
"command_search_action_count": 1,
"command_unknown_action_count": 1
}
})
);
}
#[tokio::test]
async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() {
let mut reducer = AnalyticsReducer::default();
+1
View File
@@ -154,6 +154,7 @@ fn sample_turn_start_response() -> ClientResponsePayload {
ClientResponsePayload::TurnStart(TurnStartResponse {
turn: Turn {
id: "turn-1".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
+216
View File
@@ -61,6 +61,20 @@ pub(crate) enum TrackEventRequest {
Compaction(Box<CodexCompactionEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
TurnSteer(CodexTurnSteerEventRequest),
#[allow(dead_code)]
CommandExecution(CodexCommandExecutionEventRequest),
#[allow(dead_code)]
FileChange(CodexFileChangeEventRequest),
#[allow(dead_code)]
McpToolCall(CodexMcpToolCallEventRequest),
#[allow(dead_code)]
DynamicToolCall(CodexDynamicToolCallEventRequest),
#[allow(dead_code)]
CollabAgentToolCall(CodexCollabAgentToolCallEventRequest),
#[allow(dead_code)]
WebSearch(CodexWebSearchEventRequest),
#[allow(dead_code)]
ImageGeneration(CodexImageGenerationEventRequest),
PluginUsed(CodexPluginUsedEventRequest),
PluginInstalled(CodexPluginEventRequest),
PluginUninstalled(CodexPluginEventRequest),
@@ -386,6 +400,208 @@ pub(crate) struct GuardianReviewEventPayload {
pub(crate) guardian_review: GuardianReviewEventParams,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ToolItemFinalApprovalOutcome {
Unknown,
NotNeeded,
ConfigAllowed,
PolicyForbidden,
GuardianApproved,
GuardianDenied,
GuardianAborted,
UserApproved,
UserApprovedForSession,
UserDenied,
UserAborted,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ToolItemTerminalStatus {
Completed,
Failed,
Rejected,
Interrupted,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ToolItemFailureKind {
ToolError,
ApprovalDenied,
ApprovalAborted,
SandboxDenied,
PolicyForbidden,
}
#[derive(Serialize)]
pub(crate) struct CodexToolItemEventBase {
pub(crate) thread_id: String,
pub(crate) turn_id: String,
/// App-server ThreadItem.id. For tool-originated items this generally
/// corresponds to the originating core call_id.
pub(crate) item_id: String,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) thread_source: Option<&'static str>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) tool_name: String,
pub(crate) started_at_ms: u64,
pub(crate) completed_at_ms: u64,
pub(crate) duration_ms: Option<u64>,
pub(crate) review_count: u64,
pub(crate) guardian_review_count: u64,
pub(crate) user_review_count: u64,
pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome,
pub(crate) terminal_status: ToolItemTerminalStatus,
pub(crate) failure_kind: Option<ToolItemFailureKind>,
pub(crate) requested_additional_permissions: bool,
pub(crate) requested_network_access: bool,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CommandExecutionSource {
Agent,
UserShell,
UnifiedExecStartup,
UnifiedExecInteraction,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WebSearchActionKind {
Search,
OpenPage,
FindInPage,
Other,
}
#[derive(Serialize)]
pub(crate) struct CodexCommandExecutionEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) command_execution_source: CommandExecutionSource,
pub(crate) exit_code: Option<i32>,
pub(crate) command_total_action_count: u64,
pub(crate) command_read_action_count: u64,
pub(crate) command_list_files_action_count: u64,
pub(crate) command_search_action_count: u64,
pub(crate) command_unknown_action_count: u64,
}
#[derive(Serialize)]
pub(crate) struct CodexCommandExecutionEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexCommandExecutionEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexFileChangeEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) file_change_count: u64,
pub(crate) file_add_count: u64,
pub(crate) file_update_count: u64,
pub(crate) file_delete_count: u64,
pub(crate) file_move_count: u64,
}
#[derive(Serialize)]
pub(crate) struct CodexFileChangeEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexFileChangeEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexMcpToolCallEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) mcp_server_name: String,
pub(crate) mcp_tool_name: String,
pub(crate) mcp_error_present: bool,
}
#[derive(Serialize)]
pub(crate) struct CodexMcpToolCallEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexMcpToolCallEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexDynamicToolCallEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) dynamic_tool_name: String,
pub(crate) success: Option<bool>,
pub(crate) output_content_item_count: Option<u64>,
pub(crate) output_text_item_count: Option<u64>,
pub(crate) output_image_item_count: Option<u64>,
}
#[derive(Serialize)]
pub(crate) struct CodexDynamicToolCallEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexDynamicToolCallEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexCollabAgentToolCallEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) sender_thread_id: String,
pub(crate) receiver_thread_count: u64,
pub(crate) receiver_thread_ids: Option<Vec<String>>,
pub(crate) requested_model: Option<String>,
pub(crate) requested_reasoning_effort: Option<String>,
pub(crate) agent_state_count: Option<u64>,
pub(crate) completed_agent_count: Option<u64>,
pub(crate) failed_agent_count: Option<u64>,
}
#[derive(Serialize)]
pub(crate) struct CodexCollabAgentToolCallEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexCollabAgentToolCallEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexWebSearchEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) web_search_action: Option<WebSearchActionKind>,
pub(crate) query_present: bool,
pub(crate) query_count: Option<u64>,
}
#[derive(Serialize)]
pub(crate) struct CodexWebSearchEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexWebSearchEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexImageGenerationEventParams {
#[serde(flatten)]
pub(crate) base: CodexToolItemEventBase,
pub(crate) image_generation_status: String,
pub(crate) revised_prompt_present: bool,
pub(crate) saved_path_present: bool,
}
#[derive(Serialize)]
pub(crate) struct CodexImageGenerationEventRequest {
pub(crate) event_type: &'static str,
pub(crate) event_params: CodexImageGenerationEventParams,
}
#[derive(Serialize)]
pub(crate) struct CodexAppMetadata {
pub(crate) connector_id: Option<String>,
+3 -1
View File
@@ -29,7 +29,6 @@ pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server::in_process::LogDbLayer;
pub use codex_app_server::in_process::StateDbHandle;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
@@ -47,6 +46,7 @@ use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
pub use codex_core::StateDbHandle;
use codex_core::config::Config;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::EnvironmentManagerArgs;
@@ -1154,6 +1154,7 @@ mod tests {
thread_id: "thread".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
@@ -1984,6 +1985,7 @@ mod tests {
thread_id: "thread".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn".to_string(),
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
@@ -4312,12 +4312,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -4400,6 +4409,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -9833,6 +9833,9 @@
"null"
]
},
"currentHash": {
"type": "string"
},
"displayOrder": {
"format": "int64",
"type": "integer"
@@ -9880,9 +9883,13 @@
"format": "uint64",
"minimum": 0.0,
"type": "integer"
},
"trustStatus": {
"$ref": "#/definitions/v2/HookTrustStatus"
}
},
"required": [
"currentHash",
"displayOrder",
"enabled",
"eventName",
@@ -9891,7 +9898,8 @@
"key",
"source",
"sourcePath",
"timeoutSec"
"timeoutSec",
"trustStatus"
],
"type": "object"
},
@@ -10081,6 +10089,15 @@
"title": "HookStartedNotification",
"type": "object"
},
"HookTrustStatus": {
"enum": [
"managed",
"untrusted",
"trusted",
"modified"
],
"type": "string"
},
"HooksListEntry": {
"properties": {
"cwd": {
@@ -17666,12 +17683,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/v2/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/v2/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -17795,6 +17821,31 @@
"title": "TurnInterruptResponse",
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -6400,6 +6400,9 @@
"null"
]
},
"currentHash": {
"type": "string"
},
"displayOrder": {
"format": "int64",
"type": "integer"
@@ -6447,9 +6450,13 @@
"format": "uint64",
"minimum": 0.0,
"type": "integer"
},
"trustStatus": {
"$ref": "#/definitions/HookTrustStatus"
}
},
"required": [
"currentHash",
"displayOrder",
"enabled",
"eventName",
@@ -6458,7 +6465,8 @@
"key",
"source",
"sourcePath",
"timeoutSec"
"timeoutSec",
"trustStatus"
],
"type": "object"
},
@@ -6648,6 +6656,15 @@
"title": "HookStartedNotification",
"type": "object"
},
"HookTrustStatus": {
"enum": [
"managed",
"untrusted",
"trusted",
"modified"
],
"type": "string"
},
"HooksListEntry": {
"properties": {
"cwd": {
@@ -15552,12 +15569,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -15681,6 +15707,31 @@
"title": "TurnInterruptResponse",
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -47,6 +47,9 @@
"null"
]
},
"currentHash": {
"type": "string"
},
"displayOrder": {
"format": "int64",
"type": "integer"
@@ -94,9 +97,13 @@
"format": "uint64",
"minimum": 0.0,
"type": "integer"
},
"trustStatus": {
"$ref": "#/definitions/HookTrustStatus"
}
},
"required": [
"currentHash",
"displayOrder",
"enabled",
"eventName",
@@ -105,7 +112,8 @@
"key",
"source",
"sourcePath",
"timeoutSec"
"timeoutSec",
"trustStatus"
],
"type": "object"
},
@@ -124,6 +132,15 @@
],
"type": "string"
},
"HookTrustStatus": {
"enum": [
"managed",
"untrusted",
"trusted",
"modified"
],
"type": "string"
},
"HooksListEntry": {
"properties": {
"cwd": {
@@ -1324,12 +1324,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1377,6 +1386,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -2225,12 +2225,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -2278,6 +2287,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -2225,12 +2225,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -2278,6 +2287,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -2225,12 +2225,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -2278,6 +2287,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1675,12 +1675,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1728,6 +1737,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1324,12 +1324,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1377,6 +1386,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1324,12 +1324,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1377,6 +1386,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -1324,12 +1324,21 @@
"type": "string"
},
"items": {
"description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.",
"description": "Thread items currently included in this turn payload.",
"items": {
"$ref": "#/definitions/ThreadItem"
},
"type": "array"
},
"itemsView": {
"allOf": [
{
"$ref": "#/definitions/TurnItemsView"
}
],
"default": "full",
"description": "Describes how much of `items` has been loaded for this turn."
},
"startedAt": {
"description": "Unix timestamp (in seconds) when the turn started.",
"format": "int64",
@@ -1377,6 +1386,31 @@
],
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
},
"TurnStatus": {
"enum": [
"completed",
@@ -5,5 +5,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { HookEventName } from "./HookEventName";
import type { HookHandlerType } from "./HookHandlerType";
import type { HookSource } from "./HookSource";
import type { HookTrustStatus } from "./HookTrustStatus";
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, };
export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, };
@@ -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 HookTrustStatus = "managed" | "untrusted" | "trusted" | "modified";
+7 -4
View File
@@ -3,15 +3,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadItem } from "./ThreadItem";
import type { TurnError } from "./TurnError";
import type { TurnItemsView } from "./TurnItemsView";
import type { TurnStatus } from "./TurnStatus";
export type Turn = { id: string,
/**
* Only populated on a `thread/resume` or `thread/fork` response.
* For all other responses and notifications returning a Turn,
* the items field will be an empty list.
* Thread items currently included in this turn payload.
*/
items: Array<ThreadItem>, status: TurnStatus,
items: Array<ThreadItem>,
/**
* Describes how much of `items` has been loaded for this turn.
*/
itemsView: TurnItemsView, status: TurnStatus,
/**
* Only populated when the Turn's status is failed.
*/
@@ -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 TurnItemsView = "notLoaded" | "summary" | "full";
@@ -168,6 +168,7 @@ export type { HookRunSummary } from "./HookRunSummary";
export type { HookScope } from "./HookScope";
export type { HookSource } from "./HookSource";
export type { HookStartedNotification } from "./HookStartedNotification";
export type { HookTrustStatus } from "./HookTrustStatus";
export type { HooksListEntry } from "./HooksListEntry";
export type { HooksListParams } from "./HooksListParams";
export type { HooksListResponse } from "./HooksListResponse";
@@ -424,6 +425,7 @@ export type { TurnEnvironmentParams } from "./TurnEnvironmentParams";
export type { TurnError } from "./TurnError";
export type { TurnInterruptParams } from "./TurnInterruptParams";
export type { TurnInterruptResponse } from "./TurnInterruptResponse";
export type { TurnItemsView } from "./TurnItemsView";
export type { TurnPlanStep } from "./TurnPlanStep";
export type { TurnPlanStepStatus } from "./TurnPlanStepStatus";
export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification";
@@ -17,6 +17,7 @@ use crate::protocol::v2::ThreadItem;
use crate::protocol::v2::Turn;
use crate::protocol::v2::TurnError as V2TurnError;
use crate::protocol::v2::TurnError;
use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
use crate::protocol::v2::WebSearchAction;
@@ -1166,6 +1167,7 @@ impl From<PendingTurn> for Turn {
Self {
id: value.id,
items: value.items,
items_view: TurnItemsView::Full,
error: value.error,
status: value.status,
started_at: value.started_at,
@@ -1180,6 +1182,7 @@ impl From<&PendingTurn> for Turn {
Self {
id: value.id.clone(),
items: value.items.clone(),
items_view: TurnItemsView::Full,
error: value.error.clone(),
status: value.status.clone(),
started_at: value.started_at,
@@ -1453,6 +1456,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: vec![
ThreadItem::UserMessage {
id: "item-1".into(),
@@ -2723,6 +2727,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: Vec::new(),
}]
);
@@ -2982,6 +2987,7 @@ mod tests {
started_at: None,
completed_at: None,
duration_ms: None,
items_view: TurnItemsView::Full,
items: vec![ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
+51 -12
View File
@@ -80,6 +80,7 @@ use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus;
use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary;
use codex_protocol::protocol::HookScope as CoreHookScope;
use codex_protocol::protocol::HookSource as CoreHookSource;
use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus;
use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason;
use codex_protocol::protocol::ModelVerification as CoreModelVerification;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
@@ -482,6 +483,12 @@ v2_enum_from_core!(
}
);
v2_enum_from_core!(
pub enum HookTrustStatus from CoreHookTrustStatus {
Managed, Untrusted, Trusted, Modified
}
);
fn default_hook_source() -> HookSource {
HookSource::Unknown
}
@@ -3841,9 +3848,9 @@ pub struct ThreadStartParams {
#[experimental("thread/start.experimentalRawEvents")]
#[serde(default)]
pub experimental_raw_events: bool,
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
/// Deprecated and ignored by app-server. Kept only so older clients can
/// continue sending the field while rollout persistence always uses the
/// limited history policy.
#[experimental("thread/start.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -3973,9 +3980,9 @@ pub struct ThreadResumeParams {
#[experimental("thread/resume.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
/// Deprecated and ignored by app-server. Kept only so older clients can
/// continue sending the field while rollout persistence always uses the
/// limited history policy.
#[experimental("thread/resume.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -4079,9 +4086,9 @@ pub struct ThreadForkParams {
#[experimental("thread/fork.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
/// Deprecated and ignored by app-server. Kept only so older clients can
/// continue sending the field while rollout persistence always uses the
/// limited history policy.
#[experimental("thread/fork.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -5032,6 +5039,8 @@ pub struct HookMetadata {
pub display_order: i64,
pub enabled: bool,
pub is_managed: bool,
pub current_hash: String,
pub trust_status: HookTrustStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -5425,10 +5434,11 @@ impl From<CoreTokenUsage> for TokenUsageBreakdown {
#[ts(export_to = "v2/")]
pub struct Turn {
pub id: String,
/// Only populated on a `thread/resume` or `thread/fork` response.
/// For all other responses and notifications returning a Turn,
/// the items field will be an empty list.
/// Thread items currently included in this turn payload.
pub items: Vec<ThreadItem>,
/// Describes how much of `items` has been loaded for this turn.
#[serde(default)]
pub items_view: TurnItemsView,
pub status: TurnStatus,
/// Only populated when the Turn's status is failed.
pub error: Option<TurnError>,
@@ -5443,6 +5453,19 @@ pub struct Turn {
pub duration_ms: Option<i64>,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum TurnItemsView {
/// `items` was not loaded for this turn. The field is intentionally empty.
NotLoaded,
/// `items` contains only a display summary for this turn.
Summary,
/// `items` contains every ThreadItem available from persisted app-server history for this turn.
#[default]
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8432,6 +8455,22 @@ mod tests {
}
}
#[test]
fn turn_defaults_legacy_missing_items_view_to_full() {
let turn: Turn = serde_json::from_value(json!({
"id": "turn_123",
"items": [],
"status": "completed",
"error": null,
"startedAt": null,
"completedAt": null,
"durationMs": null,
}))
.expect("legacy turn should deserialize");
assert_eq!(turn.items_view, TurnItemsView::Full);
}
#[test]
fn thread_list_params_accepts_single_cwd() {
let params = serde_json::from_value::<ThreadListParams>(json!({
+10 -2
View File
@@ -427,6 +427,8 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
Every returned `Turn` includes `itemsView`, which tells clients whether the `items` array was omitted intentionally (`notLoaded`), contains only summary items (`summary`), or contains every item available from persisted app-server history (`full`). Current `thread/turns/list` responses return `full` turns.
```json
{ "method": "thread/turns/list", "id": 24, "params": {
"threadId": "thr_123",
@@ -1539,7 +1541,11 @@ To enable or disable a skill by name:
}
```
Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entry is evaluated using that `cwd`'s effective config, so feature gating and discovered config layers can differ across entries in the same request. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Hook state is stored under `hooks.state`; clients should treat hooks from managed sources as non-configurable, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response.
Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading.
For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional.
```json
{
@@ -1570,7 +1576,9 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entr
"source": "user",
"pluginId": null,
"displayOrder": 0,
"enabled": true
"enabled": true,
"currentHash": "sha256:...",
"trustStatus": "untrusted"
}],
"warnings": [],
"errors": []
@@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnDiffUpdatedNotification;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
@@ -157,15 +158,19 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
let turn = {
let state = thread_state.lock().await;
state.active_turn_snapshot().unwrap_or_else(|| Turn {
let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn {
id: payload.turn_id.clone(),
items: Vec::new(),
items_view: TurnItemsView::NotLoaded,
error: None,
status: TurnStatus::InProgress,
started_at: payload.started_at,
completed_at: None,
duration_ms: None,
})
});
turn.items.clear();
turn.items_view = TurnItemsView::NotLoaded;
turn
};
let notification = TurnStartedNotification {
thread_id: conversation_id.to_string(),
@@ -1305,6 +1310,7 @@ async fn emit_turn_completed_with_status(
turn: Turn {
id: event_turn_id,
items: vec![],
items_view: TurnItemsView::NotLoaded,
error: turn_completion_metadata.error,
status: turn_completion_metadata.status,
started_at: turn_completion_metadata.started_at,
@@ -2623,7 +2629,8 @@ mod tests {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
),
)
.await,
);
let codex_core::NewThread {
thread_id: conversation_id,
@@ -3198,6 +3205,92 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn turn_started_omits_active_snapshot_items() -> Result<()> {
let codex_home = TempDir::new()?;
let config = load_default_config_for_test(&codex_home).await;
let thread_manager = Arc::new(
codex_core::test_support::thread_manager_with_models_provider_and_home(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
)
.await,
);
let codex_core::NewThread {
thread_id: conversation_id,
thread: conversation,
..
} = thread_manager.start_thread(config.clone()).await?;
let thread_state = new_thread_state();
{
let mut state = thread_state.lock().await;
state.track_current_turn_event(
"turn-1",
&EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: Some(42),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
);
state.track_current_turn_event(
"turn-1",
&EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent {
message: "already tracked".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
);
}
let thread_watch_manager = ThreadWatchManager::new();
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(
tx,
codex_analytics::AnalyticsEventsClient::disabled(),
));
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing,
vec![ConnectionId(1)],
conversation_id,
);
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: Some(42),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
},
conversation_id,
conversation,
thread_manager,
/*analytics_events_client*/ None,
outgoing,
thread_state,
thread_watch_manager,
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
)
.await;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnStarted(n)) => {
assert_eq!(n.turn.id, "turn-1");
assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded);
assert!(n.turn.items.is_empty());
}
other => bail!("unexpected message: {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> {
let conversation_id = ThreadId::new();
@@ -3245,6 +3338,8 @@ mod tests {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, event_turn_id);
assert_eq!(n.turn.status, TurnStatus::Completed);
assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded);
assert!(n.turn.items.is_empty());
assert_eq!(n.turn.error, None);
assert_eq!(n.turn.started_at, Some(42));
assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT));
+46 -44
View File
@@ -52,9 +52,9 @@ use std::time::Duration;
use crate::analytics_utils::analytics_events_client_from_config;
use crate::config_manager::ConfigManager;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::OVERLOADED_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::message_processor::ConnectionSessionState;
use crate::message_processor::MessageProcessor;
use crate::message_processor::MessageProcessorArgs;
@@ -82,11 +82,12 @@ use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::config::Config;
use codex_core::init_state_db_from_config;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
use codex_protocol::protocol::SessionSource;
pub use codex_rollout::StateDbHandle;
use codex_rollout::state_db::StateDbHandle;
pub use codex_state::log_db::LogDbLayer;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -127,7 +128,7 @@ pub struct InProcessStartArgs {
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Process-wide SQLite state handle shared with embedded app-server consumers.
/// Optional state DB handle to use for the in-process runtime.
pub state_db: Option<StateDbHandle>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
@@ -344,7 +345,7 @@ impl InProcessClientHandle {
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
let initialize = args.initialize.clone();
let client = start_uninitialized(args);
let client = start_uninitialized(args).await;
let initialize_response = client
.request(ClientRequest::Initialize {
@@ -364,8 +365,12 @@ pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle>
Ok(client)
}
fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
let channel_capacity = args.channel_capacity.max(1);
let state_db = match args.state_db.clone() {
Some(state_db) => Some(state_db),
None => init_state_db_from_config(args.config.as_ref()).await,
};
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
@@ -414,6 +419,12 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
);
let (processor_tx, mut processor_rx) = mpsc::channel::<ProcessorCommand>(channel_capacity);
let mut processor_handle = tokio::spawn(async move {
let Some(state_db) = state_db else {
warn!(
"in-process app-server state db initialization failed; shutting down processor task"
);
return;
};
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
outgoing: Arc::clone(&processor_outgoing),
analytics_events_client,
@@ -423,7 +434,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
environment_manager: args.environment_manager,
feedback: args.feedback,
log_db: args.log_db,
state_db: args.state_db,
state_db,
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,
@@ -526,11 +537,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
entry.insert(response_tx);
}
Entry::Occupied(_) => {
let _ = response_tx.send(Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("duplicate request id: {request_id:?}"),
data: None,
}));
let _ = response_tx.send(Err(invalid_request(format!(
"duplicate request id: {request_id:?}"
))));
continue;
}
}
@@ -553,13 +562,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
if let Some(response_tx) =
pending_request_responses.remove(&request_id)
{
let _ = response_tx.send(Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message:
"in-process app-server request processor is closed"
.to_string(),
data: None,
}));
let _ = response_tx.send(Err(internal_error(
"in-process app-server request processor is closed",
)));
}
break;
}
@@ -627,15 +632,20 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
if let Err(send_error) = event_tx
.try_send(InProcessServerEvent::ServerRequest(request))
{
let (code, message, inner) = match send_error {
let (error, inner) = match send_error {
mpsc::error::TrySendError::Full(inner) => (
OVERLOADED_ERROR_CODE,
"in-process server request queue is full",
JSONRPCErrorError {
code: OVERLOADED_ERROR_CODE,
message:
"in-process server request queue is full".to_string(),
data: None,
},
inner,
),
mpsc::error::TrySendError::Closed(inner) => (
INTERNAL_ERROR_CODE,
"in-process server request consumer is closed",
internal_error(
"in-process server request consumer is closed",
),
inner,
),
};
@@ -644,14 +654,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
_ => unreachable!("we just sent a ServerRequest variant"),
};
outgoing_message_sender
.notify_client_error(
request_id,
JSONRPCErrorError {
code,
message: message.to_string(),
data: None,
},
)
.notify_client_error(request_id, error)
.await;
}
}
@@ -688,21 +691,17 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
drop(writer_rx);
drop(processor_tx);
outgoing_message_sender
.cancel_all_requests(Some(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "in-process app-server runtime is shutting down".to_string(),
data: None,
}))
.cancel_all_requests(Some(internal_error(
"in-process app-server runtime is shutting down",
)))
.await;
// Drop the runtime's last sender before awaiting the router task so
// `outgoing_rx.recv()` can observe channel closure and exit cleanly.
drop(outgoing_message_sender);
for (_, response_tx) in pending_request_responses {
let _ = response_tx.send(Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "in-process app-server runtime is shutting down".to_string(),
data: None,
}));
let _ = response_tx.send(Err(internal_error(
"in-process app-server runtime is shutting down",
)));
}
if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await {
@@ -731,6 +730,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
#[cfg(test)]
mod tests {
use super::*;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::DeviceKeyPublicParams;
@@ -743,6 +743,7 @@ mod tests {
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::ConfigBuilder;
use pretty_assertions::assert_eq;
@@ -771,7 +772,7 @@ mod tests {
) -> InProcessClientHandle {
let codex_home = TempDir::new().expect("temp dir");
let config = Arc::new(build_test_config(codex_home.path()).await);
let state_db = codex_rollout::state_db::try_init(config.as_ref())
let state_db = init_state_db_from_config(config.as_ref())
.await
.expect("state db should initialize for in-process test");
let args = InProcessStartArgs {
@@ -829,7 +830,7 @@ mod tests {
}
#[tokio::test]
async fn in_process_allows_device_key_requests_to_reach_device_key_processor() {
async fn in_process_allows_device_key_requests_to_reach_device_key_api() {
let client = start_test_client(SessionSource::Cli).await;
const MALFORMED_KEY_ID_MESSAGE: &str = concat!(
"invalid device key payload: keyId must be dk_hse_, dk_tpm_, or dk_osn_ ",
@@ -972,6 +973,7 @@ mod tests {
turn: Turn {
id: "turn-1".to_string(),
items: Vec::new(),
items_view: TurnItemsView::NotLoaded,
status: TurnStatus::Completed,
error: None,
started_at: None,
+14 -24
View File
@@ -50,11 +50,11 @@ use codex_config::TextRange as CoreTextRange;
use codex_core::ExecPolicyError;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::find_codex_home;
use codex_core::init_state_db_from_config;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_rollout::state_db as rollout_state_db;
use codex_state::log_db;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -487,9 +487,9 @@ pub async fn run_main_with_transport_options(
}
};
let state_db_result = rollout_state_db::try_init(&config).await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
let state_db = init_state_db_from_config(&config)
.await
.ok_or_else(|| std::io::Error::other("failed to initialize sqlite state db"))?;
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
@@ -598,10 +598,12 @@ pub async fn run_main_with_transport_options(
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
let log_db = state_db.clone().map(log_db::start);
let log_db_layer = log_db
.clone()
.map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE)));
let log_db = log_db::start(state_db.clone());
let log_db_layer = Some(
log_db
.clone()
.with_filter(Targets::new().with_default(Level::TRACE)),
);
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
let _ = tracing_subscriber::registry()
@@ -618,10 +620,6 @@ pub async fn run_main_with_transport_options(
None => error!("{}", warning.summary),
}
}
if let Some(err) = &state_db_init_error {
error!("failed to initialize sqlite state db: {err}");
}
let transport_shutdown_token = CancellationToken::new();
let mut transport_accept_handles = Vec::<JoinHandle<()>>::new();
@@ -666,25 +664,17 @@ pub async fn run_main_with_transport_options(
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl);
let remote_control_enabled = remote_control_config_enabled && state_db.is_some();
if remote_control_config_enabled && state_db.is_none() {
error!("remote control disabled because sqlite state db is unavailable");
}
let remote_control_enabled = config.features.enabled(Feature::RemoteControl);
if transport_accept_handles.is_empty() && !remote_control_enabled {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
if remote_control_config_enabled && state_db.is_none() {
"no transport configured; remote control disabled because sqlite state db is unavailable"
} else {
"no transport configured; use --listen or enable remote control"
},
"no transport configured; use --listen or enable remote control",
));
}
let (remote_control_accept_handle, remote_control_handle) = start_remote_control(
config.chatgpt_base_url.clone(),
state_db.clone(),
Some(state_db.clone()),
auth_manager.clone(),
transport_event_tx.clone(),
transport_shutdown_token.clone(),
@@ -768,7 +758,7 @@ pub async fn run_main_with_transport_options(
config_manager,
environment_manager,
feedback: feedback.clone(),
log_db,
log_db: Some(log_db),
state_db: state_db.clone(),
config_warnings,
session_source,
+19 -6
View File
@@ -61,6 +61,7 @@ use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings;
use codex_core::ThreadManager;
use codex_core::agent_graph_store_from_state_db;
use codex_core::config::Config;
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
@@ -254,7 +255,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) environment_manager: Arc<EnvironmentManager>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) state_db: StateDbHandle,
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
pub(crate) session_source: SessionSource,
pub(crate) auth_manager: Arc<AuthManager>,
@@ -291,14 +292,16 @@ impl MessageProcessor {
// affect per-thread behavior, but they must not move newly started,
// resumed, or forked threads to a different persistence backend/root.
let thread_store = thread_store_from_config(config.as_ref(), state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let thread_manager = Arc::new(ThreadManager::new(
config.as_ref(),
auth_manager.clone(),
session_source,
environment_manager,
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
Arc::clone(&thread_store),
agent_graph_store.clone(),
));
thread_manager
.plugins_manager()
@@ -344,7 +347,7 @@ impl MessageProcessor {
Arc::clone(&config),
feedback,
log_db,
state_db.clone(),
Some(state_db.clone()),
);
let git_processor = GitRequestProcessor::new();
let initialize_processor = InitializeRequestProcessor::new(
@@ -395,7 +398,7 @@ impl MessageProcessor {
thread_watch_manager.clone(),
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db.clone(),
Some(state_db.clone()),
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -954,12 +957,22 @@ impl MessageProcessor {
}
ClientRequest::ThreadResume { params, .. } => {
self.thread_processor
.thread_resume(request_id.clone(), params)
.thread_resume(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
)
.await
}
ClientRequest::ThreadFork { params, .. } => {
self.thread_processor
.thread_fork(request_id.clone(), params)
.thread_fork(
request_id.clone(),
params,
app_server_client_name.clone(),
client_version.clone(),
)
.await
}
ClientRequest::ThreadArchive { params, .. } => {
@@ -32,6 +32,7 @@ use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::init_state_db_from_config;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
@@ -281,6 +282,9 @@ async fn build_test_processor(
outgoing_tx,
analytics_events_client.clone(),
));
let state_db = init_state_db_from_config(config.as_ref())
.await
.expect("tracing test processor requires state db");
let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
outgoing,
analytics_events_client,
@@ -290,7 +294,7 @@ async fn build_test_processor(
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
state_db,
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
auth_manager,
+11 -21
View File
@@ -21,7 +21,6 @@ use tracing::Instrument;
use tracing::Span;
use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::internal_error;
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
pub(crate) use codex_app_server_transport::ConnectionId;
@@ -157,11 +156,14 @@ impl ThreadScopedOutgoingMessageSender {
self.outgoing
.cancel_requests_for_thread(
self.thread_id,
Some(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "client request resolved because the turn state was changed"
.to_string(),
data: Some(serde_json::json!({ "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON })),
Some({
let mut error = internal_error(
"client request resolved because the turn state was changed",
);
error.data = Some(serde_json::json!({
"reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON,
}));
error
}),
)
.await
@@ -1011,11 +1013,7 @@ mod tests {
connection_id: ConnectionId(9),
request_id: RequestId::Integer(3),
};
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "boom".to_string(),
data: None,
};
let error = internal_error("boom");
outgoing.send_error(request_id.clone(), error.clone()).await;
@@ -1139,11 +1137,7 @@ mod tests {
))
.await;
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "refresh failed".to_string(),
data: None,
};
let error = internal_error("refresh failed");
outgoing
.notify_client_error(request_id, error.clone())
@@ -1253,11 +1247,7 @@ mod tests {
},
))
.await;
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "tracked request cancelled".to_string(),
data: None,
};
let error = internal_error("tracked request cancelled");
outgoing
.cancel_requests_for_thread(thread_id, Some(error.clone()))
@@ -4,9 +4,6 @@ use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_manager::ConfigManager;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::invalid_params;
use crate::models::supported_models;
use crate::outgoing_message::ConnectionId;
@@ -52,6 +49,7 @@ use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
@@ -224,6 +222,7 @@ use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -243,12 +243,9 @@ impl AccountRequestProcessor {
}
fn external_auth_active_error(&self) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it."
.to_string(),
data: None,
}
invalid_request(
"External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.",
)
}
async fn login_api_key_common(
@@ -263,11 +260,9 @@ impl AccountRequestProcessor {
self.config.forced_login_method,
Some(ForcedLoginMethod::Chatgpt)
) {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "API key login is disabled. Use ChatGPT login instead.".to_string(),
data: None,
});
return Err(invalid_request(
"API key login is disabled. Use ChatGPT login instead.",
));
}
// Cancel any active login attempt.
@@ -287,11 +282,7 @@ impl AccountRequestProcessor {
self.auth_manager.reload().await;
Ok(())
}
Err(err) => Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to save api key: {err}"),
data: None,
}),
Err(err) => Err(internal_error(format!("failed to save api key: {err}"))),
}
}
@@ -321,11 +312,9 @@ impl AccountRequestProcessor {
}
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "ChatGPT login is disabled. Use API key login instead.".to_string(),
data: None,
});
return Err(invalid_request(
"ChatGPT login is disabled. Use API key login instead.",
));
}
let opts = LoginServerOptions {
@@ -354,18 +343,10 @@ impl AccountRequestProcessor {
fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError {
let is_not_found = err.kind() == std::io::ErrorKind::NotFound;
JSONRPCErrorError {
code: if is_not_found {
INVALID_REQUEST_ERROR_CODE
} else {
INTERNAL_ERROR_CODE
},
message: if is_not_found {
err.to_string()
} else {
format!("failed to request device code: {err}")
},
data: None,
if is_not_found {
invalid_request(err.to_string())
} else {
internal_error(format!("failed to request device code: {err}"))
}
}
@@ -698,11 +679,7 @@ impl AccountRequestProcessor {
match self.auth_manager.logout_with_revoke().await {
Ok(_) => {}
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("logout failed: {err}"),
data: None,
});
return Err(internal_error(format!("logout failed: {err}")));
}
}
@@ -885,28 +862,19 @@ impl AccountRequestProcessor {
params: SendAddCreditsNudgeEmailParams,
) -> Result<AddCreditsNudgeEmailStatus, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "codex account authentication required to notify workspace owner"
.to_string(),
data: None,
});
return Err(invalid_request(
"codex account authentication required to notify workspace owner",
));
};
if !auth.uses_codex_backend() {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "chatgpt authentication required to notify workspace owner".to_string(),
data: None,
});
return Err(invalid_request(
"chatgpt authentication required to notify workspace owner",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to construct backend client: {err}"),
data: None,
})?;
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
match client
.send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type))
@@ -916,11 +884,9 @@ impl AccountRequestProcessor {
Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
Err(err) => Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to notify workspace owner: {err}"),
data: None,
}),
Err(err) => Err(internal_error(format!(
"failed to notify workspace owner: {err}"
))),
}
}
@@ -941,42 +907,28 @@ impl AccountRequestProcessor {
JSONRPCErrorError,
> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "codex account authentication required to read rate limits".to_string(),
data: None,
});
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "chatgpt authentication required to read rate limits".to_string(),
data: None,
});
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to construct backend client: {err}"),
data: None,
})?;
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let snapshots = client
.get_rate_limits_many()
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to fetch codex rate limits: {err}"),
data: None,
})?;
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if snapshots.is_empty() {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to fetch codex rate limits: no snapshots returned".to_string(),
data: None,
});
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
@@ -231,21 +231,14 @@ impl AppsRequestProcessor {
&self,
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
Ok((thread_id, thread))
}
@@ -257,11 +250,7 @@ impl AppsRequestProcessor {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
async fn workspace_codex_plugins_enabled(
@@ -319,11 +308,9 @@ fn paginate_apps(
) -> Result<AppsListResponse, JSONRPCErrorError> {
let total = connectors.len();
if start > total {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total apps {total}"),
data: None,
});
return Err(invalid_request(format!(
"cursor {start} exceeds total apps {total}"
)));
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
@@ -72,6 +72,8 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
display_order: hook.display_order,
enabled: hook.enabled,
is_managed: hook.is_managed,
current_hash: hook.current_hash.clone(),
trust_status: hook.trust_status.into(),
})
.collect()
}
@@ -190,11 +192,7 @@ impl CatalogRequestProcessor {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
async fn workspace_codex_plugins_enabled(
@@ -29,9 +29,7 @@ pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
data
});
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to load configuration: {err}"),
data,
}
let mut error = invalid_request(format!("failed to load configuration: {err}"));
error.data = data;
error
}
@@ -2,7 +2,6 @@ use std::sync::Arc;
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
@@ -612,11 +611,9 @@ fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: Some(json!({
"config_write_error_code": code,
})),
}
let mut error = invalid_request(message);
error.data = Some(json!({
"config_write_error_code": code,
}));
error
}
@@ -33,8 +33,8 @@ use codex_device_key::RemoteControlClientConnectionAudience;
use codex_device_key::RemoteControlClientConnectionSignPayload;
use codex_device_key::RemoteControlClientEnrollmentAudience;
use codex_device_key::RemoteControlClientEnrollmentSignPayload;
use codex_rollout::state_db::StateDbHandle;
use codex_state::DeviceKeyBindingRecord;
use codex_state::StateRuntime;
#[derive(Clone)]
pub(crate) struct DeviceKeyRequestProcessor {
@@ -43,10 +43,7 @@ pub(crate) struct DeviceKeyRequestProcessor {
}
impl DeviceKeyRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
state_db: Option<Arc<StateRuntime>>,
) -> Self {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>, state_db: StateDbHandle) -> Self {
Self {
outgoing,
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(state_db))),
@@ -170,25 +167,18 @@ async fn sign_device_key(
}
struct StateDeviceKeyBindingStore {
state_db: Option<Arc<StateRuntime>>,
state_db: StateDbHandle,
}
impl StateDeviceKeyBindingStore {
fn new(state_db: Option<Arc<StateRuntime>>) -> Self {
fn new(state_db: StateDbHandle) -> Self {
Self { state_db }
}
async fn state_db(&self) -> Result<Arc<StateRuntime>, DeviceKeyError> {
self.state_db
.clone()
.ok_or_else(|| DeviceKeyError::Platform("sqlite state db unavailable".to_string()))
}
}
impl fmt::Debug for StateDeviceKeyBindingStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StateDeviceKeyBindingStore")
.field("has_state_db", &self.state_db.is_some())
.finish_non_exhaustive()
}
}
@@ -196,7 +186,7 @@ impl fmt::Debug for StateDeviceKeyBindingStore {
#[async_trait]
impl DeviceKeyBindingStore for StateDeviceKeyBindingStore {
async fn get_binding(&self, key_id: &str) -> Result<Option<DeviceKeyBinding>, DeviceKeyError> {
let state_db = self.state_db().await?;
let state_db = self.state_db.clone();
state_db
.get_device_key_binding(key_id)
.await
@@ -214,7 +204,7 @@ impl DeviceKeyBindingStore for StateDeviceKeyBindingStore {
key_id: &str,
binding: &DeviceKeyBinding,
) -> Result<(), DeviceKeyError> {
let state_db = self.state_db().await?;
let state_db = self.state_db.clone();
state_db
.upsert_device_key_binding(&DeviceKeyBindingRecord {
key_id: key_id.to_string(),
@@ -132,10 +132,6 @@ impl MarketplaceRequestProcessor {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
}
@@ -89,32 +89,21 @@ impl McpRequestProcessor {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
async fn load_thread(
&self,
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
Ok((thread_id, thread))
}
@@ -130,11 +119,9 @@ impl McpRequestProcessor {
let mcp_servers = match serde_json::to_value(configured_servers) {
Ok(value) => value,
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize MCP servers: {err}"),
data: None,
});
return Err(internal_error(format!(
"failed to serialize MCP servers: {err}"
)));
}
};
@@ -142,13 +129,9 @@ impl McpRequestProcessor {
match serde_json::to_value(config.mcp_oauth_credentials_store_mode) {
Ok(value) => value,
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to serialize MCP OAuth credentials store mode: {err}"
),
data: None,
});
return Err(internal_error(format!(
"failed to serialize MCP OAuth credentials store mode: {err}"
)));
}
};
@@ -219,11 +219,7 @@ impl PluginRequestProcessor {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
async fn workspace_codex_plugins_enabled(
@@ -1307,16 +1303,17 @@ fn remote_plugin_catalog_error_to_jsonrpc(
err: RemotePluginCatalogError,
context: &str,
) -> JSONRPCErrorError {
let code = match &err {
let message = format!("{context}: {err}");
match &err {
RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => {
INVALID_REQUEST_ERROR_CODE
invalid_request(message)
}
RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => {
INVALID_REQUEST_ERROR_CODE
invalid_request(message)
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => INVALID_REQUEST_ERROR_CODE,
| RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message),
RemotePluginCatalogError::AuthToken(_)
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
@@ -1330,12 +1327,7 @@ fn remote_plugin_catalog_error_to_jsonrpc(
| RemotePluginCatalogError::ArchiveJoin(_)
| RemotePluginCatalogError::MissingUploadEtag
| RemotePluginCatalogError::UnexpectedResponse(_)
| RemotePluginCatalogError::CacheRemove(_) => INTERNAL_ERROR_CODE,
};
JSONRPCErrorError {
code,
message: format!("{context}: {err}"),
data: None,
| RemotePluginCatalogError::CacheRemove(_) => internal_error(message),
}
}
@@ -3,8 +3,8 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::fuzzy_file_search::FuzzyFileSearchSession;
use crate::fuzzy_file_search::run_fuzzy_file_search;
use crate::fuzzy_file_search::start_fuzzy_file_search_session;
@@ -132,19 +132,3 @@ impl SearchRequestProcessor {
Ok(FuzzyFileSearchSessionStopResponse {})
}
}
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: None,
}
}
fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: message.into(),
data: None,
}
}
@@ -7,7 +7,7 @@ pub(crate) struct ThreadGoalRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
state_db: Option<StateDbHandle>,
state_db: StateDbHandle,
}
impl ThreadGoalRequestProcessor {
@@ -16,7 +16,7 @@ impl ThreadGoalRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
state_db: Option<StateDbHandle>,
state_db: StateDbHandle,
) -> Self {
Self {
thread_manager,
@@ -72,23 +72,6 @@ impl ThreadGoalRequestProcessor {
}
}
pub(crate) async fn pending_resume_goal_state(
&self,
thread: &CodexThread,
) -> (bool, Option<StateDbHandle>) {
let emit_thread_goal_update = self.config.features.enabled(Feature::Goals);
let thread_goal_state_db = if emit_thread_goal_update {
if let Some(state_db) = thread.state_db() {
Some(state_db)
} else {
self.state_db.clone()
}
} else {
None
};
(emit_thread_goal_update, thread_goal_state_db)
}
async fn thread_goal_set_inner(
&self,
request_id: ConnectionRequestId,
@@ -110,7 +93,7 @@ impl ThreadGoalRequestProcessor {
None => find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
Some(self.state_db.as_ref()),
)
.await
.map_err(|err| {
@@ -275,7 +258,7 @@ impl ThreadGoalRequestProcessor {
None => find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
Some(self.state_db.as_ref()),
)
.await
.map_err(|err| {
@@ -339,7 +322,7 @@ impl ThreadGoalRequestProcessor {
find_thread_path_by_id_str(
&self.config.codex_home,
&thread_id.to_string(),
self.state_db.as_deref(),
Some(self.state_db.as_ref()),
)
.await
.map_err(|err| {
@@ -348,9 +331,7 @@ impl ThreadGoalRequestProcessor {
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?;
}
self.state_db
.clone()
.ok_or_else(|| internal_error("sqlite state db unavailable for thread goals"))
Ok(self.state_db.clone())
}
async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) {
@@ -147,23 +147,17 @@ pub(super) async fn ensure_conversation_listener(
{
Ok(conv) => conv,
Err(_) => {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {conversation_id}"),
data: None,
});
return Err(invalid_request(format!(
"thread not found: {conversation_id}"
)));
}
};
let thread_state = {
let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await;
if pending_thread_unloads.contains(&conversation_id) {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"thread {conversation_id} is closing; retry after the thread is closed"
),
data: None,
});
return Err(invalid_request(format!(
"thread {conversation_id} is closing; retry after the thread is closed"
)));
}
let Some(thread_state) = listener_task_context
.thread_state_manager
@@ -229,13 +223,9 @@ pub(super) async fn ensure_listener_task_running(
)
.await
else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"thread {conversation_id} is closing; retry after the thread is closed"
),
data: None,
});
return Err(invalid_request(format!(
"thread {conversation_id} is closing; retry after the thread is closed"
)));
};
let (mut listener_command_rx, listener_generation) = {
let mut thread_state = thread_state.lock().await;
@@ -2,6 +2,10 @@ use super::*;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
const PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY: &str =
"persistExtendedHistory is deprecated and ignored";
const PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS: &str =
"Remove this parameter. App-server always uses limited history persistence.";
struct ThreadListFilters {
model_providers: Option<Vec<String>>,
@@ -121,12 +125,6 @@ fn collect_resume_override_mismatches(
"developerInstructions override was provided and ignored while running".to_string(),
);
}
if request.persist_extended_history {
mismatch_details.push(
"persistExtendedHistory override was provided and ignored while running".to_string(),
);
}
mismatch_details
}
@@ -165,10 +163,8 @@ fn normalize_thread_list_cwd_filters(
for cwd in cwds {
let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str())
.map(AbsolutePathBuf::into_path_buf)
.map_err(|err| JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: format!("invalid thread/list cwd filter `{cwd}`: {err}"),
data: None,
.map_err(|err| {
invalid_params(format!("invalid thread/list cwd filter `{cwd}`: {err}"))
})?;
normalized_cwds.push(cwd);
}
@@ -333,20 +329,34 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(request_id, params)
.await
.map(|()| None)
self.thread_resume_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
)
.await
.map(|()| None)
}
pub(crate) async fn thread_fork(
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(request_id, params)
.await
.map(|()| None)
self.thread_fork_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
)
.await
.map(|()| None)
}
pub(crate) async fn thread_archive(
@@ -567,21 +577,14 @@ impl ThreadRequestProcessor {
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
// Resolve the core conversation handle from a v2 thread id string.
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
Ok((thread_id, thread))
}
@@ -601,14 +604,18 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to set app server client info: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
async fn finalize_thread_teardown(&self, thread_id: ThreadId) {
@@ -750,6 +757,10 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
if persist_extended_history {
self.send_persist_extended_history_deprecation_notice(request_id.connection_id)
.await;
}
let environment_selections = self.parse_environment_selections(environments)?;
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
@@ -792,7 +803,6 @@ impl ThreadRequestProcessor {
dynamic_tools,
session_start_source,
environment_selections,
persist_extended_history,
service_name,
experimental_raw_events,
request_trace,
@@ -841,6 +851,18 @@ impl ThreadRequestProcessor {
self.outgoing.request_trace_context(request_id).await
}
async fn send_persist_extended_history_deprecation_notice(&self, connection_id: ConnectionId) {
self.outgoing
.send_server_notification_to_connections(
&[connection_id],
ServerNotification::DeprecationNotice(DeprecationNoticeNotification {
summary: PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY.to_string(),
details: Some(PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS.to_string()),
}),
)
.await;
}
async fn submit_core_op(
&self,
request_id: &ConnectionRequestId,
@@ -864,7 +886,6 @@ impl ThreadRequestProcessor {
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
environments: Option<Vec<TurnEnvironmentSelection>>,
persist_extended_history: bool,
service_name: Option<String>,
experimental_raw_events: bool,
request_trace: Option<W3cTraceContext>,
@@ -981,7 +1002,7 @@ impl ThreadRequestProcessor {
},
session_source: None,
dynamic_tools: core_dynamic_tools,
persist_extended_history,
persist_extended_history: false,
metrics_service_name: service_name,
parent_trace: request_trace,
environments,
@@ -990,7 +1011,7 @@ impl ThreadRequestProcessor {
"app_server.thread_start.create_thread",
otel.name = "app_server.thread_start.create_thread",
thread_start.dynamic_tool_count = core_dynamic_tool_count,
thread_start.persist_extended_history = persist_extended_history,
thread_start.persist_extended_history = false,
))
.await
.map_err(|err| match err {
@@ -1536,10 +1557,8 @@ impl ThreadRequestProcessor {
.and_then(|stored_thread| {
summary_from_stored_thread(stored_thread, fallback_provider.as_str())
.map(|summary| summary_to_thread(summary, &self.config.cwd))
.ok_or_else(|| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to read unarchived thread {thread_id}"),
data: None,
.ok_or_else(|| {
internal_error(format!("failed to read unarchived thread {thread_id}"))
})
})?;
@@ -2174,6 +2193,8 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2202,6 +2223,10 @@ impl ThreadRequestProcessor {
.await;
return Ok(());
}
if params.persist_extended_history {
self.send_persist_extended_history_deprecation_notice(request_id.connection_id)
.await;
}
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
@@ -2210,7 +2235,15 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
match self.resume_running_thread(&request_id, &params).await {
match self
.resume_running_thread(
&request_id,
&params,
app_server_client_name.clone(),
app_server_client_version.clone(),
)
.await
{
Ok(true) => return Ok(()),
Ok(false) => {}
Err(error) => {
@@ -2236,7 +2269,7 @@ impl ThreadRequestProcessor {
developer_instructions,
personality,
exclude_turns,
persist_extended_history,
persist_extended_history: _persist_extended_history,
} = params;
let include_turns = !exclude_turns;
@@ -2300,7 +2333,7 @@ impl ThreadRequestProcessor {
config.clone(),
thread_history,
self.auth_manager.clone(),
persist_extended_history,
/*persist_extended_history*/ false,
self.request_trace_context(&request_id).await,
)
.await
@@ -2311,6 +2344,16 @@ impl ThreadRequestProcessor {
session_configured,
..
}) => {
if let Err(err) = Self::set_app_server_client_info(
codex_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await
{
self.outgoing.send_error(request_id, err).await;
return Ok(());
}
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
let error =
@@ -2447,6 +2490,8 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: &ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<bool, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
@@ -2528,6 +2573,12 @@ impl ThreadRequestProcessor {
thread_state.clone(),
)
.await?;
Self::set_app_server_client_info(
existing_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
@@ -2560,10 +2611,10 @@ impl ThreadRequestProcessor {
)));
};
let (emit_thread_goal_update, thread_goal_state_db) = self
.thread_goal_processor
.pending_resume_goal_state(existing_thread.as_ref())
.await;
let emit_thread_goal_update = self.config.features.enabled(Feature::Goals);
let thread_goal_state_db = emit_thread_goal_update
.then(|| self.state_db.clone())
.flatten();
let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
Box::new(crate::thread_state::PendingThreadResumeRequest {
@@ -2811,6 +2862,8 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -2836,6 +2889,10 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
if persist_extended_history {
self.send_persist_extended_history_deprecation_notice(request_id.connection_id)
.await;
}
let source_thread = self
.read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true)
@@ -2913,7 +2970,7 @@ impl ThreadRequestProcessor {
history: history_items.clone(),
rollout_path: source_thread.rollout_path.clone(),
}),
persist_extended_history,
/*persist_extended_history*/ false,
self.request_trace_context(&request_id).await,
)
.await
@@ -2925,6 +2982,13 @@ impl ThreadRequestProcessor {
err => internal_error(format!("error forking thread: {err}")),
})?;
Self::set_app_server_client_info(
forked_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
// Auto-attach a conversation listener when forking a thread.
log_listener_attach_result(
self.ensure_conversation_listener(
@@ -3181,6 +3245,17 @@ impl ThreadRequestProcessor {
}
}
fn xcode_26_4_mcp_elicitations_auto_deny(
client_name: Option<&str>,
client_version: Option<&str>,
) -> bool {
// Xcode 26.4 shipped before app-server MCP elicitation requests were
// client-visible. Keep elicitations auto-denied for that client line.
// TODO: Remove this compatibility hack once Xcode 26.4 ages out.
client_name == Some("Xcode")
&& client_version.is_some_and(|version| version.starts_with("26.4"))
}
const THREAD_TURNS_DEFAULT_LIMIT: usize = 25;
const THREAD_TURNS_MAX_LIMIT: usize = 100;
@@ -3239,11 +3314,9 @@ fn paginate_thread_turns(
.as_ref()
.and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id));
if anchor.is_some() && anchor_index.is_none() {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "invalid cursor: anchor turn is no longer present".to_string(),
data: None,
});
return Err(invalid_request(
"invalid cursor: anchor turn is no longer present",
));
}
let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect();
@@ -3304,19 +3377,11 @@ fn serialize_thread_turns_cursor(
turn_id: turn_id.to_string(),
include_anchor,
})
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize cursor: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to serialize cursor: {err}")))
}
fn parse_thread_turns_cursor(cursor: &str) -> Result<ThreadTurnsCursor, JSONRPCErrorError> {
serde_json::from_str(cursor).map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid cursor: {cursor}"),
data: None,
})
serde_json::from_str(cursor).map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))
}
fn reconstruct_thread_turns_for_turns_list(
@@ -3367,36 +3432,18 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to list threads: {err}"),
data: None,
},
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
err => internal_error(format!("failed to list threads: {err}")),
}
}
fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("no rollout found for thread id {thread_id}"),
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to read thread: {err}"),
data: None,
},
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_request(format!("no rollout found for thread id {thread_id}"))
}
err => internal_error(format!("failed to read thread: {err}")),
}
}
@@ -3459,25 +3506,17 @@ fn conversation_summary_thread_id_read_error(
ThreadStoreError::ThreadNotFound { thread_id } if thread_id == conversation_id => {
conversation_summary_not_found_error(conversation_id)
}
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to load conversation summary for {conversation_id}: {err}"),
data: None,
},
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
err => internal_error(format!(
"failed to load conversation summary for {conversation_id}: {err}"
)),
}
}
fn conversation_summary_not_found_error(conversation_id: ThreadId) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("no rollout found for conversation id {conversation_id}"),
data: None,
}
invalid_request(format!(
"no rollout found for conversation id {conversation_id}"
))
}
fn conversation_summary_rollout_path_read_error(
@@ -3485,55 +3524,29 @@ fn conversation_summary_rollout_path_read_error(
err: ThreadStoreError,
) -> JSONRPCErrorError {
match err {
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to load conversation summary from {}: {}",
path.display(),
err
),
data: None,
},
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
err => internal_error(format!(
"failed to load conversation summary from {}: {}",
path.display(),
err
)),
}
}
fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
},
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to {operation}: {err}"),
data: None,
},
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
err => internal_error(format!("failed to {operation}: {err}")),
}
}
fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
},
err => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to {operation} thread: {err}"),
data: None,
},
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
err => internal_error(format!("failed to {operation} thread: {err}")),
}
}
@@ -52,6 +52,7 @@ mod thread_processor_behavior_tests {
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
@@ -205,6 +206,7 @@ mod thread_processor_behavior_tests {
text_elements: Vec::new(),
}],
}],
items_view: TurnItemsView::Full,
error: None,
status: TurnStatus::InProgress,
started_at: None,
@@ -171,21 +171,14 @@ impl TurnRequestProcessor {
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
// Resolve the core conversation handle from a v2 thread id string.
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
.map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?;
Ok((thread_id, thread))
}
@@ -209,14 +202,6 @@ impl TurnRequestProcessor {
fn review_request_from_target(
target: ApiReviewTarget,
) -> Result<(ReviewRequest, String), JSONRPCErrorError> {
fn invalid_request(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
}
}
let cleaned_target = match target {
ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges,
ApiReviewTarget::BaseBranch { branch } => {
@@ -305,17 +290,15 @@ impl TurnRequestProcessor {
}
fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: format!(
"Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
),
data: Some(serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"max_chars": MAX_USER_INPUT_TEXT_CHARS,
"actual_chars": actual_chars,
})),
}
let mut error = invalid_params(format!(
"Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
));
error.data = Some(serde_json::json!({
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
"max_chars": MAX_USER_INPUT_TEXT_CHARS,
"actual_chars": actual_chars,
}));
error
}
fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
@@ -519,6 +502,7 @@ impl TurnRequestProcessor {
let turn = Turn {
id: turn_id,
items: vec![],
items_view: TurnItemsView::NotLoaded,
error: None,
status: TurnStatus::InProgress,
started_at: None,
@@ -561,14 +545,18 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to set app server client info: {err}"),
data: None,
})
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
async fn turn_steer_inner(
@@ -612,9 +600,8 @@ impl TurnRequestProcessor {
)
.await
.map_err(|err| {
let (code, message, data, error_type) = match err {
let (message, data, error_type) = match err {
SteerInputError::NoActiveTurn(_) => (
INVALID_REQUEST_ERROR_CODE,
"no active turn to steer".to_string(),
None,
Some(AnalyticsJsonRpcError::TurnSteer(
@@ -622,7 +609,6 @@ impl TurnRequestProcessor {
)),
),
SteerInputError::ExpectedTurnMismatch { expected, actual } => (
INVALID_REQUEST_ERROR_CODE,
format!("expected active turn id `{expected}` but found `{actual}`"),
None,
Some(AnalyticsJsonRpcError::TurnSteer(
@@ -658,24 +644,19 @@ impl TurnRequestProcessor {
}
};
(
INVALID_REQUEST_ERROR_CODE,
message,
data,
Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)),
)
}
SteerInputError::EmptyInput => (
INVALID_REQUEST_ERROR_CODE,
"input must not be empty".to_string(),
None,
Some(AnalyticsJsonRpcError::Input(InputError::Empty)),
),
};
let error = JSONRPCErrorError {
code,
message,
data,
};
let mut error = invalid_request(message);
error.data = data;
self.track_error_response(request_id, &error, error_type);
error
})?;
@@ -835,6 +816,7 @@ impl TurnRequestProcessor {
Turn {
id: turn_id,
items,
items_view: TurnItemsView::NotLoaded,
error: None,
status: TurnStatus::InProgress,
started_at: None,
@@ -1009,7 +991,7 @@ impl TurnRequestProcessor {
request_id,
parent_thread,
review_request,
display_text.as_str(),
&display_text,
thread_id,
)
.await?;
@@ -1020,7 +1002,7 @@ impl TurnRequestProcessor {
parent_thread_id,
parent_thread,
review_request,
display_text.as_str(),
&display_text,
)
.await?;
}
@@ -1122,3 +1104,14 @@ impl TurnRequestProcessor {
.await
}
}
fn xcode_26_4_mcp_elicitations_auto_deny(
client_name: Option<&str>,
client_version: Option<&str>,
) -> bool {
// Xcode 26.4 shipped before app-server MCP elicitation requests were
// client-visible. Keep elicitations auto-denied for that client line.
// TODO: Remove this compatibility hack once Xcode 26.4 ages out.
client_name == Some("Xcode")
&& client_version.is_some_and(|version| version.starts_with("26.4"))
}
@@ -11,6 +11,7 @@ use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::HookHandlerType;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookSource;
use codex_app_server_protocol::HookTrustStatus;
use codex_app_server_protocol::HooksListEntry;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
@@ -26,11 +27,44 @@ use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::skip_if_windows;
use pretty_assertions::assert_eq;
use serde::Serialize;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Serialize)]
struct NormalizedHookIdentity {
event_name: &'static str,
#[serde(flatten)]
group: codex_config::MatcherGroup,
}
fn command_hook_hash(
event_name: &'static str,
matcher: Option<&str>,
command: &str,
timeout_sec: u64,
status_message: Option<&str>,
) -> String {
let identity = NormalizedHookIdentity {
event_name,
group: codex_config::MatcherGroup {
matcher: matcher.map(ToOwned::to_owned),
hooks: vec![codex_config::HookHandlerConfig::Command {
command: command.to_string(),
timeout_sec: Some(timeout_sec),
r#async: false,
status_message: status_message.map(ToOwned::to_owned),
}],
},
};
let Ok(value) = codex_config::TomlValue::try_from(identity) else {
unreachable!("normalized hook identity should serialize to TOML");
};
codex_config::version_for_toml(&value)
}
fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> {
std::fs::write(
codex_home.join("config.toml"),
@@ -113,6 +147,14 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> {
display_order: 0,
enabled: true,
is_managed: false,
current_hash: command_hook_hash(
"pre_tool_use",
Some("Bash"),
"python3 /tmp/listed-hook.py",
/*timeout_sec*/ 5,
Some("running listed hook"),
),
trust_status: HookTrustStatus::Untrusted,
}],
warnings: Vec::new(),
errors: Vec::new(),
@@ -183,6 +225,14 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> {
display_order: 0,
enabled: true,
is_managed: false,
current_hash: command_hook_hash(
"pre_tool_use",
Some("Bash"),
"echo plugin hook",
/*timeout_sec*/ 7,
Some("running plugin hook"),
),
trust_status: HookTrustStatus::Untrusted,
}],
warnings: Vec::new(),
errors: Vec::new(),
@@ -300,6 +350,14 @@ timeout = 5
display_order: 0,
enabled: true,
is_managed: false,
current_hash: command_hook_hash(
"pre_tool_use",
Some("Bash"),
"echo project hook",
/*timeout_sec*/ 5,
/*status_message*/ None,
),
trust_status: HookTrustStatus::Untrusted,
}],
warnings: Vec::new(),
errors: Vec::new(),
@@ -408,6 +466,254 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> {
skip_if_windows!(Ok(()));
let responses = vec![
create_final_assistant_message_sse_response("Warmup")?,
create_final_assistant_message_sse_response("Untrusted turn")?,
create_final_assistant_message_sse_response("Trusted turn")?,
create_final_assistant_message_sse_response("Modified turn")?,
];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py");
let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl");
std::fs::write(
&hook_script_path,
format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
hook_log_path = hook_log_path.display(),
),
)?;
std::fs::write(
codex_home.path().join("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 for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
[hooks]
[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "python3 {hook_script_path}"
"#,
server_uri = server.uri(),
hook_script_path = hook_script_path.display(),
),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let hook_list_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![codex_home.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
let hook = data[0].hooks[0].clone();
assert_eq!(hook.trust_status, HookTrustStatus::Untrusted);
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(response)?;
let first_turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "first turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)),
)
.await??;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert!(!std::fs::exists(&hook_log_path)?);
let write_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
edits: vec![ConfigEdit {
key_path: "hooks.state".to_string(),
value: serde_json::json!({
hook.key.clone(): {
"trusted_hash": hook.current_hash.clone()
}
}),
merge_strategy: MergeStrategy::Upsert,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;
let hook_list_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![codex_home.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
let trusted_hook = &data[0].hooks[0];
assert_eq!(trusted_hook.key, hook.key);
assert_eq!(trusted_hook.current_hash, hook.current_hash);
assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted);
let second_turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "second turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)),
)
.await??;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert_eq!(
std::fs::read_to_string(&hook_log_path)?
.lines()
.filter(|line| !line.is_empty())
.count(),
1
);
let write_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
edits: vec![ConfigEdit {
key_path: "hooks.UserPromptSubmit".to_string(),
value: serde_json::json!([{
"hooks": [{
"type": "command",
"command": format!("python3 {}", hook_script_path.display()),
"statusMessage": "modified hook",
}],
}]),
merge_strategy: MergeStrategy::Replace,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;
let hook_list_id = mcp
.send_hooks_list_request(HooksListParams {
cwds: vec![codex_home.path().to_path_buf()],
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)),
)
.await??;
let HooksListResponse { data } = to_response(response)?;
let modified_hook = &data[0].hooks[0];
assert_eq!(modified_hook.key, hook.key);
assert_ne!(modified_hook.current_hash, hook.current_hash);
assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified);
let third_turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "third turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)),
)
.await??;
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
assert_eq!(
std::fs::read_to_string(&hook_log_path)?
.lines()
.filter(|line| !line.is_empty())
.count(),
1
);
Ok(())
}
#[tokio::test]
async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> {
skip_if_windows!(Ok(()));
@@ -482,6 +788,29 @@ command = "python3 {hook_script_path}"
let hook = &data[0].hooks[0];
assert_eq!(hook.enabled, true);
let write_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
edits: vec![ConfigEdit {
key_path: "hooks.state".to_string(),
value: serde_json::json!({
hook.key.clone(): {
"trusted_hash": hook.current_hash.clone()
}
}),
merge_strategy: MergeStrategy::Upsert,
}],
file_path: None,
expected_version: None,
reload_user_config: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(write_id)),
)
.await??;
let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
@@ -42,6 +42,7 @@ use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_state::StateRuntime;
use codex_thread_store::InMemoryThreadStore;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -67,6 +68,13 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let sqlite_home = TempDir::new()?;
let state_db = StateRuntime::init(
sqlite_home.path().to_path_buf(),
config.model_provider_id.clone(),
)
.await
.expect("remote thread store regression test should initialize state db");
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
@@ -80,7 +88,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
state_db: Some(state_db),
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
@@ -22,6 +22,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
@@ -85,6 +86,17 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
assert_eq!(review_thread_id, thread_id.clone());
let turn_id = turn.id.clone();
assert_eq!(turn.status, TurnStatus::InProgress);
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
id: turn_id.clone(),
content: vec![V2UserInput::Text {
text: "commit 1234567: Tidy UI colors".to_string(),
text_elements: Vec::new(),
}],
}]
);
// Confirm we see the EnteredReviewMode marker on the main thread.
let mut saw_entered_review_mode = false;
@@ -182,6 +194,17 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() ->
.await??;
let ReviewStartResponse { turn, .. } = to_response::<ReviewStartResponse>(review_resp)?;
let turn_id = turn.id.clone();
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
id: turn_id.clone(),
content: vec![V2UserInput::Text {
text: "commit 1234567: Check review approvals".to_string(),
text_elements: Vec::new(),
}],
}]
);
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
@@ -300,6 +323,17 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
} = to_response::<ReviewStartResponse>(review_resp)?;
assert_eq!(turn.status, TurnStatus::InProgress);
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
id: turn.id.clone(),
content: vec![V2UserInput::Text {
text: "detached review".to_string(),
text_elements: Vec::new(),
}],
}]
);
assert_ne!(
review_thread_id, thread_id,
"detached review should run on a different thread"
@@ -33,6 +33,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -174,6 +175,7 @@ async fn thread_read_can_include_turns() -> Result<()> {
assert_eq!(thread.turns.len(), 1);
let turn = &thread.turns[0];
assert_eq!(turn.status, TurnStatus::Completed);
assert_eq!(turn.items_view, TurnItemsView::Full);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
@@ -234,6 +236,10 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
backwards_cursor,
} = to_response::<ThreadTurnsListResponse>(read_resp)?;
assert_eq!(turn_user_texts(&data), vec!["third", "second"]);
assert!(
data.iter()
.all(|turn| turn.items_view == TurnItemsView::Full)
);
let next_cursor = next_cursor.expect("expected nextCursor for older turns");
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn");
@@ -41,6 +41,7 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -1629,6 +1630,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
.await??;
let TurnStartResponse { turn: running_turn } =
to_response::<TurnStartResponse>(running_turn_resp)?;
assert_eq!(running_turn.items_view, TurnItemsView::NotLoaded);
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/started"),
@@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
@@ -50,6 +51,46 @@ use super::analytics::wait_for_analytics_payload;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[tokio::test]
async fn thread_start_deprecates_persist_extended_history_true() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
persist_extended_history: true,
..Default::default()
})
.await?;
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("deprecationNotice"),
)
.await??;
let notice: DeprecationNoticeNotification = serde_json::from_value(
notification
.params
.expect("deprecationNotice params should be present"),
)?;
assert_eq!(
notice.summary,
"persistExtendedHistory is deprecated and ignored"
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
// Provide a mock server and config so model wiring is valid.
@@ -44,6 +44,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
@@ -868,6 +869,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
codex_app_server_protocol::TurnStatus::InProgress
);
assert_eq!(started.turn.id, turn.id);
assert_eq!(started.turn.items_view, TurnItemsView::NotLoaded);
assert!(started.turn.items.is_empty());
let completed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
@@ -882,6 +885,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_eq!(completed.turn.items_view, TurnItemsView::NotLoaded);
assert!(completed.turn.items.is_empty());
// Send a second turn that exercises the overrides path: change the model.
let turn_req2 = mcp
@@ -915,6 +920,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
assert_eq!(started2.thread_id, thread.id);
assert_eq!(started2.turn.id, turn2.id);
assert_eq!(started2.turn.status, TurnStatus::InProgress);
assert_eq!(started2.turn.items_view, TurnItemsView::NotLoaded);
assert!(started2.turn.items.is_empty());
let completed_notif2: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
@@ -929,6 +936,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
assert_eq!(completed2.thread_id, thread.id);
assert_eq!(completed2.turn.id, turn2.id);
assert_eq!(completed2.turn.status, TurnStatus::Completed);
assert_eq!(completed2.turn.items_view, TurnItemsView::NotLoaded);
assert!(completed2.turn.items.is_empty());
Ok(())
}
+32 -8
View File
@@ -447,12 +447,20 @@ struct AppServerCommand {
#[derive(Debug, Parser)]
struct ExecServerCommand {
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`.
#[arg(
long = "listen",
value_name = "URL",
default_value = "ws://127.0.0.1:0"
)]
listen: String,
#[arg(long = "listen", value_name = "URL", conflicts_with = "remote")]
listen: Option<String>,
/// Register this exec-server as a remote executor using the given base URL.
#[arg(long = "remote", value_name = "URL", requires = "executor_id")]
remote: Option<String>,
/// Executor id to attach to when registering remotely.
#[arg(long = "executor-id", value_name = "ID")]
executor_id: Option<String>,
/// Human-readable executor name.
#[arg(long = "name", value_name = "NAME")]
name: Option<String>,
}
#[derive(Debug, clap::Subcommand)]
@@ -1264,7 +1272,23 @@ async fn run_exec_server_command(
codex_self_exe,
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
codex_exec_server::run_main(&cmd.listen, runtime_paths)
if let Some(base_url) = cmd.remote {
let executor_id = cmd
.executor_id
.ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?;
let mut remote_config =
codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?;
if let Some(name) = cmd.name {
remote_config.name = name;
}
codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?;
return Ok(());
}
let listen_url = cmd
.listen
.as_deref()
.unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL);
codex_exec_server::run_main(listen_url, runtime_paths)
.await
.map_err(anyhow::Error::from_boxed)
}
@@ -1387,7 +1411,7 @@ async fn run_debug_prompt_input_command(
});
}
let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?;
let prompt_input = codex_core::build_prompt_input(config, input).await?;
println!("{}", serde_json::to_string_pretty(&prompt_input)?);
Ok(())
+3
View File
@@ -9,6 +9,9 @@ doctest = false
name = "codex_code_mode"
path = "src/lib.rs"
[features]
sandbox = ["v8/v8_enable_sandbox"]
[lints]
workspace = true
@@ -128,6 +128,14 @@ impl McpConnectionManager {
}
}
pub fn elicitations_auto_deny(&self) -> bool {
self.elicitation_requests.auto_deny()
}
pub fn set_elicitations_auto_deny(&self, auto_deny: bool) {
self.elicitation_requests.set_auto_deny(auto_deny);
}
#[allow(clippy::new_ret_no_self, clippy::too_many_arguments)]
pub async fn new(
mcp_servers: &HashMap<String, McpServerConfig>,
+29
View File
@@ -36,6 +36,7 @@ pub(crate) struct ElicitationRequestManager {
requests: Arc<Mutex<ResponderMap>>,
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
auto_deny: Arc<StdMutex<bool>>,
}
impl ElicitationRequestManager {
@@ -47,6 +48,20 @@ impl ElicitationRequestManager {
requests: Arc::new(Mutex::new(HashMap::new())),
approval_policy: Arc::new(StdMutex::new(approval_policy)),
permission_profile: Arc::new(StdMutex::new(permission_profile)),
auto_deny: Arc::new(StdMutex::new(false)),
}
}
pub(crate) fn auto_deny(&self) -> bool {
self.auto_deny
.lock()
.map(|auto_deny| *auto_deny)
.unwrap_or(false)
}
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
if let Ok(mut current) = self.auto_deny.lock() {
*current = auto_deny;
}
}
@@ -73,13 +88,27 @@ impl ElicitationRequestManager {
let elicitation_requests = self.requests.clone();
let approval_policy = self.approval_policy.clone();
let permission_profile = self.permission_profile.clone();
let auto_deny = self.auto_deny.clone();
Box::new(move |id, elicitation| {
let elicitation_requests = elicitation_requests.clone();
let tx_event = tx_event.clone();
let server_name = server_name.clone();
let approval_policy = approval_policy.clone();
let permission_profile = permission_profile.clone();
let auto_deny = auto_deny.clone();
async move {
let auto_deny = auto_deny
.lock()
.map(|auto_deny| *auto_deny)
.unwrap_or(false);
if auto_deny {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
}
let approval_policy = approval_policy
.lock()
.map(|policy| *policy)
+2
View File
@@ -25,6 +25,8 @@ pub struct HooksToml {
pub struct HookStateToml {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_hash: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+2
View File
@@ -90,6 +90,7 @@ fn hooks_toml_deserializes_inline_events_and_state_map() {
r#"
[state."/tmp/hooks.json:pre_tool_use:0:0"]
enabled = false
trusted_hash = "sha256:abc123"
[[PreToolUse]]
matcher = "^Bash$"
@@ -120,6 +121,7 @@ command = "python3 /tmp/pre.py"
"/tmp/hooks.json:pre_tool_use:0:0".to_string(),
super::HookStateToml {
enabled: Some(false),
trusted_hash: Some("sha256:abc123".to_string()),
},
)]),
}
+14
View File
@@ -7,6 +7,7 @@ use crate::config_toml::ToolsToml;
use crate::types::AnalyticsConfigToml;
use crate::types::ApprovalsReviewer;
use crate::types::Personality;
use crate::types::SessionPickerViewMode;
use crate::types::WindowsToml;
use codex_features::FeaturesToml;
use codex_protocol::config_types::ReasoningSummary;
@@ -63,6 +64,9 @@ pub struct ConfigProfile {
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<AnalyticsConfigToml>,
/// TUI settings scoped to this profile.
#[serde(default)]
pub tui: Option<ProfileTui>,
#[serde(default)]
pub windows: Option<WindowsToml>,
/// Optional feature toggles scoped to this profile.
@@ -73,6 +77,16 @@ pub struct ConfigProfile {
pub oss_provider: Option<String>,
}
/// TUI settings supported inside a named profile.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ProfileTui {
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
}
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
+2
View File
@@ -106,6 +106,8 @@ pub struct TuiGlobalKeymap {
pub toggle_vim_mode: Option<KeybindingsSpec>,
/// Toggle Fast mode.
pub toggle_fast_mode: Option<KeybindingsSpec>,
/// Toggle raw scrollback mode for copy-friendly transcript selection.
pub toggle_raw_output: Option<KeybindingsSpec>,
}
/// Chat context keybindings.
+33
View File
@@ -57,6 +57,30 @@ const fn default_enabled() -> bool {
true
}
/// Preferred layout for the resume/fork session picker.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum SessionPickerViewMode {
Comfortable,
#[default]
Dense,
}
impl SessionPickerViewMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Comfortable => "comfortable",
Self::Dense => "dense",
}
}
}
impl fmt::Display for SessionPickerViewMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Determine where Codex should store CLI auth credentials.
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
@@ -617,6 +641,11 @@ pub struct Tui {
#[serde(default)]
pub vim_mode_default: bool,
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
/// Defaults to `false`.
#[serde(default)]
pub raw_output_mode: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
@@ -656,6 +685,10 @@ pub struct Tui {
#[serde(default)]
pub theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
pub session_picker_view: Option<SessionPickerViewMode>,
/// Keybinding overrides for the TUI.
///
/// This supports rebinding selected actions globally and by context.
+3
View File
@@ -18,6 +18,7 @@ pub use codex_config::types::ModelAvailabilityNuxConfig;
pub use codex_config::types::Notice;
pub use codex_config::types::OAuthCredentialsStoreMode;
pub use codex_config::types::OtelConfig;
pub use codex_config::types::SessionPickerViewMode;
pub use codex_config::types::ToolSuggestConfig;
pub use codex_config::types::TuiKeymap;
pub use codex_config::types::TuiNotificationSettings;
@@ -30,6 +31,7 @@ pub use codex_core::StartThreadOptions;
pub use codex_core::StateDbHandle;
pub use codex_core::ThreadManager;
pub use codex_core::ThreadShutdownReport;
pub use codex_core::agent_graph_store_from_state_db;
pub use codex_core::config::Config;
pub use codex_core::config::Constrained;
pub use codex_core::config::GhostSnapshotConfig;
@@ -39,6 +41,7 @@ pub use codex_core::config::TerminalResizeReflowConfig;
pub use codex_core::config::ThreadStoreConfig;
pub use codex_core::config::find_codex_home;
pub use codex_core::init_state_db;
pub use codex_core::init_state_db_from_config;
pub use codex_core::skills::SkillsManager;
pub use codex_core::thread_store_from_config;
pub use codex_exec_server::EnvironmentManager;
+1
View File
@@ -26,6 +26,7 @@ bm25 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-analytics = { workspace = true }
codex-agent-graph-store = { workspace = true }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
+60
View File
@@ -674,6 +674,15 @@
"tools_view_image": {
"type": "boolean"
},
"tui": {
"allOf": [
{
"$ref": "#/definitions/ProfileTui"
}
],
"default": null,
"description": "TUI settings scoped to this profile."
},
"web_search": {
"$ref": "#/definitions/WebSearchMode"
},
@@ -999,6 +1008,9 @@
"properties": {
"enabled": {
"type": "boolean"
},
"trusted_hash": {
"type": "string"
}
},
"type": "object"
@@ -1873,6 +1885,22 @@
},
"type": "object"
},
"ProfileTui": {
"additionalProperties": false,
"description": "TUI settings supported inside a named profile.",
"properties": {
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
}
},
"type": "object"
},
"ProjectConfig": {
"additionalProperties": false,
"properties": {
@@ -2160,6 +2188,14 @@
],
"type": "string"
},
"SessionPickerViewMode": {
"description": "Preferred layout for the resume/fork session picker.",
"enum": [
"comfortable",
"dense"
],
"type": "string"
},
"ShellEnvironmentPolicyInherit": {
"oneOf": [
{
@@ -2460,6 +2496,7 @@
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_raw_output": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
},
@@ -2558,6 +2595,20 @@
"default": true,
"description": "Enable desktop notifications from the TUI. Defaults to `true`."
},
"raw_output_mode": {
"default": false,
"description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.",
"type": "boolean"
},
"session_picker_view": {
"allOf": [
{
"$ref": "#/definitions/SessionPickerViewMode"
}
],
"default": null,
"description": "Preferred layout for resume/fork session picker results."
},
"show_tooltips": {
"default": true,
"description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.",
@@ -2956,6 +3007,14 @@
],
"description": "Toggle Fast mode."
},
"toggle_raw_output": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Toggle raw scrollback mode for copy-friendly transcript selection."
},
"toggle_shortcuts": {
"allOf": [
{
@@ -3062,6 +3121,7 @@
"queue": null,
"submit": null,
"toggle_fast_mode": null,
"toggle_raw_output": null,
"toggle_shortcuts": null,
"toggle_vim_mode": null
}
+19 -29
View File
@@ -29,7 +29,6 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::user_input::UserInput;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_thread_store::ReadThreadParams;
use serde::Serialize;
use std::collections::HashMap;
@@ -296,7 +295,6 @@ impl AgentControl {
state.notify_thread_created(new_thread.thread_id);
self.persist_thread_spawn_edge_for_source(
new_thread.thread.as_ref(),
new_thread.thread_id,
notification_source.as_ref(),
)
@@ -449,19 +447,14 @@ impl AgentControl {
))
.await?;
let state = self.upgrade()?;
let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else {
return Ok(resumed_thread_id);
};
let Some(state_db_ctx) = resumed_thread.state_db() else {
return Ok(resumed_thread_id);
};
let agent_graph_store = state.agent_graph_store();
let mut resume_queue = VecDeque::from([(thread_id, root_depth)]);
while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() {
let child_ids = match state_db_ctx
.list_thread_spawn_children_with_status(
let child_ids = match agent_graph_store
.list_thread_spawn_children(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
Some(codex_agent_graph_store::ThreadSpawnEdgeStatus::Open),
)
.await
{
@@ -525,7 +518,6 @@ impl AgentControl {
let _ = config.features.disable(Feature::Collab);
}
let state = self.upgrade()?;
let state_db_ctx = state.state_db();
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let (session_source, agent_metadata) = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
@@ -535,14 +527,11 @@ impl AgentControl {
agent_role: _,
agent_nickname: _,
}) => {
let state_db_ctx = state.state_db();
let (resumed_agent_nickname, resumed_agent_role) =
if let Some(state_db_ctx) = state_db_ctx.as_ref() {
match state_db_ctx.get_thread(thread_id).await {
Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role),
Ok(None) | Err(_) => (None, None),
}
} else {
(None, None)
match state_db_ctx.get_thread(thread_id).await {
Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role),
Ok(None) | Err(_) => (None, None),
};
self.prepare_thread_spawn(
&mut reservation,
@@ -609,7 +598,6 @@ impl AgentControl {
);
}
self.persist_thread_spawn_edge_for_source(
resumed_thread.thread.as_ref(),
resumed_thread.thread_id,
Some(&notification_source),
)
@@ -722,11 +710,13 @@ impl AgentControl {
/// agent and any live descendants reached from the in-memory tree.
pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
let state = self.upgrade()?;
if let Ok(thread) = state.get_thread(agent_id).await
&& let Some(state_db_ctx) = thread.state_db()
&& let Err(err) = state_db_ctx
.set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed)
.await
if let Err(err) = state
.agent_graph_store()
.set_thread_spawn_edge_status(
agent_id,
codex_agent_graph_store::ThreadSpawnEdgeStatus::Closed,
)
.await
{
warn!("failed to persist thread-spawn edge status for {agent_id}: {err}");
}
@@ -1142,21 +1132,21 @@ impl AgentControl {
async fn persist_thread_spawn_edge_for_source(
&self,
thread: &crate::CodexThread,
child_thread_id: ThreadId,
session_source: Option<&SessionSource>,
) {
let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else {
return;
};
let Some(state_db_ctx) = thread.state_db() else {
let Ok(state) = self.upgrade() else {
return;
};
if let Err(err) = state_db_ctx
if let Err(err) = state
.agent_graph_store()
.upsert_thread_spawn_edge(
parent_thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
codex_agent_graph_store::ThreadSpawnEdgeStatus::Open,
)
.await
{
+22 -19
View File
@@ -1,6 +1,5 @@
use super::*;
use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::config::AgentRoleConfig;
@@ -8,7 +7,6 @@ use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::context::ContextualUserFragment;
use crate::context::SubagentNotification;
use crate::init_state_db;
use assert_matches::assert_matches;
use codex_features::Feature;
use codex_login::CodexAuth;
@@ -86,7 +84,6 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem {
struct AgentControlHarness {
_home: TempDir,
config: Config,
state_db: Option<StateDbHandle>,
manager: ThreadManager,
control: AgentControl,
}
@@ -94,19 +91,17 @@ struct AgentControlHarness {
impl AgentControlHarness {
async fn new() -> Self {
let (home, config) = test_config().await;
let state_db = init_state_db(&config).await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
state_db.clone(),
);
)
.await;
let control = manager.agent_control();
Self {
_home: home,
config,
state_db,
manager,
control,
}
@@ -955,7 +950,8 @@ async fn spawn_agent_respects_max_threads_limit() {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
)
.await;
let control = manager.agent_control();
let _ = manager
@@ -1007,7 +1003,8 @@ async fn spawn_agent_releases_slot_after_shutdown() {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
)
.await;
let control = manager.agent_control();
let first_agent_id = control
@@ -1050,7 +1047,8 @@ async fn spawn_agent_limit_shared_across_clones() {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
)
.await;
let control = manager.agent_control();
let cloned = control.clone();
@@ -1095,7 +1093,8 @@ async fn resume_agent_respects_max_threads_limit() {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
)
.await;
let control = manager.agent_control();
let resumable_id = control
@@ -1151,7 +1150,8 @@ async fn resume_agent_releases_slot_after_resume_failure() {
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
)
.await;
let control = manager.agent_control();
let _ = control
@@ -1543,19 +1543,17 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() {
.features
.enable(Feature::Sqlite)
.expect("test config should allow sqlite");
let state_db = init_state_db(&config).await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
state_db.clone(),
);
)
.await;
let control = manager.agent_control();
let harness = AgentControlHarness {
_home: home,
config,
state_db,
manager,
control,
};
@@ -1706,7 +1704,12 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
.expect("child shutdown should succeed");
let store = LocalThreadStore::new(
LocalThreadStoreConfig::from_config(&harness.config),
harness.state_db.clone(),
codex_state::StateRuntime::init(
harness.config.sqlite_home.clone(),
harness.config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
);
store
.archive_thread(ArchiveThreadParams {
+1
View File
@@ -95,6 +95,7 @@ pub(crate) async fn run_codex_thread_interactive(
parent_trace: None,
environment_selections: parent_ctx.environments.clone(),
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
state_db: parent_session.services.state_db.clone(),
thread_store: Arc::clone(&parent_session.services.thread_store),
}))
.or_cancel(&cancel_token)
+6 -1
View File
@@ -221,9 +221,14 @@ impl CodexThread {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.codex
.set_app_server_client_info(app_server_client_name, app_server_client_version)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
}
+157
View File
@@ -44,6 +44,7 @@ use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_config::types::Notifications;
use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
@@ -550,11 +551,13 @@ fn config_toml_deserializes_model_availability_nux() {
animations: true,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
@@ -660,6 +663,53 @@ fn test_tui_vim_mode_default_true() {
);
}
#[test]
fn test_tui_raw_output_mode_defaults_to_false() {
let toml = r#"
[tui]
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table");
assert!(
!parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[test]
fn test_tui_raw_output_mode_true() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
assert!(
parsed
.tui
.expect("config should include tui section")
.raw_output_mode
);
}
#[tokio::test]
async fn runtime_config_uses_tui_raw_output_mode() {
let toml = r#"
[tui]
raw_output_mode = true
"#;
let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load config");
assert!(cfg.tui_raw_output_mode);
}
#[test]
fn config_toml_deserializes_permission_profiles() {
let toml = r#"
@@ -2108,6 +2158,31 @@ fn tui_theme_defaults_to_none() {
assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None);
}
#[test]
fn tui_session_picker_view_deserializes_from_toml() {
let cfg = r#"
[tui]
session_picker_view = "dense"
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
Some(SessionPickerViewMode::Dense),
);
}
#[test]
fn tui_session_picker_view_defaults_to_none() {
let cfg = r#"
[tui]
"#;
let parsed = toml::from_str::<ConfigToml>(cfg).expect("TOML deserialization should succeed");
assert_eq!(
parsed.tui.as_ref().and_then(|t| t.session_picker_view),
None,
);
}
#[test]
fn tui_config_missing_notifications_field_defaults_to_enabled() {
let cfg = r#"
@@ -2125,11 +2200,13 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
animations: true,
show_tooltips: true,
vim_mode_default: false,
raw_output_mode: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
terminal_title: None,
theme: None,
session_picker_view: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
@@ -2195,6 +2272,78 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides()
);
}
#[test]
fn profile_tui_rejects_unsupported_settings() {
let err = toml::from_str::<ConfigToml>(
r#"profile = "work"
[profiles.work.tui]
theme = "dark"
"#,
)
.expect_err("profile TUI config should only accept supported fields");
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("theme"));
}
#[tokio::test]
async fn runtime_config_resolves_session_picker_view_default_and_override() {
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load default config");
assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense);
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
tui: Some(Tui {
session_picker_view: Some(SessionPickerViewMode::Comfortable),
..Default::default()
}),
..Default::default()
},
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load root override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
let cfg_toml = toml::from_str::<ConfigToml>(
r#"profile = "work"
[tui]
session_picker_view = "dense"
[profiles.work.tui]
session_picker_view = "comfortable"
"#,
)
.expect("parse profile scoped tui config");
let cfg = Config::load_from_base_config_with_overrides(
cfg_toml,
ConfigOverrides::default(),
tempdir().expect("tempdir").abs(),
)
.await
.expect("load profile override config");
assert_eq!(
cfg.tui_session_picker_view,
SessionPickerViewMode::Comfortable
);
}
#[tokio::test]
async fn test_sandbox_config_parsing() {
let sandbox_full_access = r#"
@@ -6450,6 +6599,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
@@ -6461,6 +6611,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -6652,6 +6803,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
@@ -6663,6 +6815,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -6808,6 +6961,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
@@ -6819,6 +6973,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
@@ -6949,6 +7104,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
animations: true,
show_tooltips: true,
tui_vim_mode_default: false,
tui_raw_output_mode: false,
tui_keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow: TerminalResizeReflowConfig::default(),
@@ -6960,6 +7116,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_status_line_use_colors: true,
tui_terminal_title: None,
tui_theme: None,
tui_session_picker_view: SessionPickerViewMode::Dense,
otel: OtelConfig::default(),
};
+28
View File
@@ -3,6 +3,7 @@ use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_config::CONFIG_TOML_FILE;
use codex_config::types::McpServerConfig;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::FEATURES;
use codex_protocol::config_types::Personality;
@@ -91,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
}
}
/// Produces a config edit that sets `[tui].session_picker_view = "<mode>"`.
pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "session_picker_view".to_string()],
value: value(mode.to_string()),
}
}
/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list.
///
/// The array is written even when it is empty so "hide the status line" stays
@@ -1316,6 +1325,25 @@ impl ConfigEditsBuilder {
self
}
pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self {
let segments = if let Some(profile) = self.profile.as_ref() {
vec![
"profiles".to_string(),
profile.clone(),
"tui".to_string(),
"session_picker_view".to_string(),
]
} else {
vec!["tui".to_string(), "session_picker_view".to_string()]
};
self.edits.push(ConfigEdit::SetPath {
segments,
value: value(mode.to_string()),
});
self
}
pub fn with_edits<I>(mut self, edits: I) -> Self
where
I: IntoIterator<Item = ConfigEdit>,
+36
View File
@@ -2,6 +2,7 @@ use super::*;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
#[cfg(unix)]
@@ -48,6 +49,41 @@ fn builder_with_edits_applies_custom_paths() {
assert_eq!(contents, "enabled = true\n");
}
#[test]
fn session_picker_view_edit_writes_root_tui_setting() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn session_picker_view_builder_respects_active_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_profile(Some("work"))
.set_session_picker_view(SessionPickerViewMode::Dense)
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[profiles.work.tui]
session_picker_view = "dense"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_binding_edit_writes_root_action_binding() {
let tmp = tempdir().expect("tmpdir");
+18
View File
@@ -49,6 +49,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_config::types::OtelConfig;
use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::SessionPickerViewMode;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
@@ -517,6 +518,9 @@ pub struct Config {
/// Start the composer in Vim mode (`Normal`) by default.
pub tui_vim_mode_default: bool,
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
pub tui_raw_output_mode: bool,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -544,6 +548,9 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
/// Preferred layout for resume/fork session picker results.
pub tui_session_picker_view: SessionPickerViewMode,
/// Terminal resize-reflow tuning knobs.
pub terminal_resize_reflow: TerminalResizeReflowConfig,
@@ -3147,6 +3154,11 @@ impl Config {
.as_ref()
.map(|t| t.vim_mode_default)
.unwrap_or(false),
tui_raw_output_mode: cfg
.tui
.as_ref()
.map(|t| t.raw_output_mode)
.unwrap_or(false),
tui_alternate_screen: cfg
.tui
.as_ref()
@@ -3160,6 +3172,12 @@ impl Config {
.unwrap_or(true),
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
tui_session_picker_view: config_profile
.tui
.as_ref()
.and_then(|t| t.session_picker_view)
.or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view))
.unwrap_or_default(),
terminal_resize_reflow,
tui_keymap: cfg
.tui
-12
View File
@@ -29,7 +29,6 @@ use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::validate_thread_goal_objective;
use codex_rollout::state_db::reconcile_rollout;
use codex_thread_store::LocalThreadStore;
use codex_utils_template::Template;
use futures::future::BoxFuture;
use std::sync::Arc;
@@ -1338,17 +1337,6 @@ impl Session {
state_db
} else if let Some(state_db) = self.goal_runtime.state_db.lock().await.clone() {
state_db
} else if let Some(local_store) = self
.services
.thread_store
.as_any()
.downcast_ref::<LocalThreadStore>()
{
local_store.state_db().await.ok_or_else(|| {
anyhow::anyhow!(
"thread goals require a local persisted thread with a state database"
)
})?
} else {
anyhow::bail!("thread goals require a local persisted thread with a state database");
};
+2
View File
@@ -124,7 +124,9 @@ pub use thread_manager::NewThread;
pub use thread_manager::StartThreadOptions;
pub use thread_manager::ThreadManager;
pub use thread_manager::ThreadShutdownReport;
pub use thread_manager::agent_graph_store_from_state_db;
pub use thread_manager::build_models_manager;
pub use thread_manager::init_state_db_from_config;
pub use thread_manager::thread_store_from_config;
pub use web_search::web_search_action_detail;
pub use web_search::web_search_detail;
+13 -1
View File
@@ -20,6 +20,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use core_test_support::PathExt;
use core_test_support::hooks::trusted_config_layer_stack;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
@@ -163,13 +164,24 @@ print({hook_output:?})
.to_string(),
)
.expect("write hooks.json");
let hook_list = codex_hooks::list_hooks(HooksConfig {
feature_enabled: true,
config_layer_stack: Some(turn_context.config.config_layer_stack.clone()),
..HooksConfig::default()
});
assert_eq!(hook_list.hooks.len(), 1);
let trusted_config_layer_stack = trusted_config_layer_stack(
&turn_context.config.config_layer_stack,
&turn_context.config.codex_home,
hook_list.hooks,
);
session
.services
.hooks
.store(Arc::new(Hooks::new(HooksConfig {
feature_enabled: true,
config_layer_stack: Some(turn_context.config.config_layer_stack.clone()),
config_layer_stack: Some(trusted_config_layer_stack),
shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()),
shell_args: if cfg!(windows) {
Vec::new()
+7 -10
View File
@@ -25,7 +25,7 @@ pub enum PersonalityMigrationStatus {
pub async fn maybe_migrate_personality(
codex_home: &Path,
config_toml: &ConfigToml,
state_db: Option<StateDbHandle>,
state_db: StateDbHandle,
) -> io::Result<PersonalityMigrationStatus> {
let marker_path = codex_home.join(PERSONALITY_MIGRATION_FILENAME);
if tokio::fs::try_exists(&marker_path).await? {
@@ -65,16 +65,13 @@ pub async fn maybe_migrate_personality(
async fn has_recorded_sessions(
codex_home: &Path,
default_provider: &str,
state_db: Option<StateDbHandle>,
state_db: StateDbHandle,
) -> io::Result<bool> {
let store = LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
sqlite_home: codex_home.to_path_buf(),
default_model_provider_id: default_provider.to_string(),
},
state_db,
);
let config = LocalThreadStoreConfig {
codex_home: codex_home.to_path_buf(),
default_model_provider_id: default_provider.to_string(),
};
let store = LocalThreadStore::new(config, state_db);
if has_threads(&store, /*archived*/ false).await? {
return Ok(true);
}
@@ -8,7 +8,10 @@ use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::UserMessageEvent;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::RolloutConfig;
use codex_rollout::SESSIONS_SUBDIR;
use codex_rollout::state_db::StateDbHandle;
use codex_state::state_db_path;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
@@ -20,6 +23,26 @@ async fn read_config_toml(codex_home: &Path) -> io::Result<ConfigToml> {
toml::from_str(&contents).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
async fn state_db_for_test(codex_home: &Path) -> io::Result<StateDbHandle> {
state_db_for_test_with_sqlite_home(codex_home, codex_home).await
}
async fn state_db_for_test_with_sqlite_home(
codex_home: &Path,
sqlite_home: &Path,
) -> io::Result<StateDbHandle> {
let config = RolloutConfig {
codex_home: codex_home.to_path_buf(),
sqlite_home: sqlite_home.to_path_buf(),
cwd: codex_home.to_path_buf(),
model_provider_id: "openai".to_string(),
generate_memories: false,
};
codex_rollout::state_db::try_init(&config)
.await
.map_err(io::Error::other)
}
async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> {
let thread_id = ThreadId::new();
let dir = codex_home
@@ -87,7 +110,8 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> {
write_session_with_user_event(temp.path()).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
let state_db = state_db_for_test(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
@@ -103,7 +127,8 @@ async fn applies_when_only_archived_sessions_exist_and_no_personality() -> io::R
write_archived_session_with_user_event(temp.path()).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
let state_db = state_db_for_test(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
@@ -119,7 +144,8 @@ async fn skips_when_marker_exists() -> io::Result<()> {
create_marker(&temp.path().join(PERSONALITY_MIGRATION_FILENAME)).await?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
let state_db = state_db_for_test(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedMarker);
assert!(!temp.path().join("config.toml").exists());
@@ -136,7 +162,8 @@ async fn skips_when_personality_explicit() -> io::Result<()> {
.map_err(|err| io::Error::other(format!("failed to write config: {err}")))?;
let config_toml = read_config_toml(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
let state_db = state_db_for_test(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?;
assert_eq!(
status,
@@ -153,10 +180,37 @@ async fn skips_when_personality_explicit() -> io::Result<()> {
async fn skips_when_no_sessions() -> io::Result<()> {
let temp = TempDir::new()?;
let config_toml = ConfigToml::default();
let status = maybe_migrate_personality(temp.path(), &config_toml, /*state_db*/ None).await?;
let state_db = state_db_for_test(temp.path()).await?;
let status = maybe_migrate_personality(temp.path(), &config_toml, state_db).await?;
assert_eq!(status, PersonalityMigrationStatus::SkippedNoSessions);
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
assert!(!temp.path().join("config.toml").exists());
Ok(())
}
#[tokio::test]
async fn uses_configured_sqlite_home_when_checking_for_sessions() -> io::Result<()> {
let codex_home = TempDir::new()?;
let sqlite_home = TempDir::new()?;
write_session_with_user_event(codex_home.path()).await?;
let config_toml = ConfigToml::default();
let state_db =
state_db_for_test_with_sqlite_home(codex_home.path(), sqlite_home.path()).await?;
let status = maybe_migrate_personality(codex_home.path(), &config_toml, state_db).await?;
assert_eq!(status, PersonalityMigrationStatus::Applied);
assert!(
codex_home
.path()
.join(PERSONALITY_MIGRATION_FILENAME)
.exists()
);
let persisted = read_config_toml(codex_home.path()).await?;
assert_eq!(persisted.personality, Some(Personality::Pragmatic));
assert!(!state_db_path(codex_home.path()).exists());
assert!(state_db_path(sqlite_home.path()).exists());
Ok(())
}
+8 -3
View File
@@ -16,8 +16,9 @@ use crate::config::Config;
use crate::session::session::Session;
use crate::session::turn::build_prompt;
use crate::session::turn::built_tools;
use crate::state_db_bridge::StateDbHandle;
use crate::thread_manager::ThreadManager;
use crate::thread_manager::agent_graph_store_from_state_db;
use crate::thread_manager::init_state_db_from_config;
use crate::thread_manager::thread_store_from_config;
/// Build the model-visible `input` list for a single debug turn.
@@ -25,7 +26,6 @@ use crate::thread_manager::thread_store_from_config;
pub async fn build_prompt_input(
mut config: Config,
input: Vec<UserInput>,
state_db: Option<StateDbHandle>,
) -> CodexResult<Vec<ResponseItem>> {
config.ephemeral = true;
@@ -37,15 +37,20 @@ pub async fn build_prompt_input(
config.codex_linux_sandbox_exe.clone(),
)?;
let state_db = init_state_db_from_config(&config)
.await
.ok_or_else(|| std::io::Error::other("prompt debug requires state db"))?;
let thread_store = thread_store_from_config(&config, state_db.clone());
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
let thread_manager = ThreadManager::new(
&config,
Arc::clone(&auth_manager),
SessionSource::Exec,
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
/*analytics_events_client*/ None,
state_db,
thread_store,
state_db.clone(),
agent_graph_store,
);
let thread = thread_manager.start_thread(config).await?;
+4
View File
@@ -254,6 +254,10 @@ impl Session {
auth.as_ref(),
)
.await;
{
let current_manager = self.services.mcp_connection_manager.read().await;
refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny());
}
{
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
if guard.is_cancelled() {
+10 -13
View File
@@ -132,7 +132,6 @@ use codex_terminal_detection::user_agent;
use codex_thread_store::CreateThreadParams;
use codex_thread_store::LiveThread;
use codex_thread_store::LiveThreadInitGuard;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ResumeThreadParams;
use codex_thread_store::ThreadEventPersistenceMode;
use codex_thread_store::ThreadPersistenceMetadata;
@@ -356,7 +355,6 @@ use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolsConfig;
use codex_tools::ToolsConfigParams;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -412,6 +410,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environment_selections: ResolvedTurnEnvironments,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) state_db: Option<state_db::StateDbHandle>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
}
@@ -469,6 +468,7 @@ impl Codex {
parent_trace: _,
environment_selections,
analytics_events_client,
state_db,
thread_store,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
@@ -557,15 +557,7 @@ impl Codex {
};
match thread_id {
Some(thread_id) => {
let state_db_ctx = if config.ephemeral {
None
} else if let Some(local_store) =
thread_store.as_any().downcast_ref::<LocalThreadStore>()
{
local_store.state_db().await
} else {
None
};
let state_db_ctx = state_db.clone();
state_db::get_dynamic_tools(state_db_ctx.as_deref(), thread_id, "codex_spawn")
.await
}
@@ -651,6 +643,7 @@ impl Codex {
agent_control,
environment_manager,
analytics_events_client,
state_db,
thread_store,
parent_rollout_thread_trace,
)
@@ -758,6 +751,7 @@ impl Codex {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.session
.update_settings(SessionSettingsUpdate {
@@ -765,7 +759,10 @@ impl Codex {
app_server_client_version,
..Default::default()
})
.await
.await?;
let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await;
mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny);
Ok(())
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
@@ -1307,7 +1304,7 @@ impl Session {
self.services.user_shell.as_ref().clone(),
self.services.shell_snapshot_tx.clone(),
self.services.session_telemetry.clone(),
self.services.state_db.clone(),
self.state_db(),
);
}
+3 -23
View File
@@ -343,6 +343,7 @@ impl Session {
agent_control: AgentControl,
environment_manager: Arc<EnvironmentManager>,
analytics_events_client: Option<AnalyticsEventsClient>,
state_db: Option<state_db::StateDbHandle>,
thread_store: Arc<dyn ThreadStore>,
parent_rollout_thread_trace: ThreadTraceContext,
) -> anyhow::Result<Arc<Self>> {
@@ -441,22 +442,7 @@ impl Session {
otel.name = "session_init.thread_persistence",
session_init.ephemeral = config.ephemeral,
));
let state_db_fut = async {
if config.ephemeral {
None
} else if let Some(local_store) =
thread_store.as_any().downcast_ref::<LocalThreadStore>()
{
local_store.state_db().await
} else {
None
}
}
.instrument(info_span!(
"session_init.state_db",
otel.name = "session_init.state_db",
session_init.ephemeral = config.ephemeral,
));
let state_db_ctx = if config.ephemeral { None } else { state_db };
let is_subagent = session_configuration.session_source.is_non_root_agent();
let history_meta_fut = async {
@@ -495,15 +481,9 @@ impl Session {
// Join all independent futures.
let (
thread_persistence_result,
state_db_ctx,
(history_log_id, history_entry_count),
(auth, mcp_servers, auth_statuses),
) = tokio::join!(
thread_persistence_fut,
state_db_fut,
history_meta_fut,
auth_and_mcp_fut
);
) = tokio::join!(thread_persistence_fut, history_meta_fut, auth_and_mcp_fut);
let mut live_thread_init =
LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| {
+151 -103
View File
@@ -66,9 +66,10 @@ use crate::tasks::execute_user_shell_command;
use crate::tools::ToolRouter;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::GoalHandler;
use crate::tools::handlers::CreateGoalHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::UpdateGoalHandler;
use crate::tools::registry::ToolHandler;
use crate::tools::router::ToolCallSource;
use crate::turn_diff_tracker::TurnDiffTracker;
@@ -866,7 +867,7 @@ async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyho
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)?;
let session = make_session_with_config(move |config| {
let (session, _codex_home) = make_session_with_config(move |config| {
let cwd = config.cwd.clone();
config
.permissions
@@ -932,7 +933,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
&permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess),
)?;
let session = make_session_with_config(move |config| {
let (session, _codex_home) = make_session_with_config(move |config| {
let cwd = config.cwd.clone();
config
.permissions
@@ -1007,7 +1008,7 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any
&permission_profile_for_sandbox_policy(&sandbox_policy),
)?;
let session = make_session_with_config(move |config| {
let (session, _codex_home) = make_session_with_config(move |config| {
let cwd = config.cwd.clone();
config
.permissions
@@ -1034,7 +1035,7 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R
&permission_profile_for_sandbox_policy(&sandbox_policy),
)?;
let (session, rx) = make_session_with_config_and_rx(move |config| {
let (session, rx, _codex_home) = make_session_with_config_and_rx(move |config| {
let cwd = config.cwd.clone();
config
.permissions
@@ -1164,7 +1165,7 @@ async fn reload_user_config_layer_updates_effective_apps_config() {
#[tokio::test]
async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> {
let session = make_session_with_config(|config| {
let (session, _codex_home) = make_session_with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
@@ -1173,15 +1174,17 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> {
.await?;
let codex_home = session.codex_home().await;
std::fs::create_dir_all(&codex_home)?;
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"
[hooks]
[[hooks.SessionStart]]
hooks = [{ type = "command", command = "python3 /tmp/user.py" }]
"#,
)?;
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
let user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "python3 /tmp/user.py",
}],
}],
},
}))?;
let request = codex_hooks::SessionStartRequest {
session_id: session.conversation_id,
@@ -1193,6 +1196,39 @@ hooks = [{ type = "command", command = "python3 /tmp/user.py" }]
};
assert!(session.hooks().preview_session_start(&request).is_empty());
let config = session.get_config().await;
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: true,
config_layer_stack: Some(
config
.config_layer_stack
.with_user_config(&config_toml_path, user_config.clone()),
),
..codex_hooks::HooksConfig::default()
});
assert_eq!(hook_list.hooks.len(), 1);
assert_eq!(
hook_list.hooks[0].trust_status,
codex_protocol::protocol::HookTrustStatus::Untrusted
);
let trusted_user_config: codex_config::TomlValue = serde_json::from_value(serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "python3 /tmp/user.py",
}],
}],
"state": {
hook_list.hooks[0].key.clone(): {
"trusted_hash": hook_list.hooks[0].current_hash.clone(),
},
},
},
}))?;
std::fs::write(&config_toml_path, toml::to_string(&trusted_user_config)?)?;
session.reload_user_config_layer().await;
assert_eq!(session.hooks().preview_session_start(&request).len(), 1);
@@ -2946,15 +2982,13 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
fn turn_environments_for_tests(
environment: &Arc<codex_exec_server::Environment>,
cwd: &codex_utils_absolute_path::AbsolutePathBuf,
) -> crate::environment_selection::ResolvedTurnEnvironments {
crate::environment_selection::ResolvedTurnEnvironments {
turn_environments: vec![TurnEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
environment: Arc::clone(environment),
cwd: cwd.clone(),
shell: "bash".to_string(),
}],
}
) -> Vec<TurnEnvironment> {
vec![TurnEnvironment {
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
environment: Arc::clone(environment),
cwd: cwd.clone(),
shell: "bash".to_string(),
}]
}
#[tokio::test]
@@ -3492,9 +3526,15 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
/*state_db*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
@@ -3642,7 +3682,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
)),
model_client: ModelClient::new(
Some(auth_manager.clone()),
@@ -3687,7 +3732,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_info,
&models_manager,
/*network*/ None,
turn_environments,
crate::environment_selection::ResolvedTurnEnvironments { turn_environments },
session_configuration.cwd.clone(),
"turn_id".to_string(),
skills_outcome,
@@ -3720,14 +3765,18 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
async fn make_session_with_config(
mutator: impl FnOnce(&mut Config),
) -> anyhow::Result<Arc<Session>> {
let (session, _rx_event) = make_session_with_config_and_rx(mutator).await?;
Ok(session)
) -> anyhow::Result<(Arc<Session>, tempfile::TempDir)> {
let (session, _rx_event, codex_home) = make_session_with_config_and_rx(mutator).await?;
Ok((session, codex_home))
}
async fn make_session_with_config_and_rx(
mutator: impl FnOnce(&mut Config),
) -> anyhow::Result<(Arc<Session>, async_channel::Receiver<Event>)> {
) -> anyhow::Result<(
Arc<Session>,
async_channel::Receiver<Event>,
tempfile::TempDir,
)> {
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = build_test_config(codex_home.path()).await;
mutator(&mut config);
@@ -3812,15 +3861,21 @@ async fn make_session_with_config_and_rx(
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
/*state_db*/ None,
Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
/*state_db*/ None,
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
)
.await?;
Ok((session, rx_event))
Ok((session, rx_event, codex_home))
}
#[tokio::test]
@@ -4482,7 +4537,7 @@ async fn default_turn_honors_empty_stored_thread_environments() {
let turn_context = session.new_default_turn().await;
assert!(turn_context.environments.primary().is_none());
assert!(turn_context.environments.primary_environment().is_none());
assert!(turn_context.environments.turn_environments.is_empty());
assert_eq!(turn_context.cwd, session_cwd);
assert_eq!(turn_context.config.cwd, session_cwd);
@@ -4501,7 +4556,7 @@ async fn primary_environment_uses_first_turn_environment() {
environment_id: "second".to_string(),
environment: Arc::clone(&first_environment.environment),
cwd: second_cwd.clone(),
shell: first_environment.shell.clone(),
shell: "bash".to_string(),
});
assert_eq!(
@@ -4544,7 +4599,7 @@ async fn empty_turn_environments_clear_primary_environment() {
.await
.expect("turn should start");
assert!(turn_context.environments.primary().is_none());
assert!(turn_context.environments.primary_environment().is_none());
assert!(turn_context.environments.turn_environments.is_empty());
assert_eq!(turn_context.cwd, session.get_config().await.cwd);
assert_eq!(turn_context.config.cwd, session.get_config().await.cwd);
@@ -5000,47 +5055,13 @@ async fn make_session_and_context_with_auth_and_config_and_rx<F>(
Arc<TurnContext>,
async_channel::Receiver<Event>,
)
where
F: FnOnce(&mut Config),
{
let codex_home = tempfile::tempdir().expect("create temp dir");
make_session_and_context_with_auth_config_home_and_rx(
auth,
dynamic_tools,
codex_home.path(),
configure_config,
)
.await
}
async fn make_session_and_context_with_auth_config_home_and_rx<F>(
auth: CodexAuth,
dynamic_tools: Vec<DynamicToolSpec>,
codex_home: &Path,
configure_config: F,
) -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
)
where
F: FnOnce(&mut Config),
{
let (tx_event, rx_event) = async_channel::unbounded();
let mut config = build_test_config(codex_home).await;
let codex_home = tempfile::tempdir().expect("create temp dir").keep();
let mut config = build_test_config(codex_home.as_path()).await;
configure_config(&mut config);
let state_db = if config.features.enabled(Feature::Goals) {
Some(
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("goal tests should initialize sqlite state db"),
)
} else {
None
};
let config = Arc::new(config);
let conversation_id = ThreadId::default();
let auth_manager = AuthManager::from_auth_for_testing(auth);
@@ -5126,6 +5147,12 @@ where
.expect("create environment"),
);
let state_db = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let skills_watcher = Arc::new(SkillsWatcher::noop());
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
@@ -5166,7 +5193,7 @@ where
agent_control,
network_proxy: None,
network_approval: Arc::clone(&network_approval),
state_db: state_db.clone(),
state_db: Some(state_db.clone()),
live_thread: None,
thread_store: Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()),
@@ -5215,7 +5242,7 @@ where
model_info,
&models_manager,
/*network*/ None,
turn_environments,
crate::environment_selection::ResolvedTurnEnvironments { turn_environments },
session_configuration.cwd.clone(),
"turn_id".to_string(),
skills_outcome,
@@ -5265,13 +5292,10 @@ async fn make_goal_session_and_context_with_rx() -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
tempfile::TempDir,
) {
let codex_home = tempfile::tempdir().expect("create temp dir");
let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx(
let (session, turn_context, rx) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
codex_home.path(),
|config| {
config
.features
@@ -5281,14 +5305,14 @@ async fn make_goal_session_and_context_with_rx() -> (
)
.await;
upsert_goal_test_thread(session.as_ref()).await;
(session, turn_context, rx, codex_home)
(session, turn_context, rx)
}
async fn upsert_goal_test_thread(session: &Session) {
let config = session.get_config().await;
let state_db = session
.state_db()
.expect("goal test session should have a state db");
let state_db = goal_test_state_db(session)
.await
.expect("goal test state db should initialize");
let mut builder = codex_state::ThreadMetadataBuilder::new(
session.conversation_id,
config
@@ -7112,7 +7136,7 @@ async fn abort_empty_active_turn_preserves_pending_input() {
#[tokio::test]
async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7376,7 +7400,7 @@ async fn goal_test_state_db(sess: &Session) -> anyhow::Result<crate::StateDbHand
#[tokio::test]
async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> {
let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (sess, tc, rx) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7476,7 +7500,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
@@ -7543,7 +7567,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (sess, tc, _rx) = make_goal_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
@@ -8210,9 +8234,9 @@ async fn sample_rollout(
#[tokio::test]
async fn create_goal_tool_rejects_existing_goal() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let handler = CreateGoalHandler;
handler
.handle(ToolInvocation {
@@ -8272,11 +8296,12 @@ async fn create_goal_tool_rejects_existing_goal() {
#[tokio::test]
async fn update_goal_tool_rejects_pausing_goal() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
handler
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8296,7 +8321,7 @@ async fn update_goal_tool_rejects_pausing_goal() {
.await
.expect("initial create_goal should succeed");
let response = handler
let response = update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8332,11 +8357,12 @@ async fn update_goal_tool_rejects_pausing_goal() {
#[tokio::test]
async fn update_goal_tool_marks_goal_complete() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let (session, turn_context, _rx) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = GoalHandler;
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
handler
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8356,7 +8382,7 @@ async fn update_goal_tool_marks_goal_complete() {
.await
.expect("initial create_goal should succeed");
handler
update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
@@ -8513,7 +8539,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
let turn_context = Arc::new(turn_context_raw);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = UnifiedExecHandler;
let handler = ExecCommandHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
@@ -8568,17 +8594,27 @@ async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io:
.build()
.await?;
let preview = preview_session_start_hooks(&config).await?;
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: true,
config_layer_stack: Some(config.config_layer_stack.clone()),
..codex_hooks::HooksConfig::default()
});
let expected_source_path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
nested_dot_codex.join("hooks.json"),
)?;
assert_eq!(
preview
hook_list
.hooks
.iter()
.map(|run| &run.source_path)
.map(|hook| &hook.source_path)
.collect::<Vec<_>>(),
vec![&expected_source_path],
);
assert_eq!(
hook_list.hooks[0].trust_status,
codex_protocol::protocol::HookTrustStatus::Untrusted
);
assert!(preview_session_start_hooks(&config).await?.is_empty());
Ok(())
}
@@ -8618,11 +8654,23 @@ async fn session_start_hooks_require_project_trust_without_config_toml() -> std:
.build()
.await?;
let hook_list = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: true,
config_layer_stack: Some(config.config_layer_stack.clone()),
..codex_hooks::HooksConfig::default()
});
assert_eq!(
preview_session_start_hooks(&config).await?.len(),
hook_list.hooks.len(),
expected_hooks,
"unexpected hook count for {name}",
"unexpected discovered hook count for {name}",
);
assert!(preview_session_start_hooks(&config).await?.is_empty());
if expected_hooks == 1 {
assert_eq!(
hook_list.hooks[0].trust_status,
codex_protocol::protocol::HookTrustStatus::Untrusted
);
}
}
Ok(())
@@ -498,7 +498,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
let turn_context = Arc::new(turn_context_raw);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = UnifiedExecHandler;
let handler = ExecCommandHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
@@ -731,7 +731,12 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let skills_watcher = Arc::new(SkillsWatcher::noop());
let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new(
codex_thread_store::LocalThreadStoreConfig::from_config(&config),
/*state_db*/ None,
codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await
.expect("state db should initialize"),
));
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
@@ -760,6 +765,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
turn_environments: Vec::new(),
},
analytics_events_client: None,
state_db: None,
thread_store,
})
.await
@@ -8,6 +8,7 @@ use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use codex_tools::ToolEnvironmentMode;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
+3 -6
View File
@@ -138,11 +138,8 @@ pub(crate) async fn record_completed_response_item(
.await;
}
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
let has_memory_citation = record_stage1_output_usage_and_detect_memory_citation(
sess.services.state_db.as_ref(),
item,
)
.await;
let has_memory_citation =
record_stage1_output_usage_and_detect_memory_citation(sess.state_db(), item).await;
if has_memory_citation {
sess.record_memory_citation_for_turn(&turn_context.sub_id)
.await;
@@ -177,7 +174,7 @@ pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context(
}
async fn record_stage1_output_usage_and_detect_memory_citation(
state_db_ctx: Option<&state_db::StateDbHandle>,
state_db_ctx: Option<state_db::StateDbHandle>,
item: &ResponseItem,
) -> bool {
let Some(raw_text) = raw_assistant_output_text_from_item(item) else {
+4 -19
View File
@@ -52,14 +52,14 @@ pub fn auth_manager_from_auth_with_home(auth: CodexAuth, codex_home: PathBuf) ->
AuthManager::from_auth_for_testing_with_home(auth, codex_home)
}
pub fn thread_manager_with_models_provider(
pub async fn thread_manager_with_models_provider(
auth: CodexAuth,
provider: ModelProviderInfo,
) -> ThreadManager {
ThreadManager::with_models_provider_for_tests(auth, provider)
ThreadManager::with_models_provider_for_tests(auth, provider).await
}
pub fn thread_manager_with_models_provider_and_home(
pub async fn thread_manager_with_models_provider_and_home(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
@@ -71,22 +71,7 @@ pub fn thread_manager_with_models_provider_and_home(
codex_home,
environment_manager,
)
}
pub fn thread_manager_with_models_provider_home_and_state(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
state_db: Option<crate::StateDbHandle>,
) -> ThreadManager {
ThreadManager::with_models_provider_home_and_state_for_tests(
auth,
provider,
codex_home,
environment_manager,
state_db,
)
.await
}
pub async fn start_thread_with_user_shell_override(
+76 -34
View File
@@ -18,6 +18,8 @@ use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::tasks::InterruptedTurnHistoryMarker;
use crate::tasks::interrupted_turn_history_marker;
use codex_agent_graph_store::AgentGraphStore;
use codex_agent_graph_store::LocalAgentGraphStore;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
@@ -50,8 +52,8 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::state_db;
use codex_rollout::state_db::StateDbHandle;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
@@ -247,9 +249,10 @@ pub(crate) struct ThreadManagerState {
mcp_manager: Arc<McpManager>,
skills_watcher: Arc<SkillsWatcher>,
thread_store: Arc<dyn ThreadStore>,
state_db: StateDbHandle,
agent_graph_store: Arc<dyn AgentGraphStore>,
session_source: SessionSource,
analytics_events_client: Option<AnalyticsEventsClient>,
state_db: Option<StateDbHandle>,
// Captures submitted ops for testing purpose when test mode is enabled.
ops_log: Option<SharedCapturedOps>,
}
@@ -265,10 +268,11 @@ pub fn build_models_manager(
)
}
pub fn thread_store_from_config(
config: &Config,
state_db: Option<StateDbHandle>,
) -> Arc<dyn ThreadStore> {
pub async fn init_state_db_from_config(config: &Config) -> Option<StateDbHandle> {
state_db::init(config).await
}
pub fn thread_store_from_config(config: &Config, state_db: StateDbHandle) -> Arc<dyn ThreadStore> {
match &config.experimental_thread_store {
ThreadStoreConfig::Local => Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig::from_config(config),
@@ -279,15 +283,38 @@ pub fn thread_store_from_config(
}
}
pub fn agent_graph_store_from_state_db(state_db: StateDbHandle) -> Arc<dyn AgentGraphStore> {
Arc::new(LocalAgentGraphStore::new(state_db))
}
async fn state_db_from_roots_for_tests(
codex_home: PathBuf,
sqlite_home: PathBuf,
default_model_provider_id: String,
) -> StateDbHandle {
let config = codex_rollout::RolloutConfig {
codex_home: codex_home.clone(),
sqlite_home,
cwd: codex_home,
model_provider_id: default_model_provider_id,
generate_memories: false,
};
state_db::try_init(&config)
.await
.unwrap_or_else(|err| panic!("test state db should initialize: {err}"))
}
impl ThreadManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: &Config,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
analytics_events_client: Option<AnalyticsEventsClient>,
state_db: StateDbHandle,
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
agent_graph_store: Arc<dyn AgentGraphStore>,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
@@ -314,10 +341,11 @@ impl ThreadManager {
mcp_manager,
skills_watcher,
thread_store,
state_db,
agent_graph_store,
auth_manager,
session_source,
analytics_events_client,
state_db,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
@@ -327,7 +355,7 @@ impl ThreadManager {
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub(crate) fn with_models_provider_for_tests(
pub(crate) async fn with_models_provider_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
) -> Self {
@@ -338,11 +366,18 @@ impl ThreadManager {
));
std::fs::create_dir_all(&codex_home)
.unwrap_or_else(|err| panic!("temp codex home dir create failed: {err}"));
let mut manager = Self::with_models_provider_and_home_for_tests(
let state_db = state_db_from_roots_for_tests(
codex_home.clone(),
codex_home.clone(),
OPENAI_PROVIDER_ID.to_string(),
)
.await;
let mut manager = Self::with_models_provider_and_home_and_state_db_for_tests(
auth,
provider,
codex_home.clone(),
Arc::new(EnvironmentManager::default_for_tests()),
state_db,
);
manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home });
manager
@@ -350,27 +385,33 @@ impl ThreadManager {
/// Construct with a dummy AuthManager containing the provided CodexAuth and codex home.
/// Used for integration tests: should not be used by ordinary business logic.
pub(crate) fn with_models_provider_and_home_for_tests(
pub(crate) async fn with_models_provider_and_home_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
) -> Self {
Self::with_models_provider_home_and_state_for_tests(
let state_db = state_db_from_roots_for_tests(
codex_home.clone(),
codex_home.clone(),
OPENAI_PROVIDER_ID.to_string(),
)
.await;
Self::with_models_provider_and_home_and_state_db_for_tests(
auth,
provider,
codex_home,
environment_manager,
/*state_db*/ None,
state_db,
)
}
pub(crate) fn with_models_provider_home_and_state_for_tests(
fn with_models_provider_and_home_and_state_db_for_tests(
auth: CodexAuth,
provider: ModelProviderInfo,
codex_home: PathBuf,
environment_manager: Arc<EnvironmentManager>,
state_db: Option<StateDbHandle>,
state_db: StateDbHandle,
) -> Self {
set_thread_manager_test_mode_for_tests(/*enabled*/ true);
let auth_manager = AuthManager::from_auth_for_testing(auth);
@@ -396,11 +437,11 @@ impl ThreadManager {
let thread_store: Arc<dyn ThreadStore> = Arc::new(LocalThreadStore::new(
LocalThreadStoreConfig {
codex_home: codex_home.clone(),
sqlite_home: codex_home.clone(),
default_model_provider_id: OPENAI_PROVIDER_ID.to_string(),
},
state_db.clone(),
));
let agent_graph_store = agent_graph_store_from_state_db(state_db.clone());
Self {
state: Arc::new(ThreadManagerState {
threads: Arc::new(RwLock::new(HashMap::new())),
@@ -413,10 +454,11 @@ impl ThreadManager {
mcp_manager,
skills_watcher,
thread_store,
state_db,
agent_graph_store,
auth_manager,
session_source: SessionSource::Exec,
analytics_events_client: None,
state_db,
ops_log: should_use_test_thread_manager_behavior()
.then(|| Arc::new(std::sync::Mutex::new(Vec::new()))),
}),
@@ -523,22 +565,17 @@ impl ThreadManager {
subtree_thread_ids.push(thread_id);
seen_thread_ids.insert(thread_id);
if let Some(state_db_ctx) = thread.state_db() {
for status in [
DirectionalThreadSpawnEdgeStatus::Open,
DirectionalThreadSpawnEdgeStatus::Closed,
] {
for descendant_id in state_db_ctx
.list_thread_spawn_descendants_with_status(thread_id, status)
.await
.map_err(|err| {
CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}"))
})?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
for descendant_id in self
.state
.agent_graph_store
.list_thread_spawn_descendants(thread_id, /*status_filter*/ None)
.await
.map_err(|err| {
CodexErr::Fatal(format!("failed to load thread-spawn descendants: {err}"))
})?
{
if seen_thread_ids.insert(descendant_id) {
subtree_thread_ids.push(descendant_id);
}
}
@@ -864,10 +901,14 @@ impl ThreadManager {
}
impl ThreadManagerState {
pub(crate) fn state_db(&self) -> Option<StateDbHandle> {
pub(crate) fn state_db(&self) -> StateDbHandle {
self.state_db.clone()
}
pub(crate) fn agent_graph_store(&self) -> Arc<dyn AgentGraphStore> {
self.agent_graph_store.clone()
}
pub(crate) async fn list_thread_ids(&self) -> Vec<ThreadId> {
self.threads
.read()
@@ -1172,6 +1213,7 @@ impl ThreadManagerState {
parent_trace,
environment_selections,
analytics_events_client: self.analytics_events_client.clone(),
state_db: Some(self.state_db.clone()),
thread_store: Arc::clone(&self.thread_store),
})
.await?;

Some files were not shown because too many files have changed in this diff Show More