mirror of
https://github.com/openai/codex.git
synced 2026-05-25 05:24:37 +00:00
## Why The desktop app on Windows needs a read-only way to tell, before the next tool call, whether the local Windows sandbox setup is in a state that should block the user and ask for setup again. The main case we want to cover is the elevated sandbox setup version bump. Today, if the app is configured for elevated Windows sandboxing and the installed setup is stale, the next sandboxed shell/exec path can end up triggering the elevated setup flow directly. That means the user can see an unexpected UAC prompt with no UI explanation. This change adds a small app-server preflight so the desktop app can ask “is Windows sandbox ready, not configured, or update-required?” during startup and show the appropriate blocking UI before the user hits a tool call. ## What changed - Added a new read-only app-server RPC: `windowsSandbox/readiness` - Added a new protocol enum and response type: - `WindowsSandboxReadiness` - `WindowsSandboxReadinessResponse` - Added core readiness logic in `core/src/windows_sandbox.rs`: - `ready` - `notConfigured` - `updateRequired` - Wired the new request through `codex_message_processor` - Regenerated the vendored app-server schema fixtures ## Readiness semantics This is intentionally a coarse startup/version-bump readiness check, not a full predictor of every runtime repair case. For now, readiness is determined from: - the configured Windows sandbox level - `sandbox_setup_is_complete()` for elevated mode That means: - `disabled` maps to `notConfigured` - `restricted token` maps to `ready` - `elevated` maps to `ready` or `updateRequired` depending on `sandbox_setup_is_complete()` This is deliberate for the first UI integration because the common case we want to catch is “the app updated, the elevated setup version bumped, and the user should see an update-required blocker instead of a surprise UAC prompt”. It does not attempt to model every case where the deeper runtime path might decide to repair or re-run setup. ## Testing - Ran `cargo fmt --all -- app-server-protocol/src/protocol/common.rs app-server-protocol/src/protocol/v2.rs app-server/src/codex_message_processor.rs core/src/windows_sandbox.rs core/src/windows_sandbox_tests.rs` - Added unit tests for the pure readiness mapping in `core/src/windows_sandbox_tests.rs` - Regenerated vendored schema fixtures with `cargo run -p codex-app-server-protocol --bin write_schema_fixtures -- --schema-root app-server-protocol/schema` - Did not run the full cargo test suite
2970 lines
106 KiB
Rust
2970 lines
106 KiB
Rust
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::JSONRPCNotification;
|
|
use crate::JSONRPCRequest;
|
|
use crate::RequestId;
|
|
use crate::export::GeneratedSchema;
|
|
use crate::export::write_json_schema;
|
|
use crate::protocol::v1;
|
|
use crate::protocol::v2;
|
|
use codex_experimental_api_macros::ExperimentalApi;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use strum_macros::Display;
|
|
use ts_rs::TS;
|
|
|
|
/// Authentication mode for OpenAI-backed providers.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AuthMode {
|
|
/// OpenAI API key provided by the caller and stored by Codex.
|
|
ApiKey,
|
|
/// ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).
|
|
Chatgpt,
|
|
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
|
|
///
|
|
/// ChatGPT auth tokens are supplied by an external host app and are only
|
|
/// stored in memory. Token refresh must be handled by the external host app.
|
|
#[serde(rename = "chatgptAuthTokens")]
|
|
#[ts(rename = "chatgptAuthTokens")]
|
|
#[strum(serialize = "chatgptAuthTokens")]
|
|
ChatgptAuthTokens,
|
|
/// Programmatic Codex auth backed by a registered Agent Identity.
|
|
#[serde(rename = "agentIdentity")]
|
|
#[ts(rename = "agentIdentity")]
|
|
#[strum(serialize = "agentIdentity")]
|
|
AgentIdentity,
|
|
}
|
|
|
|
macro_rules! experimental_reason_expr {
|
|
// If a request variant is explicitly marked experimental, that reason wins.
|
|
(variant $variant:ident, #[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => {
|
|
Some($reason)
|
|
};
|
|
// `inspect_params: true` is used when a method is mostly stable but needs
|
|
// field-level gating from its params type (for example, ThreadStart).
|
|
(variant $variant:ident, $params:ident, true) => {
|
|
crate::experimental_api::ExperimentalApi::experimental_reason($params)
|
|
};
|
|
(variant $variant:ident, $params:ident $(, $inspect_params:tt)?) => {
|
|
None
|
|
};
|
|
}
|
|
|
|
macro_rules! experimental_method_entry {
|
|
(#[experimental($reason:expr)] => $wire:literal) => {
|
|
$wire
|
|
};
|
|
(#[experimental($reason:expr)]) => {
|
|
$reason
|
|
};
|
|
($($tt:tt)*) => {
|
|
""
|
|
};
|
|
}
|
|
|
|
macro_rules! experimental_type_entry {
|
|
(#[experimental($reason:expr)] $ty:ty) => {
|
|
stringify!($ty)
|
|
};
|
|
($ty:ty) => {
|
|
""
|
|
};
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ClientRequestSerializationScope {
|
|
Global(&'static str),
|
|
Thread { thread_id: String },
|
|
ThreadPath { path: PathBuf },
|
|
CommandExecProcess { process_id: String },
|
|
Process { process_handle: String },
|
|
FuzzyFileSearchSession { session_id: String },
|
|
FsWatch { watch_id: String },
|
|
McpOauth { server_name: String },
|
|
}
|
|
|
|
macro_rules! serialization_scope_expr {
|
|
($actual_params:ident, None) => {
|
|
None
|
|
};
|
|
($actual_params:ident, global($key:literal)) => {
|
|
Some(ClientRequestSerializationScope::Global($key))
|
|
};
|
|
($actual_params:ident, thread_id($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
($actual_params:ident, optional_thread_id($params:ident . $field:ident)) => {
|
|
$actual_params
|
|
.$field
|
|
.clone()
|
|
.map(|thread_id| ClientRequestSerializationScope::Thread { thread_id })
|
|
};
|
|
($actual_params:ident, thread_or_path($params:ident . $thread_field:ident, $params2:ident . $path_field:ident)) => {
|
|
if !$actual_params.$thread_field.is_empty() {
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: $actual_params.$thread_field.clone(),
|
|
})
|
|
} else if let Some(path) = $actual_params.$path_field.clone() {
|
|
Some(ClientRequestSerializationScope::ThreadPath { path })
|
|
} else {
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: $actual_params.$thread_field.clone(),
|
|
})
|
|
}
|
|
};
|
|
($actual_params:ident, optional_command_process_id($params:ident . $field:ident)) => {
|
|
$actual_params
|
|
.$field
|
|
.clone()
|
|
.map(|process_id| ClientRequestSerializationScope::CommandExecProcess { process_id })
|
|
};
|
|
($actual_params:ident, command_process_id($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::CommandExecProcess {
|
|
process_id: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
($actual_params:ident, process_handle($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::Process {
|
|
process_handle: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
|
|
session_id: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
($actual_params:ident, fs_watch_id($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::FsWatch {
|
|
watch_id: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
($actual_params:ident, mcp_oauth_server($params:ident . $field:ident)) => {
|
|
Some(ClientRequestSerializationScope::McpOauth {
|
|
server_name: $actual_params.$field.clone(),
|
|
})
|
|
};
|
|
}
|
|
|
|
/// Generates an `enum ClientRequest` where each variant is a request that the
|
|
/// client can send to the server. Each variant has associated `params` and
|
|
/// `response` types. Also generates a `export_client_responses()` function to
|
|
/// export all response types to TypeScript.
|
|
macro_rules! client_request_definitions {
|
|
(
|
|
$(
|
|
$(#[experimental($reason:expr)])?
|
|
$(#[doc = $variant_doc:literal])*
|
|
$variant:ident $(=> $wire:literal)? {
|
|
params: $(#[$params_meta:meta])* $params:ty,
|
|
$(inspect_params: $inspect_params:tt,)?
|
|
serialization: $serialization:ident $( ( $($serialization_args:tt)* ) )?,
|
|
$(manual_payload_conversion: $manual_payload_conversion:ident,)?
|
|
response: $response:ty,
|
|
}
|
|
),* $(,)?
|
|
) => {
|
|
/// Request from the client to the server.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ClientRequest {
|
|
$(
|
|
$(#[doc = $variant_doc])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
$(#[$params_meta])*
|
|
params: $params,
|
|
},
|
|
)*
|
|
}
|
|
|
|
impl ClientRequest {
|
|
pub fn id(&self) -> &RequestId {
|
|
match self {
|
|
$(Self::$variant { request_id, .. } => request_id,)*
|
|
}
|
|
}
|
|
|
|
pub fn method(&self) -> String {
|
|
serde_json::to_value(self)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("method")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.unwrap_or_else(|| "<unknown>".to_string())
|
|
}
|
|
|
|
pub fn serialization_scope(&self) -> Option<ClientRequestSerializationScope> {
|
|
match self {
|
|
$(
|
|
Self::$variant { params, .. } => {
|
|
let _ = params;
|
|
serialization_scope_expr!(
|
|
params, $serialization $( ( $($serialization_args)* ) )?
|
|
)
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Typed response from the server to the client.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ClientResponse {
|
|
$(
|
|
$(#[doc = $variant_doc])*
|
|
$(#[serde(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
response: $response,
|
|
},
|
|
)*
|
|
}
|
|
|
|
impl ClientResponse {
|
|
pub fn id(&self) -> &RequestId {
|
|
match self {
|
|
$(Self::$variant { request_id, .. } => request_id,)*
|
|
}
|
|
}
|
|
|
|
pub fn method(&self) -> String {
|
|
serde_json::to_value(self)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("method")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.unwrap_or_else(|| "<unknown>".to_string())
|
|
}
|
|
|
|
pub fn into_jsonrpc_parts(
|
|
self,
|
|
) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> {
|
|
match self {
|
|
$(
|
|
Self::$variant { request_id, response } => {
|
|
serde_json::to_value(response).map(|result| (request_id, result))
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum ClientResponsePayload {
|
|
$( $variant($response), )*
|
|
InterruptConversation(v1::InterruptConversationResponse),
|
|
}
|
|
|
|
impl ClientResponsePayload {
|
|
pub fn into_jsonrpc_parts_and_payload(
|
|
self,
|
|
request_id: RequestId,
|
|
) -> std::result::Result<
|
|
(RequestId, crate::Result, Option<ClientResponsePayload>),
|
|
serde_json::Error,
|
|
> {
|
|
match self {
|
|
$(
|
|
Self::$variant(response) => {
|
|
let result = serde_json::to_value(&response)?;
|
|
Ok((request_id, result, Some(Self::$variant(response))))
|
|
}
|
|
)*
|
|
Self::InterruptConversation(response) => {
|
|
serde_json::to_value(response).map(|result| (request_id, result, None))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn into_client_response(self, request_id: RequestId) -> Option<ClientResponse> {
|
|
match self {
|
|
$(
|
|
Self::$variant(response) => {
|
|
Some(ClientResponse::$variant {
|
|
request_id,
|
|
response,
|
|
})
|
|
}
|
|
)*
|
|
Self::InterruptConversation(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn into_jsonrpc_parts(
|
|
self,
|
|
request_id: RequestId,
|
|
) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> {
|
|
self.to_jsonrpc_parts(request_id)
|
|
}
|
|
|
|
pub fn to_jsonrpc_parts(
|
|
&self,
|
|
request_id: RequestId,
|
|
) -> std::result::Result<(RequestId, crate::Result), serde_json::Error> {
|
|
match self {
|
|
$(
|
|
Self::$variant(response) => {
|
|
serde_json::to_value(response).map(|result| (request_id, result))
|
|
}
|
|
)*
|
|
Self::InterruptConversation(response) => {
|
|
serde_json::to_value(response).map(|result| (request_id, result))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<v1::InterruptConversationResponse> for ClientResponsePayload {
|
|
fn from(response: v1::InterruptConversationResponse) -> Self {
|
|
Self::InterruptConversation(response)
|
|
}
|
|
}
|
|
|
|
$(
|
|
client_response_payload_from_impl!(
|
|
$variant,
|
|
$response
|
|
$(, $manual_payload_conversion)?
|
|
);
|
|
)*
|
|
|
|
impl crate::experimental_api::ExperimentalApi for ClientRequest {
|
|
fn experimental_reason(&self) -> Option<&'static str> {
|
|
match self {
|
|
$(
|
|
Self::$variant { params: _params, .. } => {
|
|
experimental_reason_expr!(
|
|
variant $variant,
|
|
$(#[experimental($reason)])?
|
|
_params
|
|
$(, $inspect_params)?
|
|
)
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHODS: &[&str] = &[
|
|
$(
|
|
experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?),
|
|
)*
|
|
];
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES: &[&str] = &[
|
|
$(
|
|
experimental_type_entry!($(#[experimental($reason)])? $params),
|
|
)*
|
|
];
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES: &[&str] = &[
|
|
$(
|
|
experimental_type_entry!($(#[experimental($reason)])? $response),
|
|
)*
|
|
];
|
|
|
|
pub fn export_client_responses(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
|
|
$(
|
|
<$response as ::ts_rs::TS>::export_all_to(out_dir)?;
|
|
)*
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn visit_client_response_types(v: &mut impl ::ts_rs::TypeVisitor) {
|
|
$(
|
|
v.visit::<$response>();
|
|
)*
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_client_response_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(write_json_schema::<$response>(out_dir, stringify!($response))?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_client_param_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(write_json_schema::<$params>(out_dir, stringify!($params))?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
macro_rules! client_response_payload_from_impl {
|
|
($variant:ident, $response:ty) => {
|
|
impl From<$response> for ClientResponsePayload {
|
|
fn from(response: $response) -> Self {
|
|
Self::$variant(response)
|
|
}
|
|
}
|
|
};
|
|
($variant:ident, $response:ty, manual) => {};
|
|
}
|
|
|
|
client_request_definitions! {
|
|
Initialize {
|
|
params: v1::InitializeParams,
|
|
serialization: None,
|
|
response: v1::InitializeResponse,
|
|
},
|
|
|
|
/// NEW APIs
|
|
// Thread lifecycle
|
|
// Uses `inspect_params` because only some fields are experimental.
|
|
ThreadStart => "thread/start" {
|
|
params: v2::ThreadStartParams,
|
|
inspect_params: true,
|
|
serialization: None,
|
|
response: v2::ThreadStartResponse,
|
|
},
|
|
ThreadResume => "thread/resume" {
|
|
params: v2::ThreadResumeParams,
|
|
inspect_params: true,
|
|
serialization: thread_or_path(params.thread_id, params.path),
|
|
response: v2::ThreadResumeResponse,
|
|
},
|
|
ThreadFork => "thread/fork" {
|
|
params: v2::ThreadForkParams,
|
|
inspect_params: true,
|
|
serialization: thread_or_path(params.thread_id, params.path),
|
|
response: v2::ThreadForkResponse,
|
|
},
|
|
ThreadArchive => "thread/archive" {
|
|
params: v2::ThreadArchiveParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadArchiveResponse,
|
|
},
|
|
ThreadUnsubscribe => "thread/unsubscribe" {
|
|
params: v2::ThreadUnsubscribeParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadUnsubscribeResponse,
|
|
},
|
|
#[experimental("thread/increment_elicitation")]
|
|
/// Increment the thread-local out-of-band elicitation counter.
|
|
///
|
|
/// This is used by external helpers to pause timeout accounting while a user
|
|
/// approval or other elicitation is pending outside the app-server request flow.
|
|
ThreadIncrementElicitation => "thread/increment_elicitation" {
|
|
params: v2::ThreadIncrementElicitationParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadIncrementElicitationResponse,
|
|
},
|
|
#[experimental("thread/decrement_elicitation")]
|
|
/// Decrement the thread-local out-of-band elicitation counter.
|
|
///
|
|
/// When the count reaches zero, timeout accounting resumes for the thread.
|
|
ThreadDecrementElicitation => "thread/decrement_elicitation" {
|
|
params: v2::ThreadDecrementElicitationParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadDecrementElicitationResponse,
|
|
},
|
|
ThreadSetName => "thread/name/set" {
|
|
params: v2::ThreadSetNameParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadSetNameResponse,
|
|
},
|
|
#[experimental("thread/goal/set")]
|
|
ThreadGoalSet => "thread/goal/set" {
|
|
params: v2::ThreadGoalSetParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadGoalSetResponse,
|
|
},
|
|
#[experimental("thread/goal/get")]
|
|
ThreadGoalGet => "thread/goal/get" {
|
|
params: v2::ThreadGoalGetParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadGoalGetResponse,
|
|
},
|
|
#[experimental("thread/goal/clear")]
|
|
ThreadGoalClear => "thread/goal/clear" {
|
|
params: v2::ThreadGoalClearParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadGoalClearResponse,
|
|
},
|
|
ThreadMetadataUpdate => "thread/metadata/update" {
|
|
params: v2::ThreadMetadataUpdateParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadMetadataUpdateResponse,
|
|
},
|
|
#[experimental("thread/memoryMode/set")]
|
|
ThreadMemoryModeSet => "thread/memoryMode/set" {
|
|
params: v2::ThreadMemoryModeSetParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadMemoryModeSetResponse,
|
|
},
|
|
#[experimental("memory/reset")]
|
|
MemoryReset => "memory/reset" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: global("memory"),
|
|
response: v2::MemoryResetResponse,
|
|
},
|
|
ThreadUnarchive => "thread/unarchive" {
|
|
params: v2::ThreadUnarchiveParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadUnarchiveResponse,
|
|
},
|
|
ThreadCompactStart => "thread/compact/start" {
|
|
params: v2::ThreadCompactStartParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadCompactStartResponse,
|
|
},
|
|
ThreadShellCommand => "thread/shellCommand" {
|
|
params: v2::ThreadShellCommandParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadShellCommandResponse,
|
|
},
|
|
ThreadApproveGuardianDeniedAction => "thread/approveGuardianDeniedAction" {
|
|
params: v2::ThreadApproveGuardianDeniedActionParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadApproveGuardianDeniedActionResponse,
|
|
},
|
|
#[experimental("thread/backgroundTerminals/clean")]
|
|
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
|
|
params: v2::ThreadBackgroundTerminalsCleanParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadBackgroundTerminalsCleanResponse,
|
|
},
|
|
ThreadRollback => "thread/rollback" {
|
|
params: v2::ThreadRollbackParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadRollbackResponse,
|
|
},
|
|
ThreadList => "thread/list" {
|
|
params: v2::ThreadListParams,
|
|
serialization: None,
|
|
response: v2::ThreadListResponse,
|
|
},
|
|
ThreadLoadedList => "thread/loaded/list" {
|
|
params: v2::ThreadLoadedListParams,
|
|
serialization: None,
|
|
response: v2::ThreadLoadedListResponse,
|
|
},
|
|
ThreadRead => "thread/read" {
|
|
params: v2::ThreadReadParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadReadResponse,
|
|
},
|
|
#[experimental("thread/turns/list")]
|
|
ThreadTurnsList => "thread/turns/list" {
|
|
params: v2::ThreadTurnsListParams,
|
|
// Explicitly concurrent: this primarily reads append-only rollout storage.
|
|
serialization: None,
|
|
response: v2::ThreadTurnsListResponse,
|
|
},
|
|
/// Append raw Responses API items to the thread history without starting a user turn.
|
|
ThreadInjectItems => "thread/inject_items" {
|
|
params: v2::ThreadInjectItemsParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadInjectItemsResponse,
|
|
},
|
|
SkillsList => "skills/list" {
|
|
params: v2::SkillsListParams,
|
|
serialization: global("config"),
|
|
response: v2::SkillsListResponse,
|
|
},
|
|
HooksList => "hooks/list" {
|
|
params: v2::HooksListParams,
|
|
serialization: global("config"),
|
|
response: v2::HooksListResponse,
|
|
},
|
|
MarketplaceAdd => "marketplace/add" {
|
|
params: v2::MarketplaceAddParams,
|
|
serialization: global("config"),
|
|
response: v2::MarketplaceAddResponse,
|
|
},
|
|
MarketplaceRemove => "marketplace/remove" {
|
|
params: v2::MarketplaceRemoveParams,
|
|
serialization: global("config"),
|
|
response: v2::MarketplaceRemoveResponse,
|
|
},
|
|
MarketplaceUpgrade => "marketplace/upgrade" {
|
|
params: v2::MarketplaceUpgradeParams,
|
|
serialization: global("config"),
|
|
response: v2::MarketplaceUpgradeResponse,
|
|
},
|
|
PluginList => "plugin/list" {
|
|
params: v2::PluginListParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginListResponse,
|
|
},
|
|
PluginRead => "plugin/read" {
|
|
params: v2::PluginReadParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginReadResponse,
|
|
},
|
|
PluginSkillRead => "plugin/skill/read" {
|
|
params: v2::PluginSkillReadParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginSkillReadResponse,
|
|
},
|
|
PluginShareSave => "plugin/share/save" {
|
|
params: v2::PluginShareSaveParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginShareSaveResponse,
|
|
},
|
|
PluginShareList => "plugin/share/list" {
|
|
params: v2::PluginShareListParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginShareListResponse,
|
|
},
|
|
PluginShareDelete => "plugin/share/delete" {
|
|
params: v2::PluginShareDeleteParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginShareDeleteResponse,
|
|
},
|
|
AppsList => "app/list" {
|
|
params: v2::AppsListParams,
|
|
serialization: None,
|
|
response: v2::AppsListResponse,
|
|
},
|
|
DeviceKeyCreate => "device/key/create" {
|
|
params: v2::DeviceKeyCreateParams,
|
|
serialization: global("device-key"),
|
|
response: v2::DeviceKeyCreateResponse,
|
|
},
|
|
DeviceKeyPublic => "device/key/public" {
|
|
params: v2::DeviceKeyPublicParams,
|
|
serialization: global("device-key"),
|
|
response: v2::DeviceKeyPublicResponse,
|
|
},
|
|
DeviceKeySign => "device/key/sign" {
|
|
params: v2::DeviceKeySignParams,
|
|
serialization: global("device-key"),
|
|
response: v2::DeviceKeySignResponse,
|
|
},
|
|
// File system requests are intentionally concurrent. Desktop already treats local
|
|
// file system operations as concurrent, and app-server remote fs mirrors that model.
|
|
FsReadFile => "fs/readFile" {
|
|
params: v2::FsReadFileParams,
|
|
serialization: None,
|
|
response: v2::FsReadFileResponse,
|
|
},
|
|
FsWriteFile => "fs/writeFile" {
|
|
params: v2::FsWriteFileParams,
|
|
serialization: None,
|
|
response: v2::FsWriteFileResponse,
|
|
},
|
|
FsCreateDirectory => "fs/createDirectory" {
|
|
params: v2::FsCreateDirectoryParams,
|
|
serialization: None,
|
|
response: v2::FsCreateDirectoryResponse,
|
|
},
|
|
FsGetMetadata => "fs/getMetadata" {
|
|
params: v2::FsGetMetadataParams,
|
|
serialization: None,
|
|
response: v2::FsGetMetadataResponse,
|
|
},
|
|
FsReadDirectory => "fs/readDirectory" {
|
|
params: v2::FsReadDirectoryParams,
|
|
serialization: None,
|
|
response: v2::FsReadDirectoryResponse,
|
|
},
|
|
FsRemove => "fs/remove" {
|
|
params: v2::FsRemoveParams,
|
|
serialization: None,
|
|
response: v2::FsRemoveResponse,
|
|
},
|
|
FsCopy => "fs/copy" {
|
|
params: v2::FsCopyParams,
|
|
serialization: None,
|
|
response: v2::FsCopyResponse,
|
|
},
|
|
FsWatch => "fs/watch" {
|
|
params: v2::FsWatchParams,
|
|
serialization: fs_watch_id(params.watch_id),
|
|
response: v2::FsWatchResponse,
|
|
},
|
|
FsUnwatch => "fs/unwatch" {
|
|
params: v2::FsUnwatchParams,
|
|
serialization: fs_watch_id(params.watch_id),
|
|
response: v2::FsUnwatchResponse,
|
|
},
|
|
SkillsConfigWrite => "skills/config/write" {
|
|
params: v2::SkillsConfigWriteParams,
|
|
serialization: global("config"),
|
|
response: v2::SkillsConfigWriteResponse,
|
|
},
|
|
PluginInstall => "plugin/install" {
|
|
params: v2::PluginInstallParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginInstallResponse,
|
|
},
|
|
PluginUninstall => "plugin/uninstall" {
|
|
params: v2::PluginUninstallParams,
|
|
serialization: global("config"),
|
|
response: v2::PluginUninstallResponse,
|
|
},
|
|
TurnStart => "turn/start" {
|
|
params: v2::TurnStartParams,
|
|
inspect_params: true,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::TurnStartResponse,
|
|
},
|
|
TurnSteer => "turn/steer" {
|
|
params: v2::TurnSteerParams,
|
|
inspect_params: true,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::TurnSteerResponse,
|
|
},
|
|
TurnInterrupt => "turn/interrupt" {
|
|
params: v2::TurnInterruptParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::TurnInterruptResponse,
|
|
},
|
|
#[experimental("thread/realtime/start")]
|
|
ThreadRealtimeStart => "thread/realtime/start" {
|
|
params: v2::ThreadRealtimeStartParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadRealtimeStartResponse,
|
|
},
|
|
#[experimental("thread/realtime/appendAudio")]
|
|
ThreadRealtimeAppendAudio => "thread/realtime/appendAudio" {
|
|
params: v2::ThreadRealtimeAppendAudioParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadRealtimeAppendAudioResponse,
|
|
},
|
|
#[experimental("thread/realtime/appendText")]
|
|
ThreadRealtimeAppendText => "thread/realtime/appendText" {
|
|
params: v2::ThreadRealtimeAppendTextParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadRealtimeAppendTextResponse,
|
|
},
|
|
#[experimental("thread/realtime/stop")]
|
|
ThreadRealtimeStop => "thread/realtime/stop" {
|
|
params: v2::ThreadRealtimeStopParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ThreadRealtimeStopResponse,
|
|
},
|
|
#[experimental("thread/realtime/listVoices")]
|
|
ThreadRealtimeListVoices => "thread/realtime/listVoices" {
|
|
params: v2::ThreadRealtimeListVoicesParams,
|
|
serialization: None,
|
|
response: v2::ThreadRealtimeListVoicesResponse,
|
|
},
|
|
ReviewStart => "review/start" {
|
|
params: v2::ReviewStartParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::ReviewStartResponse,
|
|
},
|
|
|
|
ModelList => "model/list" {
|
|
params: v2::ModelListParams,
|
|
serialization: None,
|
|
response: v2::ModelListResponse,
|
|
},
|
|
ModelProviderCapabilitiesRead => "modelProvider/capabilities/read" {
|
|
params: v2::ModelProviderCapabilitiesReadParams,
|
|
serialization: None,
|
|
response: v2::ModelProviderCapabilitiesReadResponse,
|
|
},
|
|
ExperimentalFeatureList => "experimentalFeature/list" {
|
|
params: v2::ExperimentalFeatureListParams,
|
|
serialization: global("config"),
|
|
response: v2::ExperimentalFeatureListResponse,
|
|
},
|
|
ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" {
|
|
params: v2::ExperimentalFeatureEnablementSetParams,
|
|
serialization: global("config"),
|
|
response: v2::ExperimentalFeatureEnablementSetResponse,
|
|
},
|
|
#[experimental("collaborationMode/list")]
|
|
/// Lists collaboration mode presets.
|
|
CollaborationModeList => "collaborationMode/list" {
|
|
params: v2::CollaborationModeListParams,
|
|
serialization: None,
|
|
response: v2::CollaborationModeListResponse,
|
|
},
|
|
#[experimental("mock/experimentalMethod")]
|
|
/// Test-only method used to validate experimental gating.
|
|
MockExperimentalMethod => "mock/experimentalMethod" {
|
|
params: v2::MockExperimentalMethodParams,
|
|
serialization: None,
|
|
response: v2::MockExperimentalMethodResponse,
|
|
},
|
|
|
|
McpServerOauthLogin => "mcpServer/oauth/login" {
|
|
params: v2::McpServerOauthLoginParams,
|
|
serialization: mcp_oauth_server(params.name),
|
|
response: v2::McpServerOauthLoginResponse,
|
|
},
|
|
|
|
McpServerRefresh => "config/mcpServer/reload" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: global("mcp-registry"),
|
|
response: v2::McpServerRefreshResponse,
|
|
},
|
|
|
|
McpServerStatusList => "mcpServerStatus/list" {
|
|
params: v2::ListMcpServerStatusParams,
|
|
serialization: global("mcp-registry"),
|
|
response: v2::ListMcpServerStatusResponse,
|
|
},
|
|
|
|
McpResourceRead => "mcpServer/resource/read" {
|
|
params: v2::McpResourceReadParams,
|
|
serialization: optional_thread_id(params.thread_id),
|
|
response: v2::McpResourceReadResponse,
|
|
},
|
|
|
|
McpServerToolCall => "mcpServer/tool/call" {
|
|
params: v2::McpServerToolCallParams,
|
|
serialization: thread_id(params.thread_id),
|
|
response: v2::McpServerToolCallResponse,
|
|
},
|
|
|
|
WindowsSandboxSetupStart => "windowsSandbox/setupStart" {
|
|
params: v2::WindowsSandboxSetupStartParams,
|
|
serialization: global("windows-sandbox-setup"),
|
|
response: v2::WindowsSandboxSetupStartResponse,
|
|
},
|
|
WindowsSandboxReadiness => "windowsSandbox/readiness" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: global("config"),
|
|
response: v2::WindowsSandboxReadinessResponse,
|
|
},
|
|
|
|
LoginAccount => "account/login/start" {
|
|
params: v2::LoginAccountParams,
|
|
inspect_params: true,
|
|
serialization: global("account-auth"),
|
|
response: v2::LoginAccountResponse,
|
|
},
|
|
|
|
CancelLoginAccount => "account/login/cancel" {
|
|
params: v2::CancelLoginAccountParams,
|
|
serialization: global("account-auth"),
|
|
response: v2::CancelLoginAccountResponse,
|
|
},
|
|
|
|
LogoutAccount => "account/logout" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: global("account-auth"),
|
|
response: v2::LogoutAccountResponse,
|
|
},
|
|
|
|
GetAccountRateLimits => "account/rateLimits/read" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: None,
|
|
response: v2::GetAccountRateLimitsResponse,
|
|
},
|
|
|
|
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
|
|
params: v2::SendAddCreditsNudgeEmailParams,
|
|
serialization: global("account-auth"),
|
|
response: v2::SendAddCreditsNudgeEmailResponse,
|
|
},
|
|
|
|
FeedbackUpload => "feedback/upload" {
|
|
params: v2::FeedbackUploadParams,
|
|
serialization: None,
|
|
response: v2::FeedbackUploadResponse,
|
|
},
|
|
|
|
/// Execute a standalone command (argv vector) under the server's sandbox.
|
|
OneOffCommandExec => "command/exec" {
|
|
params: v2::CommandExecParams,
|
|
inspect_params: true,
|
|
serialization: optional_command_process_id(params.process_id),
|
|
response: v2::CommandExecResponse,
|
|
},
|
|
/// Write stdin bytes to a running `command/exec` session or close stdin.
|
|
CommandExecWrite => "command/exec/write" {
|
|
params: v2::CommandExecWriteParams,
|
|
serialization: command_process_id(params.process_id),
|
|
response: v2::CommandExecWriteResponse,
|
|
},
|
|
/// Terminate a running `command/exec` session by client-supplied `processId`.
|
|
CommandExecTerminate => "command/exec/terminate" {
|
|
params: v2::CommandExecTerminateParams,
|
|
serialization: command_process_id(params.process_id),
|
|
response: v2::CommandExecTerminateResponse,
|
|
},
|
|
/// Resize a running PTY-backed `command/exec` session by client-supplied `processId`.
|
|
CommandExecResize => "command/exec/resize" {
|
|
params: v2::CommandExecResizeParams,
|
|
serialization: command_process_id(params.process_id),
|
|
response: v2::CommandExecResizeResponse,
|
|
},
|
|
#[experimental("process/spawn")]
|
|
/// Spawn a standalone process (argv vector) without a Codex sandbox.
|
|
ProcessSpawn => "process/spawn" {
|
|
params: v2::ProcessSpawnParams,
|
|
serialization: process_handle(params.process_handle),
|
|
response: v2::ProcessSpawnResponse,
|
|
},
|
|
#[experimental("process/writeStdin")]
|
|
/// Write stdin bytes to a running `process/spawn` session or close stdin.
|
|
ProcessWriteStdin => "process/writeStdin" {
|
|
params: v2::ProcessWriteStdinParams,
|
|
serialization: process_handle(params.process_handle),
|
|
response: v2::ProcessWriteStdinResponse,
|
|
},
|
|
#[experimental("process/kill")]
|
|
/// Terminate a running `process/spawn` session by client-supplied `processHandle`.
|
|
ProcessKill => "process/kill" {
|
|
params: v2::ProcessKillParams,
|
|
serialization: process_handle(params.process_handle),
|
|
response: v2::ProcessKillResponse,
|
|
},
|
|
#[experimental("process/resizePty")]
|
|
/// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`.
|
|
ProcessResizePty => "process/resizePty" {
|
|
params: v2::ProcessResizePtyParams,
|
|
serialization: process_handle(params.process_handle),
|
|
response: v2::ProcessResizePtyResponse,
|
|
},
|
|
|
|
ConfigRead => "config/read" {
|
|
params: v2::ConfigReadParams,
|
|
serialization: global("config"),
|
|
response: v2::ConfigReadResponse,
|
|
},
|
|
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
|
|
params: v2::ExternalAgentConfigDetectParams,
|
|
serialization: global("config"),
|
|
response: v2::ExternalAgentConfigDetectResponse,
|
|
},
|
|
ExternalAgentConfigImport => "externalAgentConfig/import" {
|
|
params: v2::ExternalAgentConfigImportParams,
|
|
serialization: global("config"),
|
|
response: v2::ExternalAgentConfigImportResponse,
|
|
},
|
|
ConfigValueWrite => "config/value/write" {
|
|
params: v2::ConfigValueWriteParams,
|
|
serialization: global("config"),
|
|
manual_payload_conversion: manual,
|
|
response: v2::ConfigWriteResponse,
|
|
},
|
|
ConfigBatchWrite => "config/batchWrite" {
|
|
params: v2::ConfigBatchWriteParams,
|
|
serialization: global("config"),
|
|
manual_payload_conversion: manual,
|
|
response: v2::ConfigWriteResponse,
|
|
},
|
|
|
|
ConfigRequirementsRead => "configRequirements/read" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
serialization: global("config"),
|
|
response: v2::ConfigRequirementsReadResponse,
|
|
},
|
|
|
|
GetAccount => "account/read" {
|
|
params: v2::GetAccountParams,
|
|
serialization: global("account-auth"),
|
|
response: v2::GetAccountResponse,
|
|
},
|
|
|
|
/// DEPRECATED APIs below
|
|
GetConversationSummary {
|
|
params: v1::GetConversationSummaryParams,
|
|
serialization: None,
|
|
response: v1::GetConversationSummaryResponse,
|
|
},
|
|
GitDiffToRemote {
|
|
params: v1::GitDiffToRemoteParams,
|
|
serialization: None,
|
|
response: v1::GitDiffToRemoteResponse,
|
|
},
|
|
/// DEPRECATED in favor of GetAccount
|
|
GetAuthStatus {
|
|
params: v1::GetAuthStatusParams,
|
|
serialization: global("account-auth"),
|
|
response: v1::GetAuthStatusResponse,
|
|
},
|
|
// Legacy fuzzy search cancellation is intentionally concurrent: clients reuse a
|
|
// cancellation token so a newer request can cancel an older in-flight search.
|
|
FuzzyFileSearch {
|
|
params: FuzzyFileSearchParams,
|
|
serialization: None,
|
|
response: FuzzyFileSearchResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionStart")]
|
|
FuzzyFileSearchSessionStart => "fuzzyFileSearch/sessionStart" {
|
|
params: FuzzyFileSearchSessionStartParams,
|
|
serialization: fuzzy_session_id(params.session_id),
|
|
response: FuzzyFileSearchSessionStartResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionUpdate")]
|
|
FuzzyFileSearchSessionUpdate => "fuzzyFileSearch/sessionUpdate" {
|
|
params: FuzzyFileSearchSessionUpdateParams,
|
|
serialization: fuzzy_session_id(params.session_id),
|
|
response: FuzzyFileSearchSessionUpdateResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionStop")]
|
|
FuzzyFileSearchSessionStop => "fuzzyFileSearch/sessionStop" {
|
|
params: FuzzyFileSearchSessionStopParams,
|
|
serialization: fuzzy_session_id(params.session_id),
|
|
response: FuzzyFileSearchSessionStopResponse,
|
|
},
|
|
}
|
|
|
|
/// Generates an `enum ServerRequest` where each variant is a request that the
|
|
/// server can send to the client along with the corresponding params and
|
|
/// response types. It also generates helper types used by the app/server
|
|
/// infrastructure (payload enum, request constructor, and export helpers).
|
|
macro_rules! server_request_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $(=> $wire:literal)? {
|
|
params: $params:ty,
|
|
response: $response:ty,
|
|
}
|
|
),* $(,)?
|
|
) => {
|
|
/// Request initiated from the server and sent to the client.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ServerRequest {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
params: $params,
|
|
},
|
|
)*
|
|
}
|
|
|
|
impl ServerRequest {
|
|
pub fn id(&self) -> &RequestId {
|
|
match self {
|
|
$(Self::$variant { request_id, .. } => request_id,)*
|
|
}
|
|
}
|
|
|
|
pub fn response_from_result(
|
|
&self,
|
|
result: crate::Result,
|
|
) -> serde_json::Result<ServerResponse> {
|
|
match self {
|
|
$(
|
|
Self::$variant { request_id, .. } => {
|
|
let response = serde_json::from_value::<$response>(result)?;
|
|
Ok(ServerResponse::$variant {
|
|
request_id: request_id.clone(),
|
|
response,
|
|
})
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Typed response from the client to the server.
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ServerResponse {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$(#[serde(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
response: $response,
|
|
},
|
|
)*
|
|
}
|
|
|
|
impl ServerResponse {
|
|
pub fn id(&self) -> &RequestId {
|
|
match self {
|
|
$(Self::$variant { request_id, .. } => request_id,)*
|
|
}
|
|
}
|
|
|
|
pub fn method(&self) -> String {
|
|
serde_json::to_value(self)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("method")
|
|
.and_then(serde_json::Value::as_str)
|
|
.map(str::to_owned)
|
|
})
|
|
.unwrap_or_else(|| "<unknown>".to_string())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, JsonSchema)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum ServerRequestPayload {
|
|
$( $variant($params), )*
|
|
}
|
|
|
|
impl ServerRequestPayload {
|
|
pub fn request_with_id(self, request_id: RequestId) -> ServerRequest {
|
|
match self {
|
|
$(Self::$variant(params) => ServerRequest::$variant { request_id, params },)*
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn export_server_responses(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
|
|
$(
|
|
<$response as ::ts_rs::TS>::export_all_to(out_dir)?;
|
|
)*
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn visit_server_response_types(v: &mut impl ::ts_rs::TypeVisitor) {
|
|
$(
|
|
v.visit::<$response>();
|
|
)*
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_response_schemas(
|
|
out_dir: &Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(crate::export::write_json_schema::<$response>(
|
|
out_dir,
|
|
concat!(stringify!($variant), "Response"),
|
|
)?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_param_schemas(
|
|
out_dir: &Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(crate::export::write_json_schema::<$params>(
|
|
out_dir,
|
|
concat!(stringify!($variant), "Params"),
|
|
)?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Generates `ServerNotification` enum and helpers, including a JSON Schema
|
|
/// exporter for each notification.
|
|
macro_rules! server_notification_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $(=> $wire:literal)? ( $payload:ty )
|
|
),* $(,)?
|
|
) => {
|
|
/// Notification sent from the server to the client.
|
|
#[derive(
|
|
Serialize,
|
|
Deserialize,
|
|
Debug,
|
|
Clone,
|
|
JsonSchema,
|
|
TS,
|
|
Display,
|
|
ExperimentalApi,
|
|
)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
|
|
#[strum(serialize_all = "camelCase")]
|
|
pub enum ServerNotification {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)] #[strum(serialize = $wire)])?
|
|
$variant($payload),
|
|
)*
|
|
}
|
|
|
|
impl ServerNotification {
|
|
pub fn to_params(self) -> Result<serde_json::Value, serde_json::Error> {
|
|
match self {
|
|
$(Self::$variant(params) => serde_json::to_value(params),)*
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<JSONRPCNotification> for ServerNotification {
|
|
type Error = serde_json::Error;
|
|
|
|
fn try_from(value: JSONRPCNotification) -> Result<Self, serde_json::Error> {
|
|
serde_json::from_value(serde_json::to_value(value)?)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_notification_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(schemas.push(crate::export::write_json_schema::<$payload>(out_dir, stringify!($payload))?);)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
/// Notifications sent from the client to the server.
|
|
macro_rules! client_notification_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $( ( $payload:ty ) )?
|
|
),* $(,)?
|
|
) => {
|
|
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)]
|
|
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
|
|
#[strum(serialize_all = "camelCase")]
|
|
pub enum ClientNotification {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$variant $( ( $payload ) )?,
|
|
)*
|
|
}
|
|
|
|
pub fn export_client_notification_schemas(
|
|
_out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let schemas = Vec::new();
|
|
$( $(schemas.push(crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?);)? )*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
impl TryFrom<JSONRPCRequest> for ServerRequest {
|
|
type Error = serde_json::Error;
|
|
|
|
fn try_from(value: JSONRPCRequest) -> Result<Self, Self::Error> {
|
|
serde_json::from_value(serde_json::to_value(value)?)
|
|
}
|
|
}
|
|
|
|
server_request_definitions! {
|
|
/// NEW APIs
|
|
/// Sent when approval is requested for a specific command execution.
|
|
/// This request is used for Turns started via turn/start.
|
|
CommandExecutionRequestApproval => "item/commandExecution/requestApproval" {
|
|
params: v2::CommandExecutionRequestApprovalParams,
|
|
response: v2::CommandExecutionRequestApprovalResponse,
|
|
},
|
|
|
|
/// Sent when approval is requested for a specific file change.
|
|
/// This request is used for Turns started via turn/start.
|
|
FileChangeRequestApproval => "item/fileChange/requestApproval" {
|
|
params: v2::FileChangeRequestApprovalParams,
|
|
response: v2::FileChangeRequestApprovalResponse,
|
|
},
|
|
|
|
/// EXPERIMENTAL - Request input from the user for a tool call.
|
|
ToolRequestUserInput => "item/tool/requestUserInput" {
|
|
params: v2::ToolRequestUserInputParams,
|
|
response: v2::ToolRequestUserInputResponse,
|
|
},
|
|
|
|
/// Request input for an MCP server elicitation.
|
|
McpServerElicitationRequest => "mcpServer/elicitation/request" {
|
|
params: v2::McpServerElicitationRequestParams,
|
|
response: v2::McpServerElicitationRequestResponse,
|
|
},
|
|
|
|
/// Request approval for additional permissions from the user.
|
|
PermissionsRequestApproval => "item/permissions/requestApproval" {
|
|
params: v2::PermissionsRequestApprovalParams,
|
|
response: v2::PermissionsRequestApprovalResponse,
|
|
},
|
|
|
|
/// Execute a dynamic tool call on the client.
|
|
DynamicToolCall => "item/tool/call" {
|
|
params: v2::DynamicToolCallParams,
|
|
response: v2::DynamicToolCallResponse,
|
|
},
|
|
|
|
ChatgptAuthTokensRefresh => "account/chatgptAuthTokens/refresh" {
|
|
params: v2::ChatgptAuthTokensRefreshParams,
|
|
response: v2::ChatgptAuthTokensRefreshResponse,
|
|
},
|
|
|
|
/// DEPRECATED APIs below
|
|
/// Request to approve a patch.
|
|
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
|
ApplyPatchApproval {
|
|
params: v1::ApplyPatchApprovalParams,
|
|
response: v1::ApplyPatchApprovalResponse,
|
|
},
|
|
/// Request to exec a command.
|
|
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
|
ExecCommandApproval {
|
|
params: v1::ExecCommandApprovalParams,
|
|
response: v1::ExecCommandApprovalResponse,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchParams {
|
|
pub query: String,
|
|
pub roots: Vec<String>,
|
|
// if provided, will cancel any previous request that used the same value
|
|
pub cancellation_token: Option<String>,
|
|
}
|
|
|
|
/// Superset of [`codex_file_search::FileMatch`]
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
pub struct FuzzyFileSearchResult {
|
|
pub root: String,
|
|
pub path: String,
|
|
pub match_type: FuzzyFileSearchMatchType,
|
|
pub file_name: String,
|
|
pub score: u32,
|
|
pub indices: Option<Vec<u32>>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub enum FuzzyFileSearchMatchType {
|
|
File,
|
|
Directory,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
pub struct FuzzyFileSearchResponse {
|
|
pub files: Vec<FuzzyFileSearchResult>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionStartParams {
|
|
pub session_id: String,
|
|
pub roots: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionStartResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionUpdateParams {
|
|
pub session_id: String,
|
|
pub query: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionUpdateResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionStopParams {
|
|
pub session_id: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionStopResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionUpdatedNotification {
|
|
pub session_id: String,
|
|
pub query: String,
|
|
pub files: Vec<FuzzyFileSearchResult>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionCompletedNotification {
|
|
pub session_id: String,
|
|
}
|
|
|
|
server_notification_definitions! {
|
|
/// NEW NOTIFICATIONS
|
|
Error => "error" (v2::ErrorNotification),
|
|
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
|
|
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
|
|
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
|
|
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
|
|
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
|
|
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
|
|
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
|
|
#[experimental("thread/goal/updated")]
|
|
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
|
|
#[experimental("thread/goal/cleared")]
|
|
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
|
|
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
|
|
TurnStarted => "turn/started" (v2::TurnStartedNotification),
|
|
HookStarted => "hook/started" (v2::HookStartedNotification),
|
|
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
|
|
HookCompleted => "hook/completed" (v2::HookCompletedNotification),
|
|
TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification),
|
|
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
|
|
ItemStarted => "item/started" (v2::ItemStartedNotification),
|
|
ItemGuardianApprovalReviewStarted => "item/autoApprovalReview/started" (v2::ItemGuardianApprovalReviewStartedNotification),
|
|
ItemGuardianApprovalReviewCompleted => "item/autoApprovalReview/completed" (v2::ItemGuardianApprovalReviewCompletedNotification),
|
|
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
|
/// This event is internal-only. Used by Codex Cloud.
|
|
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
|
|
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
|
/// EXPERIMENTAL - proposed plan streaming deltas for plan items.
|
|
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
|
|
/// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.
|
|
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
|
|
/// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.
|
|
#[experimental("process/outputDelta")]
|
|
ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification),
|
|
/// Final exit notification for a `process/spawn` session.
|
|
#[experimental("process/exited")]
|
|
ProcessExited => "process/exited" (v2::ProcessExitedNotification),
|
|
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
|
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
|
/// Deprecated legacy apply_patch output stream notification.
|
|
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
|
FileChangePatchUpdated => "item/fileChange/patchUpdated" (v2::FileChangePatchUpdatedNotification),
|
|
ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification),
|
|
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
|
|
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
|
|
McpServerStatusUpdated => "mcpServer/startupStatus/updated" (v2::McpServerStatusUpdatedNotification),
|
|
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
|
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
|
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
|
|
RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification),
|
|
ExternalAgentConfigImportCompleted => "externalAgentConfig/import/completed" (v2::ExternalAgentConfigImportCompletedNotification),
|
|
FsChanged => "fs/changed" (v2::FsChangedNotification),
|
|
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
|
|
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
|
|
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
|
|
/// Deprecated: Use `ContextCompaction` item type instead.
|
|
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
|
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
|
|
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
|
|
Warning => "warning" (v2::WarningNotification),
|
|
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
|
|
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
|
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
|
|
FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification),
|
|
FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification),
|
|
#[experimental("thread/realtime/started")]
|
|
ThreadRealtimeStarted => "thread/realtime/started" (v2::ThreadRealtimeStartedNotification),
|
|
#[experimental("thread/realtime/itemAdded")]
|
|
ThreadRealtimeItemAdded => "thread/realtime/itemAdded" (v2::ThreadRealtimeItemAddedNotification),
|
|
#[experimental("thread/realtime/transcript/delta")]
|
|
ThreadRealtimeTranscriptDelta => "thread/realtime/transcript/delta" (v2::ThreadRealtimeTranscriptDeltaNotification),
|
|
#[experimental("thread/realtime/transcript/done")]
|
|
ThreadRealtimeTranscriptDone => "thread/realtime/transcript/done" (v2::ThreadRealtimeTranscriptDoneNotification),
|
|
#[experimental("thread/realtime/outputAudio/delta")]
|
|
ThreadRealtimeOutputAudioDelta => "thread/realtime/outputAudio/delta" (v2::ThreadRealtimeOutputAudioDeltaNotification),
|
|
#[experimental("thread/realtime/sdp")]
|
|
ThreadRealtimeSdp => "thread/realtime/sdp" (v2::ThreadRealtimeSdpNotification),
|
|
#[experimental("thread/realtime/error")]
|
|
ThreadRealtimeError => "thread/realtime/error" (v2::ThreadRealtimeErrorNotification),
|
|
#[experimental("thread/realtime/closed")]
|
|
ThreadRealtimeClosed => "thread/realtime/closed" (v2::ThreadRealtimeClosedNotification),
|
|
|
|
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
|
|
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
|
|
WindowsSandboxSetupCompleted => "windowsSandbox/setupCompleted" (v2::WindowsSandboxSetupCompletedNotification),
|
|
|
|
#[serde(rename = "account/login/completed")]
|
|
#[ts(rename = "account/login/completed")]
|
|
#[strum(serialize = "account/login/completed")]
|
|
AccountLoginCompleted(v2::AccountLoginCompletedNotification),
|
|
|
|
}
|
|
|
|
client_notification_definitions! {
|
|
Initialized,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use anyhow::Result;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_protocol::parse_command::ParsedCommand;
|
|
use codex_protocol::protocol::RealtimeConversationVersion;
|
|
use codex_protocol::protocol::RealtimeOutputModality;
|
|
use codex_protocol::protocol::RealtimeVoice;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::path::PathBuf;
|
|
|
|
fn absolute_path_string(path: &str) -> String {
|
|
let path = format!("/{}", path.trim_start_matches('/'));
|
|
test_path_buf(&path).display().to_string()
|
|
}
|
|
|
|
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
|
let path = format!("/{}", path.trim_start_matches('/'));
|
|
test_path_buf(&path).abs()
|
|
}
|
|
|
|
fn request_id() -> RequestId {
|
|
const REQUEST_ID: i64 = 1;
|
|
RequestId::Integer(REQUEST_ID)
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_serialization_scope_covers_keyed_families() {
|
|
let thread_id = "thread-1".to_string();
|
|
let thread_resume = ClientRequest::ThreadResume {
|
|
request_id: request_id(),
|
|
params: v2::ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
..Default::default()
|
|
},
|
|
};
|
|
assert_eq!(
|
|
thread_resume.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: thread_id.clone()
|
|
})
|
|
);
|
|
|
|
let thread_resume_with_path = ClientRequest::ThreadResume {
|
|
request_id: request_id(),
|
|
params: v2::ThreadResumeParams {
|
|
thread_id: thread_id.clone(),
|
|
path: Some(PathBuf::from("/tmp/resume-thread.jsonl")),
|
|
..Default::default()
|
|
},
|
|
};
|
|
assert_eq!(
|
|
thread_resume_with_path.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: thread_id.clone()
|
|
})
|
|
);
|
|
|
|
let thread_fork = ClientRequest::ThreadFork {
|
|
request_id: request_id(),
|
|
params: v2::ThreadForkParams {
|
|
thread_id: thread_id.clone(),
|
|
path: Some(PathBuf::from("/tmp/source-thread.jsonl")),
|
|
..Default::default()
|
|
},
|
|
};
|
|
assert_eq!(
|
|
thread_fork.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread { thread_id })
|
|
);
|
|
|
|
let command_exec = ClientRequest::OneOffCommandExec {
|
|
request_id: request_id(),
|
|
params: v2::CommandExecParams {
|
|
command: vec!["sleep".to_string(), "10".to_string()],
|
|
process_id: Some("proc-1".to_string()),
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
disable_output_cap: false,
|
|
disable_timeout: false,
|
|
timeout_ms: None,
|
|
cwd: None,
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
command_exec.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::CommandExecProcess {
|
|
process_id: "proc-1".to_string()
|
|
})
|
|
);
|
|
|
|
let fuzzy_update = ClientRequest::FuzzyFileSearchSessionUpdate {
|
|
request_id: request_id(),
|
|
params: FuzzyFileSearchSessionUpdateParams {
|
|
session_id: "search-1".to_string(),
|
|
query: "lib".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
fuzzy_update.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
|
|
session_id: "search-1".to_string()
|
|
})
|
|
);
|
|
|
|
let fs_watch = ClientRequest::FsWatch {
|
|
request_id: request_id(),
|
|
params: v2::FsWatchParams {
|
|
watch_id: "watch-1".to_string(),
|
|
path: absolute_path("/tmp/repo"),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
fs_watch.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::FsWatch {
|
|
watch_id: "watch-1".to_string()
|
|
})
|
|
);
|
|
|
|
let plugin_install = ClientRequest::PluginInstall {
|
|
request_id: request_id(),
|
|
params: v2::PluginInstallParams {
|
|
marketplace_path: Some(absolute_path("/tmp/marketplace")),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "plugin-a".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
plugin_install.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("config"))
|
|
);
|
|
|
|
let plugin_uninstall = ClientRequest::PluginUninstall {
|
|
request_id: request_id(),
|
|
params: v2::PluginUninstallParams {
|
|
plugin_id: "plugin-a".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
plugin_uninstall.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("config"))
|
|
);
|
|
|
|
let mcp_oauth = ClientRequest::McpServerOauthLogin {
|
|
request_id: request_id(),
|
|
params: v2::McpServerOauthLoginParams {
|
|
name: "server-a".to_string(),
|
|
scopes: None,
|
|
timeout_secs: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
mcp_oauth.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::McpOauth {
|
|
server_name: "server-a".to_string()
|
|
})
|
|
);
|
|
|
|
let mcp_resource_read = ClientRequest::McpResourceRead {
|
|
request_id: request_id(),
|
|
params: v2::McpResourceReadParams {
|
|
thread_id: Some("thread-1".to_string()),
|
|
server: "server-a".to_string(),
|
|
uri: "file:///tmp/resource".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
mcp_resource_read.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: "thread-1".to_string()
|
|
})
|
|
);
|
|
|
|
let config_read = ClientRequest::ConfigRead {
|
|
request_id: request_id(),
|
|
params: v2::ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
config_read.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("config"))
|
|
);
|
|
|
|
let account_read = ClientRequest::GetAccount {
|
|
request_id: request_id(),
|
|
params: v2::GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
account_read.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("account-auth"))
|
|
);
|
|
|
|
let thread_goal_set = ClientRequest::ThreadGoalSet {
|
|
request_id: request_id(),
|
|
params: v2::ThreadGoalSetParams {
|
|
thread_id: "goal-thread".to_string(),
|
|
objective: Some("ship it".to_string()),
|
|
status: None,
|
|
token_budget: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
thread_goal_set.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: "goal-thread".to_string()
|
|
})
|
|
);
|
|
|
|
let guardian_approval = ClientRequest::ThreadApproveGuardianDeniedAction {
|
|
request_id: request_id(),
|
|
params: v2::ThreadApproveGuardianDeniedActionParams {
|
|
thread_id: "guardian-thread".to_string(),
|
|
event: json!({ "type": "guardian" }),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
guardian_approval.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Thread {
|
|
thread_id: "guardian-thread".to_string()
|
|
})
|
|
);
|
|
|
|
let marketplace_remove = ClientRequest::MarketplaceRemove {
|
|
request_id: request_id(),
|
|
params: v2::MarketplaceRemoveParams {
|
|
marketplace_name: "marketplace".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
marketplace_remove.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("config"))
|
|
);
|
|
|
|
let device_key_create = ClientRequest::DeviceKeyCreate {
|
|
request_id: request_id(),
|
|
params: v2::DeviceKeyCreateParams {
|
|
protection_policy: None,
|
|
account_user_id: "user".to_string(),
|
|
client_id: "client".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
device_key_create.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("device-key"))
|
|
);
|
|
|
|
let add_credits_nudge = ClientRequest::SendAddCreditsNudgeEmail {
|
|
request_id: request_id(),
|
|
params: v2::SendAddCreditsNudgeEmailParams {
|
|
credit_type: v2::AddCreditsNudgeCreditType::Credits,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
add_credits_nudge.serialization_scope(),
|
|
Some(ClientRequestSerializationScope::Global("account-auth"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_serialization_scope_covers_unkeyed_representatives() {
|
|
let initialize = ClientRequest::Initialize {
|
|
request_id: request_id(),
|
|
params: v1::InitializeParams {
|
|
client_info: v1::ClientInfo {
|
|
name: "test".to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: None,
|
|
},
|
|
};
|
|
assert_eq!(initialize.serialization_scope(), None);
|
|
|
|
let thread_start = ClientRequest::ThreadStart {
|
|
request_id: request_id(),
|
|
params: v2::ThreadStartParams::default(),
|
|
};
|
|
assert_eq!(thread_start.serialization_scope(), None);
|
|
|
|
let command_exec = ClientRequest::OneOffCommandExec {
|
|
request_id: request_id(),
|
|
params: v2::CommandExecParams {
|
|
command: vec!["true".to_string()],
|
|
process_id: None,
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
disable_output_cap: false,
|
|
disable_timeout: false,
|
|
timeout_ms: None,
|
|
cwd: None,
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
},
|
|
};
|
|
assert_eq!(command_exec.serialization_scope(), None);
|
|
|
|
let fs_read = ClientRequest::FsReadFile {
|
|
request_id: request_id(),
|
|
params: v2::FsReadFileParams {
|
|
path: absolute_path("/tmp/file.txt"),
|
|
},
|
|
};
|
|
assert_eq!(fs_read.serialization_scope(), None);
|
|
|
|
let thread_turns_list = ClientRequest::ThreadTurnsList {
|
|
request_id: request_id(),
|
|
params: v2::ThreadTurnsListParams {
|
|
thread_id: "thread-1".to_string(),
|
|
cursor: None,
|
|
limit: None,
|
|
sort_direction: None,
|
|
},
|
|
};
|
|
assert_eq!(thread_turns_list.serialization_scope(), None);
|
|
|
|
let mcp_resource_read = ClientRequest::McpResourceRead {
|
|
request_id: request_id(),
|
|
params: v2::McpResourceReadParams {
|
|
thread_id: None,
|
|
server: "server-a".to_string(),
|
|
uri: "file:///tmp/resource".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(mcp_resource_read.serialization_scope(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_get_conversation_summary() -> Result<()> {
|
|
let request = ClientRequest::GetConversationSummary {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::GetConversationSummaryParams::ThreadId {
|
|
conversation_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "getConversationSummary",
|
|
"id": 42,
|
|
"params": {
|
|
"conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
|
let request = ClientRequest::Initialize {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::InitializeParams {
|
|
client_info: v1::ClientInfo {
|
|
name: "codex_vscode".to_string(),
|
|
title: Some("Codex VS Code Extension".to_string()),
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: Some(v1::InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: Some(vec![
|
|
"thread/started".to_string(),
|
|
"item/agentMessage/delta".to_string(),
|
|
]),
|
|
}),
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
json!({
|
|
"method": "initialize",
|
|
"id": 42,
|
|
"params": {
|
|
"clientInfo": {
|
|
"name": "codex_vscode",
|
|
"title": "Codex VS Code Extension",
|
|
"version": "0.1.0"
|
|
},
|
|
"capabilities": {
|
|
"experimentalApi": true,
|
|
"optOutNotificationMethods": [
|
|
"thread/started",
|
|
"item/agentMessage/delta"
|
|
]
|
|
}
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
|
let request: ClientRequest = serde_json::from_value(json!({
|
|
"method": "initialize",
|
|
"id": 42,
|
|
"params": {
|
|
"clientInfo": {
|
|
"name": "codex_vscode",
|
|
"title": "Codex VS Code Extension",
|
|
"version": "0.1.0"
|
|
},
|
|
"capabilities": {
|
|
"experimentalApi": true,
|
|
"optOutNotificationMethods": [
|
|
"thread/started",
|
|
"item/agentMessage/delta"
|
|
]
|
|
}
|
|
}
|
|
}))?;
|
|
|
|
assert_eq!(
|
|
request,
|
|
ClientRequest::Initialize {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::InitializeParams {
|
|
client_info: v1::ClientInfo {
|
|
name: "codex_vscode".to_string(),
|
|
title: Some("Codex VS Code Extension".to_string()),
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: Some(v1::InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: Some(vec![
|
|
"thread/started".to_string(),
|
|
"item/agentMessage/delta".to_string(),
|
|
]),
|
|
}),
|
|
},
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_id_serializes_as_plain_string() -> Result<()> {
|
|
let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
|
|
|
assert_eq!(
|
|
json!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
|
|
serde_json::to_value(id)?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_id_deserializes_from_plain_string() -> Result<()> {
|
|
let id: ThreadId = serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?;
|
|
|
|
assert_eq!(
|
|
ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?,
|
|
id,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_client_notification() -> Result<()> {
|
|
let notification = ClientNotification::Initialized;
|
|
// Note there is no "params" field for this notification.
|
|
assert_eq!(
|
|
json!({
|
|
"method": "initialized",
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_server_request() -> Result<()> {
|
|
let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
|
let params = v1::ExecCommandApprovalParams {
|
|
conversation_id,
|
|
call_id: "call-42".to_string(),
|
|
approval_id: Some("approval-42".to_string()),
|
|
command: vec!["echo".to_string(), "hello".to_string()],
|
|
cwd: PathBuf::from("/tmp"),
|
|
reason: Some("because tests".to_string()),
|
|
parsed_cmd: vec![ParsedCommand::Unknown {
|
|
cmd: "echo hello".to_string(),
|
|
}],
|
|
};
|
|
let request = ServerRequest::ExecCommandApproval {
|
|
request_id: RequestId::Integer(7),
|
|
params: params.clone(),
|
|
};
|
|
|
|
assert_eq!(
|
|
json!({
|
|
"method": "execCommandApproval",
|
|
"id": 7,
|
|
"params": {
|
|
"conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
|
|
"callId": "call-42",
|
|
"approvalId": "approval-42",
|
|
"command": ["echo", "hello"],
|
|
"cwd": "/tmp",
|
|
"reason": "because tests",
|
|
"parsedCmd": [
|
|
{
|
|
"type": "unknown",
|
|
"cmd": "echo hello"
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
|
|
let payload = ServerRequestPayload::ExecCommandApproval(params);
|
|
assert_eq!(request.id(), &RequestId::Integer(7));
|
|
assert_eq!(payload.request_with_id(RequestId::Integer(7)), request);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_chatgpt_auth_tokens_refresh_request() -> Result<()> {
|
|
let request = ServerRequest::ChatgptAuthTokensRefresh {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ChatgptAuthTokensRefreshParams {
|
|
reason: v2::ChatgptAuthTokensRefreshReason::Unauthorized,
|
|
previous_account_id: Some("org-123".to_string()),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/chatgptAuthTokens/refresh",
|
|
"id": 8,
|
|
"params": {
|
|
"reason": "unauthorized",
|
|
"previousAccountId": "org-123"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_server_response() -> Result<()> {
|
|
let response = ServerResponse::CommandExecutionRequestApproval {
|
|
request_id: RequestId::Integer(8),
|
|
response: v2::CommandExecutionRequestApprovalResponse {
|
|
decision: v2::CommandExecutionApprovalDecision::AcceptForSession,
|
|
},
|
|
};
|
|
|
|
assert_eq!(response.id(), &RequestId::Integer(8));
|
|
assert_eq!(response.method(), "item/commandExecution/requestApproval");
|
|
assert_eq!(
|
|
json!({
|
|
"method": "item/commandExecution/requestApproval",
|
|
"id": 8,
|
|
"response": {
|
|
"decision": "acceptForSession"
|
|
}
|
|
}),
|
|
serde_json::to_value(&response)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_mcp_server_elicitation_request() -> Result<()> {
|
|
let requested_schema: v2::McpElicitationSchema = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"confirmed": {
|
|
"type": "boolean"
|
|
}
|
|
},
|
|
"required": ["confirmed"]
|
|
}))?;
|
|
let params = v2::McpServerElicitationRequestParams {
|
|
thread_id: "thr_123".to_string(),
|
|
turn_id: Some("turn_123".to_string()),
|
|
server_name: "codex_apps".to_string(),
|
|
request: v2::McpServerElicitationRequest::Form {
|
|
meta: None,
|
|
message: "Allow this request?".to_string(),
|
|
requested_schema,
|
|
},
|
|
};
|
|
let request = ServerRequest::McpServerElicitationRequest {
|
|
request_id: RequestId::Integer(9),
|
|
params: params.clone(),
|
|
};
|
|
|
|
assert_eq!(
|
|
json!({
|
|
"method": "mcpServer/elicitation/request",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"turnId": "turn_123",
|
|
"serverName": "codex_apps",
|
|
"mode": "form",
|
|
"_meta": null,
|
|
"message": "Allow this request?",
|
|
"requestedSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"confirmed": {
|
|
"type": "boolean"
|
|
}
|
|
},
|
|
"required": ["confirmed"]
|
|
}
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
|
|
let payload = ServerRequestPayload::McpServerElicitationRequest(params);
|
|
assert_eq!(request.id(), &RequestId::Integer(9));
|
|
assert_eq!(payload.request_with_id(RequestId::Integer(9)), request);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_get_account_rate_limits() -> Result<()> {
|
|
let request = ClientRequest::GetAccountRateLimits {
|
|
request_id: RequestId::Integer(1),
|
|
params: None,
|
|
};
|
|
assert_eq!(request.id(), &RequestId::Integer(1));
|
|
assert_eq!(request.method(), "account/rateLimits/read");
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/rateLimits/read",
|
|
"id": 1,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_client_response() -> Result<()> {
|
|
let cwd = absolute_path("/tmp");
|
|
let response = ClientResponse::ThreadStart {
|
|
request_id: RequestId::Integer(7),
|
|
response: v2::ThreadStartResponse {
|
|
thread: v2::Thread {
|
|
id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
|
|
forked_from_id: None,
|
|
preview: "first prompt".to_string(),
|
|
ephemeral: true,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: v2::ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: cwd.clone(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: v2::SessionSource::Exec,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
},
|
|
model: "gpt-5".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd,
|
|
instruction_sources: vec![absolute_path("/tmp/AGENTS.md")],
|
|
approval_policy: v2::AskForApproval::OnFailure,
|
|
approvals_reviewer: v2::ApprovalsReviewer::User,
|
|
sandbox: v2::SandboxPolicy::DangerFullAccess,
|
|
permission_profile: None,
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
},
|
|
};
|
|
|
|
assert_eq!(response.id(), &RequestId::Integer(7));
|
|
assert_eq!(response.method(), "thread/start");
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/start",
|
|
"id": 7,
|
|
"response": {
|
|
"thread": {
|
|
"id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
|
|
"forkedFromId": null,
|
|
"preview": "first prompt",
|
|
"ephemeral": true,
|
|
"modelProvider": "openai",
|
|
"createdAt": 1,
|
|
"updatedAt": 2,
|
|
"status": {
|
|
"type": "idle"
|
|
},
|
|
"path": null,
|
|
"cwd": absolute_path_string("tmp"),
|
|
"cliVersion": "0.0.0",
|
|
"source": "exec",
|
|
"agentNickname": null,
|
|
"agentRole": null,
|
|
"gitInfo": null,
|
|
"name": null,
|
|
"turns": []
|
|
},
|
|
"model": "gpt-5",
|
|
"modelProvider": "openai",
|
|
"serviceTier": null,
|
|
"cwd": absolute_path_string("tmp"),
|
|
"instructionSources": [absolute_path_string("tmp/AGENTS.md")],
|
|
"approvalPolicy": "on-failure",
|
|
"approvalsReviewer": "user",
|
|
"sandbox": {
|
|
"type": "dangerFullAccess"
|
|
},
|
|
"permissionProfile": null,
|
|
"activePermissionProfile": null,
|
|
"reasoningEffort": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&response)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_config_requirements_read() -> Result<()> {
|
|
let request = ClientRequest::ConfigRequirementsRead {
|
|
request_id: RequestId::Integer(1),
|
|
params: None,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "configRequirements/read",
|
|
"id": 1,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_api_key() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(2),
|
|
params: v2::LoginAccountParams::ApiKey {
|
|
api_key: "secret".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 2,
|
|
"params": {
|
|
"type": "apiKey",
|
|
"apiKey": "secret"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(3),
|
|
params: v2::LoginAccountParams::Chatgpt {
|
|
codex_streamlined_login: false,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 3,
|
|
"params": {
|
|
"type": "chatgpt"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt_streamlined() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(3),
|
|
params: v2::LoginAccountParams::Chatgpt {
|
|
codex_streamlined_login: true,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 3,
|
|
"params": {
|
|
"type": "chatgpt",
|
|
"codexStreamlinedLogin": true
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(4),
|
|
params: v2::LoginAccountParams::ChatgptDeviceCode,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 4,
|
|
"params": {
|
|
"type": "chatgptDeviceCode"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_logout() -> Result<()> {
|
|
let request = ClientRequest::LogoutAccount {
|
|
request_id: RequestId::Integer(5),
|
|
params: None,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/logout",
|
|
"id": 5,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(6),
|
|
params: v2::LoginAccountParams::ChatgptAuthTokens {
|
|
access_token: "access-token".to_string(),
|
|
chatgpt_account_id: "org-123".to_string(),
|
|
chatgpt_plan_type: Some("business".to_string()),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 6,
|
|
"params": {
|
|
"type": "chatgptAuthTokens",
|
|
"accessToken": "access-token",
|
|
"chatgptAccountId": "org-123",
|
|
"chatgptPlanType": "business"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_get_account() -> Result<()> {
|
|
let request = ClientRequest::GetAccount {
|
|
request_id: RequestId::Integer(6),
|
|
params: v2::GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/read",
|
|
"id": 6,
|
|
"params": {
|
|
"refreshToken": false
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn account_serializes_fields_in_camel_case() -> Result<()> {
|
|
let api_key = v2::Account::ApiKey {};
|
|
assert_eq!(
|
|
json!({
|
|
"type": "apiKey",
|
|
}),
|
|
serde_json::to_value(&api_key)?,
|
|
);
|
|
|
|
let chatgpt = v2::Account::Chatgpt {
|
|
email: "user@example.com".to_string(),
|
|
plan_type: PlanType::Plus,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"type": "chatgpt",
|
|
"email": "user@example.com",
|
|
"planType": "plus",
|
|
}),
|
|
serde_json::to_value(&chatgpt)?,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_models() -> Result<()> {
|
|
let request = ClientRequest::ModelList {
|
|
request_id: RequestId::Integer(6),
|
|
params: v2::ModelListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "model/list",
|
|
"id": 6,
|
|
"params": {
|
|
"limit": null,
|
|
"cursor": null,
|
|
"includeHidden": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_model_provider_capabilities_read() -> Result<()> {
|
|
let request = ClientRequest::ModelProviderCapabilitiesRead {
|
|
request_id: RequestId::Integer(7),
|
|
params: v2::ModelProviderCapabilitiesReadParams {},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "modelProvider/capabilities/read",
|
|
"id": 7,
|
|
"params": {}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_collaboration_modes() -> Result<()> {
|
|
let request = ClientRequest::CollaborationModeList {
|
|
request_id: RequestId::Integer(7),
|
|
params: v2::CollaborationModeListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "collaborationMode/list",
|
|
"id": 7,
|
|
"params": {}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_apps() -> Result<()> {
|
|
let request = ClientRequest::AppsList {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::AppsListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "app/list",
|
|
"id": 8,
|
|
"params": {
|
|
"cursor": null,
|
|
"limit": null,
|
|
"threadId": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_fs_get_metadata() -> Result<()> {
|
|
let request = ClientRequest::FsGetMetadata {
|
|
request_id: RequestId::Integer(9),
|
|
params: v2::FsGetMetadataParams {
|
|
path: absolute_path("tmp/example"),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "fs/getMetadata",
|
|
"id": 9,
|
|
"params": {
|
|
"path": absolute_path_string("tmp/example")
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_fs_watch() -> Result<()> {
|
|
let request = ClientRequest::FsWatch {
|
|
request_id: RequestId::Integer(10),
|
|
params: v2::FsWatchParams {
|
|
watch_id: "watch-git".to_string(),
|
|
path: absolute_path("tmp/repo/.git"),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "fs/watch",
|
|
"id": 10,
|
|
"params": {
|
|
"watchId": "watch-git",
|
|
"path": absolute_path_string("tmp/repo/.git")
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_experimental_features() -> Result<()> {
|
|
let request = ClientRequest::ExperimentalFeatureList {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ExperimentalFeatureListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "experimentalFeature/list",
|
|
"id": 8,
|
|
"params": {
|
|
"cursor": null,
|
|
"limit": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_background_terminals_clean() -> Result<()> {
|
|
let request = ClientRequest::ThreadBackgroundTerminalsClean {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ThreadBackgroundTerminalsCleanParams {
|
|
thread_id: "thr_123".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/backgroundTerminals/clean",
|
|
"id": 8,
|
|
"params": {
|
|
"threadId": "thr_123"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_realtime_start() -> Result<()> {
|
|
let request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(9),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
prompt: Some(Some("You are on a call".to_string())),
|
|
realtime_session_id: Some("sess_456".to_string()),
|
|
transport: None,
|
|
voice: Some(RealtimeVoice::Marin),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"outputModality": "audio",
|
|
"prompt": "You are on a call",
|
|
"realtimeSessionId": "sess_456",
|
|
"transport": null,
|
|
"voice": "marin"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_realtime_start_prompt_default_and_null() -> Result<()> {
|
|
let default_prompt_request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(9),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
prompt: None,
|
|
realtime_session_id: None,
|
|
transport: None,
|
|
voice: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"outputModality": "audio",
|
|
"realtimeSessionId": null,
|
|
"transport": null,
|
|
"voice": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&default_prompt_request)?,
|
|
);
|
|
|
|
let null_prompt_request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(9),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
prompt: Some(None),
|
|
realtime_session_id: None,
|
|
transport: None,
|
|
voice: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"outputModality": "audio",
|
|
"prompt": null,
|
|
"realtimeSessionId": null,
|
|
"transport": null,
|
|
"voice": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&null_prompt_request)?,
|
|
);
|
|
|
|
let default_prompt_value = json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"outputModality": "audio",
|
|
"realtimeSessionId": null,
|
|
"transport": null,
|
|
"voice": null
|
|
}
|
|
});
|
|
assert_eq!(
|
|
serde_json::from_value::<ClientRequest>(default_prompt_value)?,
|
|
default_prompt_request,
|
|
);
|
|
|
|
let null_prompt_value = json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"outputModality": "audio",
|
|
"prompt": null,
|
|
"realtimeSessionId": null,
|
|
"transport": null,
|
|
"voice": null
|
|
}
|
|
});
|
|
assert_eq!(
|
|
serde_json::from_value::<ClientRequest>(null_prompt_value)?,
|
|
null_prompt_request,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_status_changed_notification() -> Result<()> {
|
|
let notification =
|
|
ServerNotification::ThreadStatusChanged(v2::ThreadStatusChangedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
status: v2::ThreadStatus::Idle,
|
|
});
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/status/changed",
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"status": {
|
|
"type": "idle"
|
|
},
|
|
}
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> {
|
|
let notification = ServerNotification::ThreadRealtimeOutputAudioDelta(
|
|
v2::ThreadRealtimeOutputAudioDeltaNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
audio: v2::ThreadRealtimeAudioChunk {
|
|
data: "AQID".to_string(),
|
|
sample_rate: 24_000,
|
|
num_channels: 1,
|
|
samples_per_channel: Some(512),
|
|
item_id: None,
|
|
},
|
|
},
|
|
);
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/outputAudio/delta",
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"audio": {
|
|
"data": "AQID",
|
|
"sampleRate": 24000,
|
|
"numChannels": 1,
|
|
"samplesPerChannel": 512,
|
|
"itemId": null
|
|
}
|
|
}
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn mock_experimental_method_is_marked_experimental() {
|
|
let request = ClientRequest::MockExperimentalMethod {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::MockExperimentalMethodParams::default(),
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
|
assert_eq!(reason, Some("mock/experimentalMethod"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_permission_profile_is_marked_experimental() {
|
|
let request = ClientRequest::OneOffCommandExec {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::CommandExecParams {
|
|
command: vec!["pwd".to_string()],
|
|
process_id: None,
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
disable_output_cap: false,
|
|
disable_timeout: false,
|
|
timeout_ms: None,
|
|
cwd: None,
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: Some(v2::PermissionProfile::Disabled),
|
|
},
|
|
};
|
|
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
|
assert_eq!(reason, Some("command/exec.permissionProfile"));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_realtime_start_is_marked_experimental() {
|
|
let request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
output_modality: RealtimeOutputModality::Audio,
|
|
prompt: Some(Some("You are on a call".to_string())),
|
|
realtime_session_id: None,
|
|
transport: None,
|
|
voice: None,
|
|
},
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
|
assert_eq!(reason, Some("thread/realtime/start"));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_goal_methods_are_marked_experimental() {
|
|
let set_request = ClientRequest::ThreadGoalSet {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::ThreadGoalSetParams {
|
|
thread_id: "thr_123".to_string(),
|
|
objective: Some("ship goal mode".to_string()),
|
|
status: Some(v2::ThreadGoalStatus::Active),
|
|
token_budget: Some(Some(10_000)),
|
|
},
|
|
};
|
|
let get_request = ClientRequest::ThreadGoalGet {
|
|
request_id: RequestId::Integer(2),
|
|
params: v2::ThreadGoalGetParams {
|
|
thread_id: "thr_123".to_string(),
|
|
},
|
|
};
|
|
let clear_request = ClientRequest::ThreadGoalClear {
|
|
request_id: RequestId::Integer(3),
|
|
params: v2::ThreadGoalClearParams {
|
|
thread_id: "thr_123".to_string(),
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&set_request),
|
|
Some("thread/goal/set")
|
|
);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&get_request),
|
|
Some("thread/goal/get")
|
|
);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request),
|
|
Some("thread/goal/clear")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_goal_notifications_are_marked_experimental() {
|
|
let goal = v2::ThreadGoal {
|
|
thread_id: "thr_123".to_string(),
|
|
objective: "ship goal mode".to_string(),
|
|
status: v2::ThreadGoalStatus::Active,
|
|
token_budget: Some(10_000),
|
|
tokens_used: 123,
|
|
time_used_seconds: 45,
|
|
created_at: 1_700_000_000,
|
|
updated_at: 1_700_000_123,
|
|
};
|
|
let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
turn_id: None,
|
|
goal,
|
|
});
|
|
let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
});
|
|
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&updated),
|
|
Some("thread/goal/updated")
|
|
);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&cleared),
|
|
Some("thread/goal/cleared")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_realtime_started_notification_is_marked_experimental() {
|
|
let notification =
|
|
ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
realtime_session_id: Some("sess_456".to_string()),
|
|
version: RealtimeConversationVersion::V1,
|
|
});
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification);
|
|
assert_eq!(reason, Some("thread/realtime/started"));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_realtime_output_audio_delta_notification_is_marked_experimental() {
|
|
let notification = ServerNotification::ThreadRealtimeOutputAudioDelta(
|
|
v2::ThreadRealtimeOutputAudioDeltaNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
audio: v2::ThreadRealtimeAudioChunk {
|
|
data: "AQID".to_string(),
|
|
sample_rate: 24_000,
|
|
num_channels: 1,
|
|
samples_per_channel: Some(512),
|
|
item_id: None,
|
|
},
|
|
},
|
|
);
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification);
|
|
assert_eq!(reason, Some("thread/realtime/outputAudio/delta"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_execution_request_approval_additional_permissions_is_marked_experimental() {
|
|
let params = v2::CommandExecutionRequestApprovalParams {
|
|
thread_id: "thr_123".to_string(),
|
|
turn_id: "turn_123".to_string(),
|
|
item_id: "call_123".to_string(),
|
|
approval_id: None,
|
|
reason: None,
|
|
network_approval_context: None,
|
|
command: Some("cat file".to_string()),
|
|
cwd: None,
|
|
command_actions: None,
|
|
additional_permissions: Some(v2::AdditionalPermissionProfile {
|
|
network: None,
|
|
file_system: Some(v2::AdditionalFileSystemPermissions {
|
|
read: Some(vec![absolute_path("/tmp/allowed")]),
|
|
write: None,
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
}),
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
available_decisions: None,
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¶ms);
|
|
assert_eq!(
|
|
reason,
|
|
Some("item/commandExecution/requestApproval.additionalPermissions")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "common_tests.rs"]
|
|
mod common_tests;
|