mirror of
https://github.com/openai/codex.git
synced 2026-05-16 09:12:54 +00:00
## Summary - Add plugin manifest keywords to core plugin marketplace/detail models - Expose keywords on app-server v2 PluginSummary and generated schema/types - Populate keywords in plugin/list and plugin/read responses for local plugins Depends on https://github.com/openai/openai/pull/891087 ## Validation - just fmt - just write-app-server-schema - cargo test -p codex-app-server-protocol - cargo test -p codex-core-plugins - cargo test -p codex-app-server plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load - cargo test -p codex-app-server plugin_read_returns_plugin_details_with_bundle_contents
3569 lines
111 KiB
Rust
3569 lines
111 KiB
Rust
use super::*;
|
|
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
|
|
use codex_protocol::items::AgentMessageContent;
|
|
use codex_protocol::items::AgentMessageItem;
|
|
use codex_protocol::items::FileChangeItem;
|
|
use codex_protocol::items::ImageViewItem;
|
|
use codex_protocol::items::McpToolCallItem;
|
|
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
|
|
use codex_protocol::items::ReasoningItem;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::items::WebSearchItem;
|
|
use codex_protocol::mcp::CallToolResult;
|
|
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
|
|
use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry;
|
|
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
|
|
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
|
|
use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions;
|
|
use codex_protocol::models::MessagePhase;
|
|
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
|
|
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
|
|
use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath;
|
|
use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
|
|
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
|
|
use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig;
|
|
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
|
|
use codex_protocol::user_input::UserInput as CoreUserInput;
|
|
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::Value as JsonValue;
|
|
use serde_json::json;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::num::NonZeroUsize;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
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 test_absolute_path() -> AbsolutePathBuf {
|
|
absolute_path("readable")
|
|
}
|
|
|
|
#[test]
|
|
fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() {
|
|
assert_eq!(
|
|
serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"),
|
|
"\"user\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"),
|
|
"\"guardian_subagent\""
|
|
);
|
|
|
|
for value in ["user", "auto_review", "guardian_subagent"] {
|
|
let json = format!("\"{value}\"");
|
|
let reviewer: ApprovalsReviewer =
|
|
serde_json::from_str(&json).expect("deserialize reviewer");
|
|
let expected = if value == "user" {
|
|
ApprovalsReviewer::User
|
|
} else {
|
|
ApprovalsReviewer::AutoReview
|
|
};
|
|
assert_eq!(expected, reviewer);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn turn_defaults_legacy_missing_items_view_to_full() {
|
|
let turn: Turn = serde_json::from_value(json!({
|
|
"id": "turn_123",
|
|
"items": [],
|
|
"status": "completed",
|
|
"error": null,
|
|
"startedAt": null,
|
|
"completedAt": null,
|
|
"durationMs": null,
|
|
}))
|
|
.expect("legacy turn should deserialize");
|
|
|
|
assert_eq!(turn.items_view, TurnItemsView::Full);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_list_params_accepts_single_cwd() {
|
|
let params = serde_json::from_value::<ThreadListParams>(json!({
|
|
"cwd": "/workspace",
|
|
}))
|
|
.expect("single cwd should deserialize");
|
|
|
|
assert_eq!(
|
|
params.cwd,
|
|
Some(ThreadListCwdFilter::One("/workspace".to_string()))
|
|
);
|
|
assert!(!params.use_state_db_only);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_list_params_accepts_multiple_cwds() {
|
|
let params = serde_json::from_value::<ThreadListParams>(json!({
|
|
"cwd": ["/workspace", "/other-workspace"],
|
|
}))
|
|
.expect("cwd array should deserialize");
|
|
|
|
assert_eq!(
|
|
params.cwd,
|
|
Some(ThreadListCwdFilter::Many(vec![
|
|
"/workspace".to_string(),
|
|
"/other-workspace".to_string(),
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_list_params_accepts_state_db_only_flag() {
|
|
let params = serde_json::from_value::<ThreadListParams>(json!({
|
|
"useStateDbOnly": true,
|
|
}))
|
|
.expect("state db only flag should deserialize");
|
|
|
|
assert!(params.use_state_db_only);
|
|
}
|
|
|
|
#[test]
|
|
fn collab_agent_state_maps_interrupted_status() {
|
|
assert_eq!(
|
|
CollabAgentState::from(CoreAgentStatus::Interrupted),
|
|
CollabAgentState {
|
|
status: CollabAgentStatus::Interrupted,
|
|
message: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_agent_config_plugins_details_round_trip() {
|
|
let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({
|
|
"itemType": "PLUGINS",
|
|
"description": "Install supported plugins from Claude settings",
|
|
"cwd": absolute_path_string("repo"),
|
|
"details": {
|
|
"plugins": [
|
|
{
|
|
"marketplaceName": "team-marketplace",
|
|
"pluginNames": ["asana"]
|
|
}
|
|
]
|
|
}
|
|
}))
|
|
.expect("plugins migration item should deserialize");
|
|
|
|
assert_eq!(
|
|
item,
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: "Install supported plugins from Claude settings".to_string(),
|
|
cwd: Some(PathBuf::from(absolute_path_string("repo"))),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "team-marketplace".to_string(),
|
|
plugin_names: vec!["asana".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_agent_config_import_params_accept_legacy_plugin_details() {
|
|
let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({
|
|
"migrationItems": [{
|
|
"itemType": "PLUGINS",
|
|
"description": "Install supported plugins from Claude settings",
|
|
"cwd": absolute_path_string("repo"),
|
|
"details": {
|
|
"plugins": [
|
|
{
|
|
"marketplaceName": "team-marketplace",
|
|
"pluginNames": ["asana"]
|
|
}
|
|
]
|
|
}
|
|
}]
|
|
}))
|
|
.expect("legacy plugin import params should deserialize");
|
|
|
|
assert_eq!(
|
|
params,
|
|
ExternalAgentConfigImportParams {
|
|
migration_items: vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: "Install supported plugins from Claude settings".to_string(),
|
|
cwd: Some(PathBuf::from(absolute_path_string("repo"))),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "team-marketplace".to_string(),
|
|
plugin_names: vec!["asana".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
|
|
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
|
|
"threadId": "thr_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "call_123",
|
|
"command": "cat file",
|
|
"cwd": absolute_path_string("tmp"),
|
|
"commandActions": null,
|
|
"reason": null,
|
|
"networkApprovalContext": null,
|
|
"additionalPermissions": {
|
|
"network": null,
|
|
"fileSystem": {
|
|
"read": ["relative/path"],
|
|
"write": null
|
|
}
|
|
},
|
|
"proposedExecpolicyAmendment": null,
|
|
"proposedNetworkPolicyAmendments": null,
|
|
"availableDecisions": null
|
|
}))
|
|
.expect_err("relative additional permission paths should fail");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("AbsolutePathBuf deserialized without a base path"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_request_approval_uses_request_permission_profile() {
|
|
let read_only_path = if cfg!(windows) {
|
|
r"C:\tmp\read-only"
|
|
} else {
|
|
"/tmp/read-only"
|
|
};
|
|
let read_write_path = if cfg!(windows) {
|
|
r"C:\tmp\read-write"
|
|
} else {
|
|
"/tmp/read-write"
|
|
};
|
|
let params = serde_json::from_value::<PermissionsRequestApprovalParams>(json!({
|
|
"threadId": "thr_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "call_123",
|
|
"cwd": absolute_path_string("repo"),
|
|
"reason": "Select a workspace root",
|
|
"permissions": {
|
|
"network": {
|
|
"enabled": true,
|
|
},
|
|
"fileSystem": {
|
|
"read": [read_only_path],
|
|
"write": [read_write_path],
|
|
},
|
|
},
|
|
}))
|
|
.expect("permissions request should deserialize");
|
|
|
|
assert_eq!(params.cwd, absolute_path("repo"));
|
|
assert_eq!(
|
|
params.permissions,
|
|
RequestPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_only_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
write: Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
}
|
|
);
|
|
|
|
assert_eq!(
|
|
CoreRequestPermissionProfile::from(params.permissions),
|
|
CoreRequestPermissionProfile {
|
|
network: Some(CoreNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
|
|
Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_only_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
)),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_request_approval_rejects_macos_permissions() {
|
|
let err = serde_json::from_value::<PermissionsRequestApprovalParams>(json!({
|
|
"threadId": "thr_123",
|
|
"turnId": "turn_123",
|
|
"itemId": "call_123",
|
|
"cwd": absolute_path_string("repo"),
|
|
"reason": "Select a workspace root",
|
|
"permissions": {
|
|
"network": null,
|
|
"fileSystem": null,
|
|
"macos": {
|
|
"preferences": "read_only",
|
|
"automations": "none",
|
|
"launchServices": false,
|
|
"accessibility": false,
|
|
"calendar": false,
|
|
"reminders": false,
|
|
"contacts": "none",
|
|
},
|
|
},
|
|
}))
|
|
.expect_err("permissions request should reject macos permissions");
|
|
|
|
assert!(
|
|
err.to_string().contains("unknown field `macos`"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_file_system_permissions_preserves_canonical_entries() {
|
|
let core_permissions = CoreFileSystemPermissions {
|
|
entries: vec![
|
|
CoreFileSystemSandboxEntry {
|
|
path: CoreFileSystemPath::Special {
|
|
value: CoreFileSystemSpecialPath::Root,
|
|
},
|
|
access: CoreFileSystemAccessMode::Write,
|
|
},
|
|
CoreFileSystemSandboxEntry {
|
|
path: CoreFileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: CoreFileSystemAccessMode::None,
|
|
},
|
|
],
|
|
glob_scan_max_depth: NonZeroUsize::new(2),
|
|
};
|
|
|
|
let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone());
|
|
assert_eq!(
|
|
permissions,
|
|
AdditionalFileSystemPermissions {
|
|
read: None,
|
|
write: None,
|
|
glob_scan_max_depth: NonZeroUsize::new(2),
|
|
entries: Some(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
]),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
CoreFileSystemPermissions::from(permissions),
|
|
core_permissions
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_file_system_permissions_populates_entries_for_legacy_roots() {
|
|
let read_only_path = absolute_path("read-only");
|
|
let read_write_path = absolute_path("read-write");
|
|
let core_permissions = CoreFileSystemPermissions::from_read_write_roots(
|
|
Some(vec![read_only_path.clone()]),
|
|
Some(vec![read_write_path.clone()]),
|
|
);
|
|
|
|
let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone());
|
|
|
|
assert_eq!(
|
|
permissions,
|
|
AdditionalFileSystemPermissions {
|
|
read: Some(vec![read_only_path.clone()]),
|
|
write: Some(vec![read_write_path.clone()]),
|
|
glob_scan_max_depth: None,
|
|
entries: Some(vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: read_only_path,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: read_write_path,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
]),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
CoreFileSystemPermissions::from(permissions),
|
|
core_permissions
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_file_system_permissions_rejects_zero_glob_scan_depth() {
|
|
serde_json::from_value::<AdditionalFileSystemPermissions>(json!({
|
|
"read": null,
|
|
"write": null,
|
|
"globScanMaxDepth": 0,
|
|
"entries": [],
|
|
}))
|
|
.expect_err("zero glob scan depth should fail deserialization");
|
|
}
|
|
|
|
#[test]
|
|
fn permission_profile_file_system_permissions_preserves_glob_scan_depth() {
|
|
let core_permissions = CoreManagedFileSystemPermissions::Restricted {
|
|
entries: vec![CoreFileSystemSandboxEntry {
|
|
path: CoreFileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: CoreFileSystemAccessMode::None,
|
|
}],
|
|
glob_scan_max_depth: NonZeroUsize::new(2),
|
|
};
|
|
|
|
let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone());
|
|
|
|
assert_eq!(
|
|
permissions,
|
|
PermissionProfileFileSystemPermissions::Restricted {
|
|
entries: vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/*.env".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
}],
|
|
glob_scan_max_depth: NonZeroUsize::new(2),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
CoreManagedFileSystemPermissions::from(permissions),
|
|
core_permissions
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() {
|
|
serde_json::from_value::<PermissionProfileFileSystemPermissions>(json!({
|
|
"type": "restricted",
|
|
"entries": [],
|
|
"globScanMaxDepth": 0,
|
|
}))
|
|
.expect_err("zero glob scan depth should fail deserialization");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_current_working_directory_special_path_deserializes_as_project_roots() {
|
|
let special_path = serde_json::from_value::<FileSystemSpecialPath>(json!({
|
|
"kind": "current_working_directory",
|
|
}))
|
|
.expect("legacy cwd special path should deserialize");
|
|
|
|
assert_eq!(
|
|
special_path,
|
|
FileSystemSpecialPath::ProjectRoots { subpath: None }
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&special_path).expect("serialize special path"),
|
|
json!({
|
|
"kind": "project_roots",
|
|
"subpath": null,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() {
|
|
let read_only_path = if cfg!(windows) {
|
|
r"C:\tmp\read-only"
|
|
} else {
|
|
"/tmp/read-only"
|
|
};
|
|
let read_write_path = if cfg!(windows) {
|
|
r"C:\tmp\read-write"
|
|
} else {
|
|
"/tmp/read-write"
|
|
};
|
|
let response = serde_json::from_value::<PermissionsRequestApprovalResponse>(json!({
|
|
"permissions": {
|
|
"network": {
|
|
"enabled": true,
|
|
},
|
|
"fileSystem": {
|
|
"read": [read_only_path],
|
|
"write": [read_write_path],
|
|
},
|
|
},
|
|
}))
|
|
.expect("permissions response should deserialize");
|
|
|
|
assert_eq!(
|
|
response.permissions,
|
|
GrantedPermissionProfile {
|
|
network: Some(AdditionalNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(AdditionalFileSystemPermissions {
|
|
read: Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_only_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
write: Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
glob_scan_max_depth: None,
|
|
entries: None,
|
|
}),
|
|
}
|
|
);
|
|
|
|
assert_eq!(
|
|
CoreAdditionalPermissionProfile::from(response.permissions),
|
|
CoreAdditionalPermissionProfile {
|
|
network: Some(CoreNetworkPermissions {
|
|
enabled: Some(true),
|
|
}),
|
|
file_system: Some(CoreFileSystemPermissions::from_read_write_roots(
|
|
Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_only_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
Some(vec![
|
|
AbsolutePathBuf::try_from(PathBuf::from(read_write_path))
|
|
.expect("path must be absolute"),
|
|
]),
|
|
)),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_request_approval_response_defaults_scope_to_turn() {
|
|
let response = serde_json::from_value::<PermissionsRequestApprovalResponse>(json!({
|
|
"permissions": {},
|
|
}))
|
|
.expect("response should deserialize");
|
|
|
|
assert_eq!(response.scope, PermissionGrantScope::Turn);
|
|
assert_eq!(response.strict_auto_review, None);
|
|
}
|
|
|
|
#[test]
|
|
fn permissions_request_approval_response_accepts_strict_auto_review() {
|
|
let response = serde_json::from_value::<PermissionsRequestApprovalResponse>(json!({
|
|
"permissions": {},
|
|
"strictAutoReview": true,
|
|
}))
|
|
.expect("response should deserialize");
|
|
|
|
assert_eq!(response.strict_auto_review, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn fs_get_metadata_response_round_trips_minimal_fields() {
|
|
let response = FsGetMetadataResponse {
|
|
is_directory: false,
|
|
is_file: true,
|
|
is_symlink: false,
|
|
created_at_ms: 123,
|
|
modified_at_ms: 456,
|
|
};
|
|
|
|
let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"isDirectory": false,
|
|
"isFile": true,
|
|
"isSymlink": false,
|
|
"createdAtMs": 123,
|
|
"modifiedAtMs": 456,
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<FsGetMetadataResponse>(value)
|
|
.expect("deserialize fs/getMetadata response");
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_read_file_response_round_trips_base64_data() {
|
|
let response = FsReadFileResponse {
|
|
data_base64: "aGVsbG8=".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(&response).expect("serialize fs/readFile response");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"dataBase64": "aGVsbG8=",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<FsReadFileResponse>(value)
|
|
.expect("deserialize fs/readFile response");
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_read_file_params_round_trip() {
|
|
let params = FsReadFileParams {
|
|
path: absolute_path("tmp/example.txt"),
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"path": absolute_path_string("tmp/example.txt"),
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<FsReadFileParams>(value).expect("deserialize fs/readFile params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn device_key_create_params_round_trip_uses_protection_policy() {
|
|
let params = DeviceKeyCreateParams {
|
|
protection_policy: None,
|
|
account_user_id: "account-user-1".to_string(),
|
|
client_id: "cli_123".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize device/key/create params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"accountUserId": "account-user-1",
|
|
"clientId": "cli_123",
|
|
"protectionPolicy": null,
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<DeviceKeyCreateParams>(value)
|
|
.expect("deserialize device/key/create params");
|
|
assert_eq!(decoded, params);
|
|
|
|
let params = DeviceKeyCreateParams {
|
|
protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable),
|
|
account_user_id: "account-user-1".to_string(),
|
|
client_id: "cli_123".to_string(),
|
|
};
|
|
let value = serde_json::to_value(¶ms)
|
|
.expect("serialize device/key/create params with protection policy");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"accountUserId": "account-user-1",
|
|
"clientId": "cli_123",
|
|
"protectionPolicy": "allow_os_protected_nonextractable",
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn device_key_create_response_round_trips_protection_class() {
|
|
let response = DeviceKeyCreateResponse {
|
|
key_id: "dk_123".to_string(),
|
|
public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(),
|
|
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
|
|
protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable,
|
|
};
|
|
|
|
let value = serde_json::to_value(&response).expect("serialize device/key/create response");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"keyId": "dk_123",
|
|
"publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE",
|
|
"algorithm": "ecdsa_p256_sha256",
|
|
"protectionClass": "os_protected_nonextractable",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<DeviceKeyCreateResponse>(value)
|
|
.expect("deserialize device/key/create response");
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn device_key_sign_params_round_trip_uses_accepted_payload_enum() {
|
|
let params = DeviceKeySignParams {
|
|
key_id: "dk_123".to_string(),
|
|
payload: DeviceKeySignPayload::RemoteControlClientConnection {
|
|
nonce: "nonce-1".to_string(),
|
|
audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket,
|
|
session_id: "wssess_123".to_string(),
|
|
target_origin: "https://chatgpt.com".to_string(),
|
|
target_path: "/api/codex/remote/control/client".to_string(),
|
|
account_user_id: "account-user-1".to_string(),
|
|
client_id: "cli_123".to_string(),
|
|
token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(),
|
|
token_expires_at: 1_700_000_000,
|
|
scopes: vec!["remote_control_controller_websocket".to_string()],
|
|
},
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"keyId": "dk_123",
|
|
"payload": {
|
|
"type": "remoteControlClientConnection",
|
|
"nonce": "nonce-1",
|
|
"audience": "remote_control_client_websocket",
|
|
"sessionId": "wssess_123",
|
|
"targetOrigin": "https://chatgpt.com",
|
|
"targetPath": "/api/codex/remote/control/client",
|
|
"accountUserId": "account-user-1",
|
|
"clientId": "cli_123",
|
|
"tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
|
|
"tokenExpiresAt": 1_700_000_000,
|
|
"scopes": ["remote_control_controller_websocket"],
|
|
},
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
|
|
.expect("deserialize device/key/sign params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn device_key_sign_params_round_trip_uses_enrollment_payload() {
|
|
let params = DeviceKeySignParams {
|
|
key_id: "dk_123".to_string(),
|
|
payload: DeviceKeySignPayload::RemoteControlClientEnrollment {
|
|
nonce: "nonce-1".to_string(),
|
|
audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment,
|
|
challenge_id: "rch_123".to_string(),
|
|
target_origin: "https://chatgpt.com".to_string(),
|
|
target_path: "/wham/remote/control/client/enroll".to_string(),
|
|
account_user_id: "account-user-1".to_string(),
|
|
client_id: "cli_123".to_string(),
|
|
device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
|
|
.to_string(),
|
|
challenge_expires_at: 1_700_000_000,
|
|
},
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms)
|
|
.expect("serialize device/key/sign params with enrollment payload");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"keyId": "dk_123",
|
|
"payload": {
|
|
"type": "remoteControlClientEnrollment",
|
|
"nonce": "nonce-1",
|
|
"audience": "remote_control_client_enrollment",
|
|
"challengeId": "rch_123",
|
|
"targetOrigin": "https://chatgpt.com",
|
|
"targetPath": "/wham/remote/control/client/enroll",
|
|
"accountUserId": "account-user-1",
|
|
"clientId": "cli_123",
|
|
"deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
|
|
"challengeExpiresAt": 1_700_000_000,
|
|
},
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<DeviceKeySignParams>(value)
|
|
.expect("deserialize device/key/sign params with enrollment payload");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn device_key_sign_response_returns_signed_payload_bytes() {
|
|
let response = DeviceKeySignResponse {
|
|
signature_der_base64: "MEUCIQD".to_string(),
|
|
signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(),
|
|
algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256,
|
|
};
|
|
|
|
let value = serde_json::to_value(&response).expect("serialize device/key/sign response");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"signatureDerBase64": "MEUCIQD",
|
|
"signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA",
|
|
"algorithm": "ecdsa_p256_sha256",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<DeviceKeySignResponse>(value)
|
|
.expect("deserialize device/key/sign response");
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_create_directory_params_round_trip_with_default_recursive() {
|
|
let params = FsCreateDirectoryParams {
|
|
path: absolute_path("tmp/example"),
|
|
recursive: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"path": absolute_path_string("tmp/example"),
|
|
"recursive": null,
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<FsCreateDirectoryParams>(value)
|
|
.expect("deserialize fs/createDirectory params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_write_file_params_round_trip_with_base64_data() {
|
|
let params = FsWriteFileParams {
|
|
path: absolute_path("tmp/example.bin"),
|
|
data_base64: "AAE=".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"path": absolute_path_string("tmp/example.bin"),
|
|
"dataBase64": "AAE=",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<FsWriteFileParams>(value)
|
|
.expect("deserialize fs/writeFile params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_copy_params_round_trip_with_recursive_directory_copy() {
|
|
let params = FsCopyParams {
|
|
source_path: absolute_path("tmp/source"),
|
|
destination_path: absolute_path("tmp/destination"),
|
|
recursive: true,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize fs/copy params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"sourcePath": absolute_path_string("tmp/source"),
|
|
"destinationPath": absolute_path_string("tmp/destination"),
|
|
"recursive": true,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<FsCopyParams>(value).expect("deserialize fs/copy params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_shell_command_params_round_trip() {
|
|
let params = ThreadShellCommandParams {
|
|
thread_id: "thr_123".to_string(),
|
|
command: "printf 'hello world\\n'".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"threadId": "thr_123",
|
|
"command": "printf 'hello world\\n'",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<ThreadShellCommandParams>(value)
|
|
.expect("deserialize thread/shellCommand params");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_shell_command_response_round_trip() {
|
|
let response = ThreadShellCommandResponse {};
|
|
|
|
let value = serde_json::to_value(&response).expect("serialize thread/shellCommand response");
|
|
assert_eq!(value, json!({}));
|
|
|
|
let decoded = serde_json::from_value::<ThreadShellCommandResponse>(value)
|
|
.expect("deserialize thread/shellCommand response");
|
|
assert_eq!(decoded, response);
|
|
}
|
|
|
|
#[test]
|
|
fn fs_changed_notification_round_trips() {
|
|
let notification = FsChangedNotification {
|
|
watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(),
|
|
changed_paths: vec![
|
|
absolute_path("tmp/repo/.git/HEAD"),
|
|
absolute_path("tmp/repo/.git/FETCH_HEAD"),
|
|
],
|
|
};
|
|
|
|
let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
|
|
"changedPaths": [
|
|
absolute_path_string("tmp/repo/.git/HEAD"),
|
|
absolute_path_string("tmp/repo/.git/FETCH_HEAD"),
|
|
],
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<FsChangedNotification>(value)
|
|
.expect("deserialize fs/changed notification");
|
|
assert_eq!(decoded, notification);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_params_default_optional_streaming_flags() {
|
|
let params = serde_json::from_value::<CommandExecParams>(json!({
|
|
"command": ["ls", "-la"],
|
|
"timeoutMs": 1000,
|
|
"cwd": "/tmp"
|
|
}))
|
|
.expect("command/exec payload should deserialize");
|
|
|
|
assert_eq!(
|
|
params,
|
|
CommandExecParams {
|
|
command: vec!["ls".to_string(), "-la".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: Some(1000),
|
|
cwd: Some(PathBuf::from("/tmp")),
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_params_round_trips_disable_timeout() {
|
|
let params = CommandExecParams {
|
|
command: vec!["sleep".to_string(), "30".to_string()],
|
|
process_id: Some("sleep-1".to_string()),
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
disable_output_cap: false,
|
|
disable_timeout: true,
|
|
timeout_ms: None,
|
|
cwd: None,
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"command": ["sleep", "30"],
|
|
"processId": "sleep-1",
|
|
"disableTimeout": true,
|
|
"timeoutMs": null,
|
|
"cwd": null,
|
|
"env": null,
|
|
"size": null,
|
|
"sandboxPolicy": null,
|
|
"permissionProfile": null,
|
|
"outputBytesCap": null,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn process_spawn_params_round_trips_without_sandbox_policy() {
|
|
let params = ProcessSpawnParams {
|
|
command: vec!["sleep".to_string(), "30".to_string()],
|
|
process_handle: "sleep-1".to_string(),
|
|
cwd: test_absolute_path(),
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
timeout_ms: None,
|
|
env: None,
|
|
size: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize process/spawn params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"command": ["sleep", "30"],
|
|
"processHandle": "sleep-1",
|
|
"cwd": absolute_path_string("readable"),
|
|
"env": null,
|
|
"size": null,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<ProcessSpawnParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn process_spawn_params_distinguish_omitted_null_and_value_limits() {
|
|
let base = json!({
|
|
"command": ["sleep", "30"],
|
|
"processHandle": "sleep-1",
|
|
"cwd": absolute_path_string("readable"),
|
|
});
|
|
|
|
let expected_omitted = ProcessSpawnParams {
|
|
command: vec!["sleep".to_string(), "30".to_string()],
|
|
process_handle: "sleep-1".to_string(),
|
|
cwd: test_absolute_path(),
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: false,
|
|
output_bytes_cap: None,
|
|
timeout_ms: None,
|
|
env: None,
|
|
size: None,
|
|
};
|
|
let decoded =
|
|
serde_json::from_value::<ProcessSpawnParams>(base).expect("deserialize omitted limits");
|
|
assert_eq!(decoded, expected_omitted);
|
|
|
|
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
|
|
"command": ["sleep", "30"],
|
|
"processHandle": "sleep-1",
|
|
"cwd": absolute_path_string("readable"),
|
|
"outputBytesCap": null,
|
|
"timeoutMs": null,
|
|
}))
|
|
.expect("deserialize disabled limits");
|
|
assert_eq!(
|
|
decoded,
|
|
ProcessSpawnParams {
|
|
output_bytes_cap: Some(None),
|
|
timeout_ms: Some(None),
|
|
..expected_omitted.clone()
|
|
}
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
|
|
"command": ["sleep", "30"],
|
|
"processHandle": "sleep-1",
|
|
"cwd": absolute_path_string("readable"),
|
|
"outputBytesCap": 123,
|
|
"timeoutMs": 456,
|
|
}))
|
|
.expect("deserialize explicit limits");
|
|
assert_eq!(
|
|
decoded,
|
|
ProcessSpawnParams {
|
|
output_bytes_cap: Some(Some(123)),
|
|
timeout_ms: Some(Some(456)),
|
|
..expected_omitted
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_params_round_trips_disable_output_cap() {
|
|
let params = CommandExecParams {
|
|
command: vec!["yes".to_string()],
|
|
process_id: Some("yes-1".to_string()),
|
|
tty: false,
|
|
stream_stdin: false,
|
|
stream_stdout_stderr: true,
|
|
output_bytes_cap: None,
|
|
disable_output_cap: true,
|
|
disable_timeout: false,
|
|
timeout_ms: None,
|
|
cwd: None,
|
|
env: None,
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"command": ["yes"],
|
|
"processId": "yes-1",
|
|
"streamStdoutStderr": true,
|
|
"outputBytesCap": null,
|
|
"disableOutputCap": true,
|
|
"timeoutMs": null,
|
|
"cwd": null,
|
|
"env": null,
|
|
"size": null,
|
|
"sandboxPolicy": null,
|
|
"permissionProfile": null,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_params_round_trips_env_overrides_and_unsets() {
|
|
let params = CommandExecParams {
|
|
command: vec!["printenv".to_string(), "FOO".to_string()],
|
|
process_id: Some("env-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: Some(HashMap::from([
|
|
("FOO".to_string(), Some("override".to_string())),
|
|
("BAR".to_string(), Some("added".to_string())),
|
|
("BAZ".to_string(), None),
|
|
])),
|
|
size: None,
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"command": ["printenv", "FOO"],
|
|
"processId": "env-1",
|
|
"outputBytesCap": null,
|
|
"timeoutMs": null,
|
|
"cwd": null,
|
|
"env": {
|
|
"FOO": "override",
|
|
"BAR": "added",
|
|
"BAZ": null,
|
|
},
|
|
"size": null,
|
|
"sandboxPolicy": null,
|
|
"permissionProfile": null,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_write_round_trips_close_only_payload() {
|
|
let params = CommandExecWriteParams {
|
|
process_id: "proc-7".to_string(),
|
|
delta_base64: None,
|
|
close_stdin: true,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processId": "proc-7",
|
|
"deltaBase64": null,
|
|
"closeStdin": true,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecWriteParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_terminate_round_trips() {
|
|
let params = CommandExecTerminateParams {
|
|
process_id: "proc-8".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processId": "proc-8",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<CommandExecTerminateParams>(value)
|
|
.expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_params_round_trip_with_size() {
|
|
let params = CommandExecParams {
|
|
command: vec!["top".to_string()],
|
|
process_id: Some("pty-1".to_string()),
|
|
tty: true,
|
|
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: Some(CommandExecTerminalSize {
|
|
rows: 40,
|
|
cols: 120,
|
|
}),
|
|
sandbox_policy: None,
|
|
permission_profile: None,
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"command": ["top"],
|
|
"processId": "pty-1",
|
|
"tty": true,
|
|
"outputBytesCap": null,
|
|
"timeoutMs": null,
|
|
"cwd": null,
|
|
"env": null,
|
|
"size": {
|
|
"rows": 40,
|
|
"cols": 120,
|
|
},
|
|
"sandboxPolicy": null,
|
|
"permissionProfile": null,
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_resize_round_trips() {
|
|
let params = CommandExecResizeParams {
|
|
process_id: "proc-9".to_string(),
|
|
size: CommandExecTerminalSize {
|
|
rows: 50,
|
|
cols: 160,
|
|
},
|
|
};
|
|
|
|
let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processId": "proc-9",
|
|
"size": {
|
|
"rows": 50,
|
|
"cols": 160,
|
|
},
|
|
})
|
|
);
|
|
|
|
let decoded =
|
|
serde_json::from_value::<CommandExecResizeParams>(value).expect("deserialize round-trip");
|
|
assert_eq!(decoded, params);
|
|
}
|
|
|
|
#[test]
|
|
fn command_exec_output_delta_round_trips() {
|
|
let notification = CommandExecOutputDeltaNotification {
|
|
process_id: "proc-1".to_string(),
|
|
stream: CommandExecOutputStream::Stdout,
|
|
delta_base64: "AQI=".to_string(),
|
|
cap_reached: false,
|
|
};
|
|
|
|
let value = serde_json::to_value(¬ification)
|
|
.expect("serialize command/exec/outputDelta notification");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processId": "proc-1",
|
|
"stream": "stdout",
|
|
"deltaBase64": "AQI=",
|
|
"capReached": false,
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<CommandExecOutputDeltaNotification>(value)
|
|
.expect("deserialize round-trip");
|
|
assert_eq!(decoded, notification);
|
|
}
|
|
|
|
#[test]
|
|
fn process_control_params_round_trip() {
|
|
let write = ProcessWriteStdinParams {
|
|
process_handle: "proc-7".to_string(),
|
|
delta_base64: None,
|
|
close_stdin: true,
|
|
};
|
|
let value = serde_json::to_value(&write).expect("serialize process/writeStdin params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processHandle": "proc-7",
|
|
"deltaBase64": null,
|
|
"closeStdin": true,
|
|
})
|
|
);
|
|
let decoded = serde_json::from_value::<ProcessWriteStdinParams>(value)
|
|
.expect("deserialize process/writeStdin params");
|
|
assert_eq!(decoded, write);
|
|
|
|
let resize = ProcessResizePtyParams {
|
|
process_handle: "proc-7".to_string(),
|
|
size: ProcessTerminalSize {
|
|
rows: 50,
|
|
cols: 160,
|
|
},
|
|
};
|
|
let value = serde_json::to_value(&resize).expect("serialize process/resizePty params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processHandle": "proc-7",
|
|
"size": {
|
|
"rows": 50,
|
|
"cols": 160,
|
|
},
|
|
})
|
|
);
|
|
let decoded = serde_json::from_value::<ProcessResizePtyParams>(value)
|
|
.expect("deserialize process/resizePty params");
|
|
assert_eq!(decoded, resize);
|
|
|
|
let kill = ProcessKillParams {
|
|
process_handle: "proc-7".to_string(),
|
|
};
|
|
let value = serde_json::to_value(&kill).expect("serialize process/kill params");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processHandle": "proc-7",
|
|
})
|
|
);
|
|
let decoded =
|
|
serde_json::from_value::<ProcessKillParams>(value).expect("deserialize process/kill");
|
|
assert_eq!(decoded, kill);
|
|
}
|
|
|
|
#[test]
|
|
fn process_notifications_round_trip() {
|
|
let delta = ProcessOutputDeltaNotification {
|
|
process_handle: "proc-1".to_string(),
|
|
stream: ProcessOutputStream::Stdout,
|
|
delta_base64: "AQI=".to_string(),
|
|
cap_reached: false,
|
|
};
|
|
let value = serde_json::to_value(&delta).expect("serialize process/outputDelta");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processHandle": "proc-1",
|
|
"stream": "stdout",
|
|
"deltaBase64": "AQI=",
|
|
"capReached": false,
|
|
})
|
|
);
|
|
let decoded = serde_json::from_value::<ProcessOutputDeltaNotification>(value)
|
|
.expect("deserialize process/outputDelta");
|
|
assert_eq!(decoded, delta);
|
|
|
|
let exited = ProcessExitedNotification {
|
|
process_handle: "proc-1".to_string(),
|
|
exit_code: 0,
|
|
stdout: "out".to_string(),
|
|
stdout_cap_reached: false,
|
|
stderr: "err".to_string(),
|
|
stderr_cap_reached: true,
|
|
};
|
|
let value = serde_json::to_value(&exited).expect("serialize process/exited");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"processHandle": "proc-1",
|
|
"exitCode": 0,
|
|
"stdout": "out",
|
|
"stdoutCapReached": false,
|
|
"stderr": "err",
|
|
"stderrCapReached": true,
|
|
})
|
|
);
|
|
let decoded = serde_json::from_value::<ProcessExitedNotification>(value)
|
|
.expect("deserialize process/exited");
|
|
assert_eq!(decoded, exited);
|
|
}
|
|
|
|
#[test]
|
|
fn command_execution_output_delta_round_trips() {
|
|
let notification = CommandExecutionOutputDeltaNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
item_id: "item-1".to_string(),
|
|
delta: "\u{fffd}a\n".to_string(),
|
|
};
|
|
|
|
let value = serde_json::to_value(¬ification)
|
|
.expect("serialize item/commandExecution/outputDelta notification");
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"threadId": "thread-1",
|
|
"turnId": "turn-1",
|
|
"itemId": "item-1",
|
|
"delta": "\u{fffd}a\n",
|
|
})
|
|
);
|
|
|
|
let decoded = serde_json::from_value::<CommandExecutionOutputDeltaNotification>(value)
|
|
.expect("deserialize round-trip");
|
|
assert_eq!(decoded, notification);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_round_trips_external_sandbox_network_access() {
|
|
let v2_policy = SandboxPolicy::ExternalSandbox {
|
|
network_access: NetworkAccess::Enabled,
|
|
};
|
|
|
|
let core_policy = v2_policy.to_core();
|
|
assert_eq!(
|
|
core_policy,
|
|
codex_protocol::protocol::SandboxPolicy::ExternalSandbox {
|
|
network_access: CoreNetworkAccess::Enabled,
|
|
}
|
|
);
|
|
|
|
let back_to_v2 = SandboxPolicy::from(core_policy);
|
|
assert_eq!(back_to_v2, v2_policy);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_round_trips_read_only_network_access() {
|
|
let v2_policy = SandboxPolicy::ReadOnly {
|
|
network_access: true,
|
|
};
|
|
|
|
let core_policy = v2_policy.to_core();
|
|
assert_eq!(
|
|
core_policy,
|
|
codex_protocol::protocol::SandboxPolicy::ReadOnly {
|
|
network_access: true,
|
|
}
|
|
);
|
|
|
|
let back_to_v2 = SandboxPolicy::from(core_policy);
|
|
assert_eq!(back_to_v2, v2_policy);
|
|
}
|
|
|
|
#[test]
|
|
fn ask_for_approval_granular_round_trips_request_permissions_flag() {
|
|
let v2_policy = AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
};
|
|
|
|
let core_policy = v2_policy.to_core();
|
|
assert_eq!(
|
|
core_policy,
|
|
CoreAskForApproval::Granular(CoreGranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
})
|
|
);
|
|
|
|
let back_to_v2 = AskForApproval::from(core_policy);
|
|
assert_eq!(back_to_v2, v2_policy);
|
|
}
|
|
|
|
#[test]
|
|
fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() {
|
|
let decoded = serde_json::from_value::<AskForApproval>(serde_json::json!({
|
|
"granular": {
|
|
"sandbox_approval": true,
|
|
"rules": false,
|
|
"mcp_elicitations": true,
|
|
}
|
|
}))
|
|
.expect("granular approval policy should deserialize");
|
|
|
|
assert_eq!(
|
|
decoded,
|
|
AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ask_for_approval_granular_is_marked_experimental() {
|
|
let reason =
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
});
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::OnRequest,),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_v2_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 {
|
|
model: None,
|
|
model_provider: None,
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
}),
|
|
approvals_reviewer: None,
|
|
service_tier: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
web_search: None,
|
|
tools: None,
|
|
chatgpt_base_url: None,
|
|
additional: HashMap::new(),
|
|
});
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config {
|
|
model: None,
|
|
review_model: None,
|
|
model_context_window: None,
|
|
model_auto_compact_token_limit: None,
|
|
model_provider: None,
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}),
|
|
approvals_reviewer: None,
|
|
sandbox_mode: None,
|
|
sandbox_workspace_write: None,
|
|
forced_chatgpt_workspace_id: None,
|
|
forced_login_method: None,
|
|
web_search: None,
|
|
tools: None,
|
|
profile: None,
|
|
profiles: HashMap::new(),
|
|
instructions: None,
|
|
developer_instructions: None,
|
|
compact_prompt: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
service_tier: None,
|
|
analytics: None,
|
|
apps: None,
|
|
additional: HashMap::new(),
|
|
});
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_approvals_reviewer_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config {
|
|
model: None,
|
|
review_model: None,
|
|
model_context_window: None,
|
|
model_auto_compact_token_limit: None,
|
|
model_provider: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
sandbox_mode: None,
|
|
sandbox_workspace_write: None,
|
|
forced_chatgpt_workspace_id: None,
|
|
forced_login_method: None,
|
|
web_search: None,
|
|
tools: None,
|
|
profile: None,
|
|
profiles: HashMap::new(),
|
|
instructions: None,
|
|
developer_instructions: None,
|
|
compact_prompt: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
service_tier: None,
|
|
analytics: None,
|
|
apps: None,
|
|
additional: HashMap::new(),
|
|
});
|
|
|
|
assert_eq!(reason, Some("config/read.approvalsReviewer"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_nested_profile_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config {
|
|
model: None,
|
|
review_model: None,
|
|
model_context_window: None,
|
|
model_auto_compact_token_limit: None,
|
|
model_provider: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
sandbox_mode: None,
|
|
sandbox_workspace_write: None,
|
|
forced_chatgpt_workspace_id: None,
|
|
forced_login_method: None,
|
|
web_search: None,
|
|
tools: None,
|
|
profile: None,
|
|
profiles: HashMap::from([(
|
|
"default".to_string(),
|
|
ProfileV2 {
|
|
model: None,
|
|
model_provider: None,
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}),
|
|
approvals_reviewer: None,
|
|
service_tier: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
web_search: None,
|
|
tools: None,
|
|
chatgpt_base_url: None,
|
|
additional: HashMap::new(),
|
|
},
|
|
)]),
|
|
instructions: None,
|
|
developer_instructions: None,
|
|
compact_prompt: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
service_tier: None,
|
|
analytics: None,
|
|
apps: None,
|
|
additional: HashMap::new(),
|
|
});
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_nested_profile_approvals_reviewer_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config {
|
|
model: None,
|
|
review_model: None,
|
|
model_context_window: None,
|
|
model_auto_compact_token_limit: None,
|
|
model_provider: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
sandbox_mode: None,
|
|
sandbox_workspace_write: None,
|
|
forced_chatgpt_workspace_id: None,
|
|
forced_login_method: None,
|
|
web_search: None,
|
|
tools: None,
|
|
profile: None,
|
|
profiles: HashMap::from([(
|
|
"default".to_string(),
|
|
ProfileV2 {
|
|
model: None,
|
|
model_provider: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
service_tier: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
web_search: None,
|
|
tools: None,
|
|
chatgpt_base_url: None,
|
|
additional: HashMap::new(),
|
|
},
|
|
)]),
|
|
instructions: None,
|
|
developer_instructions: None,
|
|
compact_prompt: None,
|
|
model_reasoning_effort: None,
|
|
model_reasoning_summary: None,
|
|
model_verbosity: None,
|
|
service_tier: None,
|
|
analytics: None,
|
|
apps: None,
|
|
additional: HashMap::new(),
|
|
});
|
|
|
|
assert_eq!(reason, Some("config/read.approvalsReviewer"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() {
|
|
let reason =
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements {
|
|
allowed_approval_policies: Some(vec![AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: false,
|
|
}]),
|
|
allowed_approvals_reviewers: None,
|
|
allowed_sandbox_modes: None,
|
|
allowed_web_search_modes: None,
|
|
feature_requirements: None,
|
|
hooks: None,
|
|
enforce_residency: None,
|
|
network: None,
|
|
});
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_thread_start_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
|
|
&crate::ClientRequest::ThreadStart {
|
|
request_id: crate::RequestId::Integer(1),
|
|
params: ThreadStartParams {
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: true,
|
|
mcp_elicitations: false,
|
|
}),
|
|
..Default::default()
|
|
},
|
|
},
|
|
);
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
|
|
&crate::ClientRequest::ThreadResume {
|
|
request_id: crate::RequestId::Integer(2),
|
|
params: ThreadResumeParams {
|
|
thread_id: "thr_123".to_string(),
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}),
|
|
..Default::default()
|
|
},
|
|
},
|
|
);
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
|
|
&crate::ClientRequest::ThreadFork {
|
|
request_id: crate::RequestId::Integer(3),
|
|
params: ThreadForkParams {
|
|
thread_id: "thr_456".to_string(),
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: true,
|
|
rules: false,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}),
|
|
..Default::default()
|
|
},
|
|
},
|
|
);
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn client_request_turn_start_granular_approval_policy_is_marked_experimental() {
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
|
|
&crate::ClientRequest::TurnStart {
|
|
request_id: crate::RequestId::Integer(4),
|
|
params: TurnStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
input: Vec::new(),
|
|
approval_policy: Some(AskForApproval::Granular {
|
|
sandbox_approval: false,
|
|
rules: true,
|
|
skill_approval: false,
|
|
request_permissions: false,
|
|
mcp_elicitations: true,
|
|
}),
|
|
..Default::default()
|
|
},
|
|
},
|
|
);
|
|
|
|
assert_eq!(reason, Some("askForApproval.granular"));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_response_round_trips_rmcp_result() {
|
|
let rmcp_result = rmcp::model::CreateElicitationResult {
|
|
action: rmcp::model::ElicitationAction::Accept,
|
|
content: Some(json!({
|
|
"confirmed": true,
|
|
})),
|
|
};
|
|
|
|
let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone());
|
|
assert_eq!(
|
|
v2_response,
|
|
McpServerElicitationRequestResponse {
|
|
action: McpServerElicitationAction::Accept,
|
|
content: Some(json!({
|
|
"confirmed": true,
|
|
})),
|
|
meta: None,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
rmcp::model::CreateElicitationResult::from(v2_response),
|
|
rmcp_result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_request_from_core_url_request() {
|
|
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url {
|
|
meta: None,
|
|
message: "Finish sign-in".to_string(),
|
|
url: "https://example.com/complete".to_string(),
|
|
elicitation_id: "elicitation-123".to_string(),
|
|
})
|
|
.expect("URL request should convert");
|
|
|
|
assert_eq!(
|
|
request,
|
|
McpServerElicitationRequest::Url {
|
|
meta: None,
|
|
message: "Finish sign-in".to_string(),
|
|
url: "https://example.com/complete".to_string(),
|
|
elicitation_id: "elicitation-123".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_request_from_core_form_request() {
|
|
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
|
|
meta: None,
|
|
message: "Allow this request?".to_string(),
|
|
requested_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"confirmed": {
|
|
"type": "boolean",
|
|
}
|
|
},
|
|
"required": ["confirmed"],
|
|
}),
|
|
})
|
|
.expect("form request should convert");
|
|
|
|
let expected_schema: McpElicitationSchema = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"confirmed": {
|
|
"type": "boolean",
|
|
}
|
|
},
|
|
"required": ["confirmed"],
|
|
}))
|
|
.expect("expected schema should deserialize");
|
|
|
|
assert_eq!(
|
|
request,
|
|
McpServerElicitationRequest::Form {
|
|
meta: None,
|
|
message: "Allow this request?".to_string(),
|
|
requested_schema: expected_schema,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() {
|
|
let schema: McpElicitationSchema = serde_json::from_value(json!({
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"properties": {
|
|
"email": {
|
|
"type": "string",
|
|
"title": "Email",
|
|
"description": "Work email address",
|
|
"format": "email",
|
|
"default": "dev@example.com",
|
|
},
|
|
"count": {
|
|
"type": "integer",
|
|
"title": "Count",
|
|
"description": "How many items to create",
|
|
"minimum": 1,
|
|
"maximum": 5,
|
|
"default": 3,
|
|
},
|
|
"confirmed": {
|
|
"type": "boolean",
|
|
"title": "Confirm",
|
|
"description": "Approve the pending action",
|
|
"default": true,
|
|
},
|
|
"legacyChoice": {
|
|
"type": "string",
|
|
"title": "Action",
|
|
"description": "Legacy titled enum form",
|
|
"enum": ["allow", "deny"],
|
|
"enumNames": ["Allow", "Deny"],
|
|
"default": "allow",
|
|
},
|
|
},
|
|
"required": ["email", "confirmed"],
|
|
}))
|
|
.expect("schema should deserialize");
|
|
|
|
assert_eq!(
|
|
schema,
|
|
McpElicitationSchema {
|
|
schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
|
|
type_: McpElicitationObjectType::Object,
|
|
properties: BTreeMap::from([
|
|
(
|
|
"confirmed".to_string(),
|
|
McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema {
|
|
type_: McpElicitationBooleanType::Boolean,
|
|
title: Some("Confirm".to_string()),
|
|
description: Some("Approve the pending action".to_string()),
|
|
default: Some(true),
|
|
}),
|
|
),
|
|
(
|
|
"count".to_string(),
|
|
McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema {
|
|
type_: McpElicitationNumberType::Integer,
|
|
title: Some("Count".to_string()),
|
|
description: Some("How many items to create".to_string()),
|
|
minimum: Some(1.0),
|
|
maximum: Some(5.0),
|
|
default: Some(3.0),
|
|
}),
|
|
),
|
|
(
|
|
"email".to_string(),
|
|
McpElicitationPrimitiveSchema::String(McpElicitationStringSchema {
|
|
type_: McpElicitationStringType::String,
|
|
title: Some("Email".to_string()),
|
|
description: Some("Work email address".to_string()),
|
|
min_length: None,
|
|
max_length: None,
|
|
format: Some(McpElicitationStringFormat::Email),
|
|
default: Some("dev@example.com".to_string()),
|
|
}),
|
|
),
|
|
(
|
|
"legacyChoice".to_string(),
|
|
McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy(
|
|
McpElicitationLegacyTitledEnumSchema {
|
|
type_: McpElicitationStringType::String,
|
|
title: Some("Action".to_string()),
|
|
description: Some("Legacy titled enum form".to_string()),
|
|
enum_: vec!["allow".to_string(), "deny".to_string()],
|
|
enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]),
|
|
default: Some("allow".to_string()),
|
|
},
|
|
)),
|
|
),
|
|
]),
|
|
required: Some(vec!["email".to_string(), "confirmed".to_string()]),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_request_rejects_null_core_form_schema() {
|
|
let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
|
|
meta: Some(json!({
|
|
"persist": "session",
|
|
})),
|
|
message: "Allow this request?".to_string(),
|
|
requested_schema: JsonValue::Null,
|
|
});
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() {
|
|
let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
|
|
meta: None,
|
|
message: "Allow this request?".to_string(),
|
|
requested_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"confirmed": {
|
|
"type": "object",
|
|
}
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_server_elicitation_response_serializes_nullable_content() {
|
|
let response = McpServerElicitationRequestResponse {
|
|
action: McpServerElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(response).expect("response should serialize"),
|
|
json!({
|
|
"action": "decline",
|
|
"content": null,
|
|
"_meta": null,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_round_trips_workspace_write_access() {
|
|
let v2_policy = SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: false,
|
|
exclude_slash_tmp: false,
|
|
};
|
|
|
|
let core_policy = v2_policy.to_core();
|
|
assert_eq!(
|
|
core_policy,
|
|
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: false,
|
|
exclude_slash_tmp: false,
|
|
}
|
|
);
|
|
|
|
let back_to_v2 = SandboxPolicy::from(core_policy);
|
|
assert_eq!(back_to_v2, v2_policy);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_deserializes_legacy_read_only_full_access_field() {
|
|
let policy = serde_json::from_value::<SandboxPolicy>(json!({
|
|
"type": "readOnly",
|
|
"access": {
|
|
"type": "fullAccess"
|
|
},
|
|
"networkAccess": true
|
|
}))
|
|
.expect("read-only policy should ignore legacy fullAccess field");
|
|
assert_eq!(
|
|
policy,
|
|
SandboxPolicy::ReadOnly {
|
|
network_access: true
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() {
|
|
let writable_root = absolute_path("/workspace");
|
|
let policy = serde_json::from_value::<SandboxPolicy>(json!({
|
|
"type": "workspaceWrite",
|
|
"writableRoots": [writable_root],
|
|
"readOnlyAccess": {
|
|
"type": "fullAccess"
|
|
},
|
|
"networkAccess": true,
|
|
"excludeTmpdirEnvVar": true,
|
|
"excludeSlashTmp": true
|
|
}))
|
|
.expect("workspace-write policy should ignore legacy fullAccess field");
|
|
assert_eq!(
|
|
policy,
|
|
SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: vec![absolute_path("/workspace")],
|
|
network_access: true,
|
|
exclude_tmpdir_env_var: true,
|
|
exclude_slash_tmp: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() {
|
|
let err = serde_json::from_value::<SandboxPolicy>(json!({
|
|
"type": "readOnly",
|
|
"access": {
|
|
"type": "restricted",
|
|
"includePlatformDefaults": false,
|
|
"readableRoots": []
|
|
}
|
|
}))
|
|
.expect_err("read-only policy should reject removed restricted access field");
|
|
assert!(err.to_string().contains("readOnly.access"));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() {
|
|
let err = serde_json::from_value::<SandboxPolicy>(json!({
|
|
"type": "workspaceWrite",
|
|
"writableRoots": [],
|
|
"readOnlyAccess": {
|
|
"type": "restricted",
|
|
"includePlatformDefaults": false,
|
|
"readableRoots": []
|
|
},
|
|
"networkAccess": false,
|
|
"excludeTmpdirEnvVar": false,
|
|
"excludeSlashTmp": false
|
|
}))
|
|
.expect_err("workspace-write policy should reject removed restricted readOnlyAccess field");
|
|
assert!(err.to_string().contains("workspaceWrite.readOnlyAccess"));
|
|
}
|
|
|
|
#[test]
|
|
fn automatic_approval_review_deserializes_aborted_status() {
|
|
let review: GuardianApprovalReview = serde_json::from_value(json!({
|
|
"status": "aborted",
|
|
"riskLevel": null,
|
|
"userAuthorization": null,
|
|
"rationale": null
|
|
}))
|
|
.expect("aborted automatic review should deserialize");
|
|
assert_eq!(
|
|
review,
|
|
GuardianApprovalReview {
|
|
status: GuardianApprovalReviewStatus::Aborted,
|
|
risk_level: None,
|
|
user_authorization: None,
|
|
rationale: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_approval_review_action_round_trips_command_shape() {
|
|
let value = json!({
|
|
"type": "command",
|
|
"source": "shell",
|
|
"command": "rm -rf /tmp/example.sqlite",
|
|
"cwd": absolute_path_string("tmp"),
|
|
});
|
|
let action: GuardianApprovalReviewAction =
|
|
serde_json::from_value(value.clone()).expect("guardian review action");
|
|
|
|
assert_eq!(
|
|
action,
|
|
GuardianApprovalReviewAction::Command {
|
|
source: GuardianCommandSource::Shell,
|
|
command: "rm -rf /tmp/example.sqlite".to_string(),
|
|
cwd: absolute_path("tmp"),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&action).expect("serialize guardian review action"),
|
|
value
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_requirements_deserializes_legacy_fields() {
|
|
let requirements: NetworkRequirements = serde_json::from_value(json!({
|
|
"allowedDomains": ["api.openai.com"],
|
|
"deniedDomains": ["blocked.example.com"],
|
|
"allowUnixSockets": ["/tmp/proxy.sock"]
|
|
}))
|
|
.expect("legacy network requirements should deserialize");
|
|
|
|
assert_eq!(
|
|
requirements,
|
|
NetworkRequirements {
|
|
enabled: None,
|
|
http_port: None,
|
|
socks_port: None,
|
|
allow_upstream_proxy: None,
|
|
dangerously_allow_non_loopback_proxy: None,
|
|
dangerously_allow_all_unix_sockets: None,
|
|
domains: None,
|
|
managed_allowed_domains_only: None,
|
|
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
|
denied_domains: Some(vec!["blocked.example.com".to_string()]),
|
|
unix_sockets: None,
|
|
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
|
|
allow_local_binding: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn network_requirements_serializes_canonical_and_legacy_fields() {
|
|
let requirements = NetworkRequirements {
|
|
enabled: Some(true),
|
|
http_port: Some(8080),
|
|
socks_port: Some(1080),
|
|
allow_upstream_proxy: Some(false),
|
|
dangerously_allow_non_loopback_proxy: Some(false),
|
|
dangerously_allow_all_unix_sockets: Some(true),
|
|
domains: Some(BTreeMap::from([
|
|
("api.openai.com".to_string(), NetworkDomainPermission::Allow),
|
|
(
|
|
"blocked.example.com".to_string(),
|
|
NetworkDomainPermission::Deny,
|
|
),
|
|
])),
|
|
managed_allowed_domains_only: Some(true),
|
|
allowed_domains: Some(vec!["api.openai.com".to_string()]),
|
|
denied_domains: Some(vec!["blocked.example.com".to_string()]),
|
|
unix_sockets: Some(BTreeMap::from([
|
|
(
|
|
"/tmp/proxy.sock".to_string(),
|
|
NetworkUnixSocketPermission::Allow,
|
|
),
|
|
(
|
|
"/tmp/ignored.sock".to_string(),
|
|
NetworkUnixSocketPermission::None,
|
|
),
|
|
])),
|
|
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
|
|
allow_local_binding: Some(true),
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(requirements).expect("network requirements should serialize"),
|
|
json!({
|
|
"enabled": true,
|
|
"httpPort": 8080,
|
|
"socksPort": 1080,
|
|
"allowUpstreamProxy": false,
|
|
"dangerouslyAllowNonLoopbackProxy": false,
|
|
"dangerouslyAllowAllUnixSockets": true,
|
|
"domains": {
|
|
"api.openai.com": "allow",
|
|
"blocked.example.com": "deny"
|
|
},
|
|
"managedAllowedDomainsOnly": true,
|
|
"allowedDomains": ["api.openai.com"],
|
|
"deniedDomains": ["blocked.example.com"],
|
|
"unixSockets": {
|
|
"/tmp/ignored.sock": "none",
|
|
"/tmp/proxy.sock": "allow"
|
|
},
|
|
"allowUnixSockets": ["/tmp/proxy.sock"],
|
|
"allowLocalBinding": true
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn core_turn_item_into_thread_item_converts_supported_variants() {
|
|
let user_item = TurnItem::UserMessage(UserMessageItem {
|
|
id: "user-1".to_string(),
|
|
content: vec![
|
|
CoreUserInput::Text {
|
|
text: "hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
CoreUserInput::Image {
|
|
image_url: "https://example.com/image.png".to_string(),
|
|
},
|
|
CoreUserInput::LocalImage {
|
|
path: PathBuf::from("local/image.png"),
|
|
},
|
|
CoreUserInput::Skill {
|
|
name: "skill-creator".to_string(),
|
|
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
|
|
},
|
|
CoreUserInput::Mention {
|
|
name: "Demo App".to_string(),
|
|
path: "app://demo-app".to_string(),
|
|
},
|
|
],
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(user_item),
|
|
ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
content: vec![
|
|
UserInput::Text {
|
|
text: "hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
UserInput::Image {
|
|
url: "https://example.com/image.png".to_string(),
|
|
},
|
|
UserInput::LocalImage {
|
|
path: PathBuf::from("local/image.png"),
|
|
},
|
|
UserInput::Skill {
|
|
name: "skill-creator".to_string(),
|
|
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
|
|
},
|
|
UserInput::Mention {
|
|
name: "Demo App".to_string(),
|
|
path: "app://demo-app".to_string(),
|
|
},
|
|
],
|
|
}
|
|
);
|
|
|
|
let agent_item = TurnItem::AgentMessage(AgentMessageItem {
|
|
id: "agent-1".to_string(),
|
|
content: vec![
|
|
AgentMessageContent::Text {
|
|
text: "Hello ".to_string(),
|
|
},
|
|
AgentMessageContent::Text {
|
|
text: "world".to_string(),
|
|
},
|
|
],
|
|
phase: None,
|
|
memory_citation: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(agent_item),
|
|
ThreadItem::AgentMessage {
|
|
id: "agent-1".to_string(),
|
|
text: "Hello world".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
}
|
|
);
|
|
|
|
let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem {
|
|
id: "agent-2".to_string(),
|
|
content: vec![AgentMessageContent::Text {
|
|
text: "final".to_string(),
|
|
}],
|
|
phase: Some(MessagePhase::FinalAnswer),
|
|
memory_citation: Some(CoreMemoryCitation {
|
|
entries: vec![CoreMemoryCitationEntry {
|
|
path: "MEMORY.md".to_string(),
|
|
line_start: 1,
|
|
line_end: 2,
|
|
note: "summary".to_string(),
|
|
}],
|
|
rollout_ids: vec!["rollout-1".to_string()],
|
|
}),
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(agent_item_with_phase),
|
|
ThreadItem::AgentMessage {
|
|
id: "agent-2".to_string(),
|
|
text: "final".to_string(),
|
|
phase: Some(MessagePhase::FinalAnswer),
|
|
memory_citation: Some(MemoryCitation {
|
|
entries: vec![MemoryCitationEntry {
|
|
path: "MEMORY.md".to_string(),
|
|
line_start: 1,
|
|
line_end: 2,
|
|
note: "summary".to_string(),
|
|
}],
|
|
thread_ids: vec!["rollout-1".to_string()],
|
|
}),
|
|
}
|
|
);
|
|
|
|
let reasoning_item = TurnItem::Reasoning(ReasoningItem {
|
|
id: "reasoning-1".to_string(),
|
|
summary_text: vec!["line one".to_string(), "line two".to_string()],
|
|
raw_content: vec![],
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(reasoning_item),
|
|
ThreadItem::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec!["line one".to_string(), "line two".to_string()],
|
|
content: vec![],
|
|
}
|
|
);
|
|
|
|
let search_item = TurnItem::WebSearch(WebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: "docs".to_string(),
|
|
action: CoreWebSearchAction::Search {
|
|
query: Some("docs".to_string()),
|
|
queries: None,
|
|
},
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(search_item),
|
|
ThreadItem::WebSearch {
|
|
id: "search-1".to_string(),
|
|
query: "docs".to_string(),
|
|
action: Some(WebSearchAction::Search {
|
|
query: Some("docs".to_string()),
|
|
queries: None,
|
|
}),
|
|
}
|
|
);
|
|
|
|
let image_view_item = TurnItem::ImageView(ImageViewItem {
|
|
id: "view-image-1".to_string(),
|
|
path: test_path_buf("/tmp/view-image.png").abs(),
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(image_view_item),
|
|
ThreadItem::ImageView {
|
|
id: "view-image-1".to_string(),
|
|
path: test_path_buf("/tmp/view-image.png").abs(),
|
|
}
|
|
);
|
|
|
|
let file_change_item = TurnItem::FileChange(FileChangeItem {
|
|
id: "patch-1".to_string(),
|
|
changes: [(
|
|
PathBuf::from("README.md"),
|
|
codex_protocol::protocol::FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
status: Some(codex_protocol::protocol::PatchApplyStatus::Completed),
|
|
auto_approved: None,
|
|
stdout: Some("Done!".to_string()),
|
|
stderr: Some(String::new()),
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(file_change_item),
|
|
ThreadItem::FileChange {
|
|
id: "patch-1".to_string(),
|
|
changes: vec![FileUpdateChange {
|
|
path: "README.md".to_string(),
|
|
kind: PatchChangeKind::Add,
|
|
diff: "hello\n".to_string(),
|
|
}],
|
|
status: PatchApplyStatus::Completed,
|
|
}
|
|
);
|
|
|
|
let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
|
|
id: "mcp-1".to_string(),
|
|
server: "server".to_string(),
|
|
tool: "tool".to_string(),
|
|
arguments: json!({"arg": "value"}),
|
|
mcp_app_resource_uri: Some("app://connector".to_string()),
|
|
status: CoreMcpToolCallStatus::InProgress,
|
|
result: None,
|
|
error: None,
|
|
duration: None,
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(mcp_tool_call_item),
|
|
ThreadItem::McpToolCall {
|
|
id: "mcp-1".to_string(),
|
|
server: "server".to_string(),
|
|
tool: "tool".to_string(),
|
|
status: McpToolCallStatus::InProgress,
|
|
arguments: json!({"arg": "value"}),
|
|
mcp_app_resource_uri: Some("app://connector".to_string()),
|
|
result: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
}
|
|
);
|
|
|
|
let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
|
|
id: "mcp-2".to_string(),
|
|
server: "server".to_string(),
|
|
tool: "tool".to_string(),
|
|
arguments: JsonValue::Null,
|
|
mcp_app_resource_uri: None,
|
|
status: CoreMcpToolCallStatus::Completed,
|
|
result: Some(CallToolResult {
|
|
content: vec![json!({"type": "text", "text": "ok"})],
|
|
structured_content: Some(json!({"ok": true})),
|
|
is_error: Some(false),
|
|
meta: Some(json!({"trace": "1"})),
|
|
}),
|
|
error: None,
|
|
duration: Some(Duration::from_millis(42)),
|
|
});
|
|
|
|
assert_eq!(
|
|
ThreadItem::from(completed_mcp_tool_call_item),
|
|
ThreadItem::McpToolCall {
|
|
id: "mcp-2".to_string(),
|
|
server: "server".to_string(),
|
|
tool: "tool".to_string(),
|
|
status: McpToolCallStatus::Completed,
|
|
arguments: JsonValue::Null,
|
|
mcp_app_resource_uri: None,
|
|
result: Some(Box::new(McpToolCallResult {
|
|
content: vec![json!({"type": "text", "text": "ok"})],
|
|
structured_content: Some(json!({"ok": true})),
|
|
meta: Some(json!({"trace": "1"})),
|
|
})),
|
|
error: None,
|
|
duration_ms: Some(42),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn skills_list_params_serialization_uses_force_reload() {
|
|
assert_eq!(
|
|
serde_json::to_value(SkillsListParams {
|
|
cwds: Vec::new(),
|
|
force_reload: false,
|
|
per_cwd_extra_user_roots: None,
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"perCwdExtraUserRoots": null,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(SkillsListParams {
|
|
cwds: vec![PathBuf::from("/repo")],
|
|
force_reload: true,
|
|
per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd {
|
|
cwd: PathBuf::from("/repo"),
|
|
extra_user_roots: vec![PathBuf::from("/shared/skills"), PathBuf::from("/tmp/x")],
|
|
}]),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"cwds": ["/repo"],
|
|
"forceReload": true,
|
|
"perCwdExtraUserRoots": [
|
|
{
|
|
"cwd": "/repo",
|
|
"extraUserRoots": ["/shared/skills", "/tmp/x"],
|
|
}
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_source_serializes_local_git_and_remote_variants() {
|
|
let local_path = if cfg!(windows) {
|
|
r"C:\plugins\linear"
|
|
} else {
|
|
"/plugins/linear"
|
|
};
|
|
let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap();
|
|
let local_path_json = local_path.as_path().display().to_string();
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(),
|
|
json!({
|
|
"type": "local",
|
|
"path": local_path_json,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginSource::Git {
|
|
url: "https://github.com/openai/example.git".to_string(),
|
|
path: Some("plugins/example".to_string()),
|
|
ref_name: Some("main".to_string()),
|
|
sha: Some("abc123".to_string()),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"type": "git",
|
|
"url": "https://github.com/openai/example.git",
|
|
"path": "plugins/example",
|
|
"refName": "main",
|
|
"sha": "abc123",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginSource::Remote).unwrap(),
|
|
json!({
|
|
"type": "remote",
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() {
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceAddParams {
|
|
source: "owner/repo".to_string(),
|
|
ref_name: None,
|
|
sparse_paths: None,
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"source": "owner/repo",
|
|
"refName": null,
|
|
"sparsePaths": null,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceAddParams {
|
|
source: "owner/repo".to_string(),
|
|
ref_name: Some("main".to_string()),
|
|
sparse_paths: Some(vec!["plugins/foo".to_string()]),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"source": "owner/repo",
|
|
"refName": "main",
|
|
"sparsePaths": ["plugins/foo"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() {
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceUpgradeParams {
|
|
marketplace_name: None,
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplaceName": null,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<MarketplaceUpgradeParams>(json!({})).unwrap(),
|
|
MarketplaceUpgradeParams {
|
|
marketplace_name: None,
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceUpgradeParams {
|
|
marketplace_name: Some("debug".to_string()),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplaceName": "debug",
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_marketplace_entry_serializes_remote_only_path_as_null() {
|
|
assert_eq!(
|
|
serde_json::to_value(PluginMarketplaceEntry {
|
|
name: "openai-curated".to_string(),
|
|
path: None,
|
|
interface: None,
|
|
plugins: Vec::new(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"name": "openai-curated",
|
|
"path": null,
|
|
"interface": null,
|
|
"plugins": [],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_interface_serializes_local_paths_and_remote_urls_separately() {
|
|
let composer_icon = if cfg!(windows) {
|
|
r"C:\plugins\linear\icon.png"
|
|
} else {
|
|
"/plugins/linear/icon.png"
|
|
};
|
|
let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap();
|
|
let composer_icon_json = composer_icon.as_path().display().to_string();
|
|
|
|
let interface = PluginInterface {
|
|
display_name: Some("Linear".to_string()),
|
|
short_description: None,
|
|
long_description: None,
|
|
developer_name: None,
|
|
category: Some("Productivity".to_string()),
|
|
capabilities: Vec::new(),
|
|
website_url: None,
|
|
privacy_policy_url: None,
|
|
terms_of_service_url: None,
|
|
default_prompt: None,
|
|
brand_color: None,
|
|
composer_icon: Some(composer_icon),
|
|
composer_icon_url: Some("https://example.com/linear/icon.png".to_string()),
|
|
logo: None,
|
|
logo_url: Some("https://example.com/linear/logo.png".to_string()),
|
|
screenshots: Vec::new(),
|
|
screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()],
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(interface).unwrap(),
|
|
json!({
|
|
"displayName": "Linear",
|
|
"shortDescription": null,
|
|
"longDescription": null,
|
|
"developerName": null,
|
|
"category": "Productivity",
|
|
"capabilities": [],
|
|
"websiteUrl": null,
|
|
"privacyPolicyUrl": null,
|
|
"termsOfServiceUrl": null,
|
|
"defaultPrompt": null,
|
|
"brandColor": null,
|
|
"composerIcon": composer_icon_json,
|
|
"composerIconUrl": "https://example.com/linear/icon.png",
|
|
"logo": null,
|
|
"logoUrl": "https://example.com/linear/logo.png",
|
|
"screenshots": [],
|
|
"screenshotUrls": ["https://example.com/linear/screenshot.png"],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_list_params_ignore_removed_force_remote_sync_field() {
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginListParams>(json!({
|
|
"cwds": null,
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginListParams { cwds: None },
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_read_params_serialization_uses_install_source_fields() {
|
|
let marketplace_path = if cfg!(windows) {
|
|
r"C:\plugins\marketplace.json"
|
|
} else {
|
|
"/plugins/marketplace.json"
|
|
};
|
|
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
|
|
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
|
assert_eq!(
|
|
serde_json::to_value(PluginReadParams {
|
|
marketplace_path: Some(marketplace_path.clone()),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "gmail".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplacePath": marketplace_path_json,
|
|
"remoteMarketplaceName": null,
|
|
"pluginName": "gmail",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginReadParams>(json!({
|
|
"marketplacePath": marketplace_path_json,
|
|
"pluginName": "gmail",
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginReadParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "gmail".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginReadParams>(json!({
|
|
"remoteMarketplaceName": "openai-curated",
|
|
"pluginName": "gmail",
|
|
}))
|
|
.unwrap(),
|
|
PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated".to_string()),
|
|
plugin_name: "gmail".to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_install_params_serialization_omits_force_remote_sync() {
|
|
let marketplace_path = if cfg!(windows) {
|
|
r"C:\plugins\marketplace.json"
|
|
} else {
|
|
"/plugins/marketplace.json"
|
|
};
|
|
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
|
|
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
|
assert_eq!(
|
|
serde_json::to_value(PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path.clone()),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "gmail".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplacePath": marketplace_path_json,
|
|
"remoteMarketplaceName": null,
|
|
"pluginName": "gmail",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginInstallParams>(json!({
|
|
"marketplacePath": marketplace_path_json,
|
|
"pluginName": "gmail",
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "gmail".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginInstallParams>(json!({
|
|
"remoteMarketplaceName": "openai-curated",
|
|
"pluginName": "gmail",
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginInstallParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated".to_string()),
|
|
plugin_name: "gmail".to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
|
|
assert_eq!(
|
|
serde_json::to_value(PluginSkillReadParams {
|
|
remote_marketplace_name: "chatgpt-global".to_string(),
|
|
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
skill_name: "plan-work".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"remoteMarketplaceName": "chatgpt-global",
|
|
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
|
"skillName": "plan-work",
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
|
|
let plugin_path = if cfg!(windows) {
|
|
r"C:\plugins\gmail"
|
|
} else {
|
|
"/plugins/gmail"
|
|
};
|
|
let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap();
|
|
let plugin_path_json = plugin_path.as_path().display().to_string();
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginShareSaveParams {
|
|
plugin_path: plugin_path.clone(),
|
|
remote_plugin_id: None,
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"pluginPath": plugin_path_json,
|
|
"remotePluginId": null,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginShareSaveParams {
|
|
plugin_path,
|
|
remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"pluginPath": plugin_path_json,
|
|
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginShareSaveResponse {
|
|
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
share_url: String::new(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
|
"shareUrl": "",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginShareListParams>(json!({})).unwrap(),
|
|
PluginShareListParams {},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginShareDeleteParams {
|
|
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_share_list_response_serializes_share_items() {
|
|
assert_eq!(
|
|
serde_json::to_value(PluginShareListResponse {
|
|
data: vec![PluginShareListItem {
|
|
plugin: PluginSummary {
|
|
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
name: "gmail".to_string(),
|
|
source: PluginSource::Remote,
|
|
installed: false,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
auth_policy: PluginAuthPolicy::OnUse,
|
|
availability: PluginAvailability::Available,
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
},
|
|
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
|
local_plugin_path: None,
|
|
}],
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"data": [{
|
|
"plugin": {
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "gmail",
|
|
"source": { "type": "remote" },
|
|
"installed": false,
|
|
"enabled": false,
|
|
"installPolicy": "AVAILABLE",
|
|
"authPolicy": "ON_USE",
|
|
"availability": "AVAILABLE",
|
|
"interface": null,
|
|
"keywords": [],
|
|
},
|
|
"shareUrl": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"localPluginPath": null,
|
|
}],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_summary_defaults_missing_availability_to_available() {
|
|
let summary: PluginSummary = serde_json::from_value(json!({
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "gmail",
|
|
"source": { "type": "remote" },
|
|
"installed": false,
|
|
"enabled": false,
|
|
"installPolicy": "AVAILABLE",
|
|
"authPolicy": "ON_USE",
|
|
"interface": null,
|
|
}))
|
|
.unwrap();
|
|
|
|
assert_eq!(summary.availability, PluginAvailability::Available);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_availability_deserializes_enabled_alias() {
|
|
let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap();
|
|
|
|
assert_eq!(availability, PluginAvailability::Available);
|
|
assert_eq!(
|
|
serde_json::to_value(availability).unwrap(),
|
|
json!("AVAILABLE")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_uninstall_params_serialization_omits_force_remote_sync() {
|
|
assert_eq!(
|
|
serde_json::to_value(PluginUninstallParams {
|
|
plugin_id: "gmail@openai-curated".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"pluginId": "gmail@openai-curated",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginUninstallParams>(json!({
|
|
"pluginId": "gmail@openai-curated",
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginUninstallParams {
|
|
plugin_id: "gmail@openai-curated".to_string(),
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(PluginUninstallParams {
|
|
plugin_id: "plugins~Plugin_gmail".to_string(),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"pluginId": "plugins~Plugin_gmail",
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::from_value::<PluginUninstallParams>(json!({
|
|
"pluginId": "plugins~Plugin_gmail",
|
|
"forceRemoteSync": true,
|
|
}))
|
|
.unwrap(),
|
|
PluginUninstallParams {
|
|
plugin_id: "plugins~Plugin_gmail".to_string(),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_remove_response_serializes_nullable_installed_root() {
|
|
let installed_root = if cfg!(windows) {
|
|
r"C:\marketplaces\debug"
|
|
} else {
|
|
"/tmp/marketplaces/debug"
|
|
};
|
|
let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap();
|
|
let installed_root_json = installed_root.as_path().display().to_string();
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceRemoveResponse {
|
|
marketplace_name: "debug".to_string(),
|
|
installed_root: Some(installed_root),
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplaceName": "debug",
|
|
"installedRoot": installed_root_json,
|
|
}),
|
|
);
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceRemoveResponse {
|
|
marketplace_name: "debug".to_string(),
|
|
installed_root: None,
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"marketplaceName": "debug",
|
|
"installedRoot": null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_upgrade_response_serializes_camel_case_fields() {
|
|
let upgraded_root = if cfg!(windows) {
|
|
r"C:\marketplaces\debug"
|
|
} else {
|
|
"/tmp/marketplaces/debug"
|
|
};
|
|
let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap();
|
|
let upgraded_root_json = upgraded_root.as_path().display().to_string();
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(MarketplaceUpgradeResponse {
|
|
selected_marketplaces: vec!["debug".to_string()],
|
|
upgraded_roots: vec![upgraded_root],
|
|
errors: vec![MarketplaceUpgradeErrorInfo {
|
|
marketplace_name: "broken".to_string(),
|
|
message: "failed to clone".to_string(),
|
|
}],
|
|
})
|
|
.unwrap(),
|
|
json!({
|
|
"selectedMarketplaces": ["debug"],
|
|
"upgradedRoots": [upgraded_root_json],
|
|
"errors": [{
|
|
"marketplaceName": "broken",
|
|
"message": "failed to clone",
|
|
}],
|
|
}),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_error_info_serializes_http_status_code_in_camel_case() {
|
|
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {
|
|
http_status_code: Some(401),
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(value).unwrap(),
|
|
json!({
|
|
"responseTooManyFailedAttempts": {
|
|
"httpStatusCode": 401
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_error_info_serializes_cyber_policy_in_camel_case() {
|
|
assert_eq!(
|
|
serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(),
|
|
json!("cyberPolicy")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() {
|
|
let value = CodexErrorInfo::ActiveTurnNotSteerable {
|
|
turn_kind: NonSteerableTurnKind::Review,
|
|
};
|
|
|
|
assert_eq!(
|
|
serde_json::to_value(value).unwrap(),
|
|
json!({
|
|
"activeTurnNotSteerable": {
|
|
"turnKind": "review"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_tool_response_serializes_content_items() {
|
|
let value = serde_json::to_value(DynamicToolCallResponse {
|
|
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
}],
|
|
success: true,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"contentItems": [
|
|
{
|
|
"type": "inputText",
|
|
"text": "dynamic-ok"
|
|
}
|
|
],
|
|
"success": true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_tool_response_serializes_text_and_image_content_items() {
|
|
let value = serde_json::to_value(DynamicToolCallResponse {
|
|
content_items: vec![
|
|
DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
},
|
|
DynamicToolCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
},
|
|
],
|
|
success: true,
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
value,
|
|
json!({
|
|
"contentItems": [
|
|
{
|
|
"type": "inputText",
|
|
"text": "dynamic-ok"
|
|
},
|
|
{
|
|
"type": "inputImage",
|
|
"imageUrl": "data:image/png;base64,AAA"
|
|
}
|
|
],
|
|
"success": true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_tool_spec_deserializes_defer_loading() {
|
|
let value = json!({
|
|
"name": "lookup_ticket",
|
|
"description": "Fetch a ticket",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": { "type": "string" }
|
|
}
|
|
},
|
|
"deferLoading": true,
|
|
});
|
|
|
|
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
|
|
|
assert_eq!(
|
|
actual,
|
|
DynamicToolSpec {
|
|
namespace: None,
|
|
name: "lookup_ticket".to_string(),
|
|
description: "Fetch a ticket".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"id": { "type": "string" }
|
|
}
|
|
}),
|
|
defer_loading: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() {
|
|
let value = json!({
|
|
"name": "lookup_ticket",
|
|
"description": "Fetch a ticket",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {}
|
|
},
|
|
"exposeToContext": false,
|
|
});
|
|
|
|
let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize");
|
|
|
|
assert!(actual.defer_loading);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_start_params_preserve_explicit_null_service_tier() {
|
|
let params: ThreadStartParams =
|
|
serde_json::from_value(json!({ "serviceTier": null })).expect("params should deserialize");
|
|
assert_eq!(params.service_tier, Some(None));
|
|
|
|
let serialized = serde_json::to_value(¶ms).expect("params should serialize");
|
|
assert_eq!(
|
|
serialized.get("serviceTier"),
|
|
Some(&serde_json::Value::Null)
|
|
);
|
|
|
|
let serialized_without_override =
|
|
serde_json::to_value(ThreadStartParams::default()).expect("params should serialize");
|
|
assert_eq!(serialized_without_override.get("serviceTier"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_lifecycle_responses_default_missing_compat_fields() {
|
|
let response = json!({
|
|
"thread": {
|
|
"id": "thread-id",
|
|
"forkedFromId": null,
|
|
"preview": "",
|
|
"ephemeral": false,
|
|
"modelProvider": "openai",
|
|
"createdAt": 1,
|
|
"updatedAt": 1,
|
|
"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"),
|
|
"approvalPolicy": "on-failure",
|
|
"approvalsReviewer": "user",
|
|
"sandbox": { "type": "dangerFullAccess" },
|
|
"reasoningEffort": null
|
|
});
|
|
|
|
let start: ThreadStartResponse =
|
|
serde_json::from_value(response.clone()).expect("thread/start response");
|
|
let resume: ThreadResumeResponse =
|
|
serde_json::from_value(response.clone()).expect("thread/resume response");
|
|
let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response");
|
|
|
|
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
|
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
|
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
|
|
assert_eq!(start.permission_profile, None);
|
|
assert_eq!(resume.permission_profile, None);
|
|
assert_eq!(fork.permission_profile, None);
|
|
assert_eq!(start.active_permission_profile, None);
|
|
assert_eq!(resume.active_permission_profile, None);
|
|
assert_eq!(fork.active_permission_profile, None);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_params_preserve_explicit_null_service_tier() {
|
|
let params: TurnStartParams = serde_json::from_value(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
"serviceTier": null
|
|
}))
|
|
.expect("params should deserialize");
|
|
assert_eq!(params.service_tier, Some(None));
|
|
|
|
let serialized = serde_json::to_value(¶ms).expect("params should serialize");
|
|
assert_eq!(
|
|
serialized.get("serviceTier"),
|
|
Some(&serde_json::Value::Null)
|
|
);
|
|
|
|
let without_override = TurnStartParams {
|
|
thread_id: "thread_123".to_string(),
|
|
input: vec![],
|
|
responsesapi_client_metadata: None,
|
|
environments: None,
|
|
cwd: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: None,
|
|
permissions: None,
|
|
model: None,
|
|
service_tier: None,
|
|
effort: None,
|
|
summary: None,
|
|
output_schema: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
};
|
|
let serialized_without_override =
|
|
serde_json::to_value(&without_override).expect("params should serialize");
|
|
assert_eq!(serialized_without_override.get("serviceTier"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_params_round_trip_environments() {
|
|
let cwd = test_absolute_path();
|
|
let params: TurnStartParams = serde_json::from_value(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
"environments": [
|
|
{
|
|
"environmentId": "local",
|
|
"cwd": cwd
|
|
}
|
|
],
|
|
}))
|
|
.expect("params should deserialize");
|
|
|
|
assert_eq!(
|
|
params.environments,
|
|
Some(vec![TurnEnvironmentParams {
|
|
environment_id: "local".to_string(),
|
|
cwd: cwd.clone(),
|
|
}])
|
|
);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(¶ms),
|
|
Some("turn/start.environments")
|
|
);
|
|
|
|
let serialized = serde_json::to_value(¶ms).expect("params should serialize");
|
|
assert_eq!(
|
|
serialized.get("environments"),
|
|
Some(&json!([
|
|
{
|
|
"environmentId": "local",
|
|
"cwd": cwd
|
|
}
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_params_preserve_empty_environments() {
|
|
let params: TurnStartParams = serde_json::from_value(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
"environments": [],
|
|
}))
|
|
.expect("params should deserialize");
|
|
|
|
assert_eq!(params.environments, Some(Vec::new()));
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(¶ms),
|
|
Some("turn/start.environments")
|
|
);
|
|
|
|
let serialized = serde_json::to_value(¶ms).expect("params should serialize");
|
|
assert_eq!(serialized.get("environments"), Some(&json!([])));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_params_treat_null_or_omitted_environments_as_default() {
|
|
let null_environments: TurnStartParams = serde_json::from_value(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
"environments": null,
|
|
}))
|
|
.expect("params should deserialize");
|
|
let omitted_environments: TurnStartParams = serde_json::from_value(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
}))
|
|
.expect("params should deserialize");
|
|
|
|
assert_eq!(null_environments.environments, None);
|
|
assert_eq!(omitted_environments.environments, None);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_params_reject_relative_environment_cwd() {
|
|
let err = serde_json::from_value::<TurnStartParams>(json!({
|
|
"threadId": "thread_123",
|
|
"input": [],
|
|
"environments": [
|
|
{
|
|
"environmentId": "local",
|
|
"cwd": "relative"
|
|
}
|
|
],
|
|
}))
|
|
.expect_err("relative environment cwd should fail");
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("AbsolutePathBuf deserialized without a base path"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|