mirror of
https://github.com/openai/codex.git
synced 2026-05-06 20:36:33 +00:00
Compare commits
1 Commits
efrazer/wi
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c48a3bf03 |
@@ -317,6 +317,16 @@ client_request_definitions! {
|
||||
params: v2::ThreadReadParams,
|
||||
response: v2::ThreadReadResponse,
|
||||
},
|
||||
#[experimental("thread/share")]
|
||||
ThreadShare => "thread/share" {
|
||||
params: v2::ThreadShareParams,
|
||||
response: v2::ThreadShareResponse,
|
||||
},
|
||||
#[experimental("share/revoke")]
|
||||
ShareRevoke => "share/revoke" {
|
||||
params: v2::ShareRevokeParams,
|
||||
response: v2::ShareRevokeResponse,
|
||||
},
|
||||
SkillsList => "skills/list" {
|
||||
params: v2::SkillsListParams,
|
||||
response: v2::SkillsListResponse,
|
||||
|
||||
@@ -3245,6 +3245,35 @@ pub struct ThreadReadResponse {
|
||||
pub thread: Thread,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadShareParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadShareResponse {
|
||||
pub share_id: String,
|
||||
pub share_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ShareRevokeParams {
|
||||
pub share_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ShareRevokeResponse {
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -53,6 +53,8 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SandboxPolicy;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ShareRevokeParams;
|
||||
use codex_app_server_protocol::ShareRevokeResponse;
|
||||
use codex_app_server_protocol::ThreadDecrementElicitationParams;
|
||||
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
|
||||
use codex_app_server_protocol::ThreadIncrementElicitationParams;
|
||||
@@ -62,6 +64,8 @@ use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadShareParams;
|
||||
use codex_app_server_protocol::ThreadShareResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
@@ -272,6 +276,18 @@ enum CliCommand {
|
||||
#[arg(long, default_value_t = 15)]
|
||||
hold_seconds: u64,
|
||||
},
|
||||
/// Create a shareable snapshot link for a persisted thread.
|
||||
#[command(name = "thread-share")]
|
||||
ThreadShare {
|
||||
/// Existing thread id to share.
|
||||
thread_id: String,
|
||||
},
|
||||
/// Revoke a previously created share link.
|
||||
#[command(name = "share-revoke")]
|
||||
ShareRevoke {
|
||||
/// Share id returned by thread-share.
|
||||
share_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
@@ -423,6 +439,16 @@ pub async fn run() -> Result<()> {
|
||||
hold_seconds,
|
||||
)
|
||||
}
|
||||
CliCommand::ThreadShare { thread_id } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "thread-share")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
thread_share(&endpoint, &config_overrides, thread_id).await
|
||||
}
|
||||
CliCommand::ShareRevoke { share_id } => {
|
||||
ensure_dynamic_tools_unused(&dynamic_tools, "share-revoke")?;
|
||||
let endpoint = resolve_endpoint(codex_bin, url)?;
|
||||
share_revoke(&endpoint, &config_overrides, share_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,6 +1163,40 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3
|
||||
.await
|
||||
}
|
||||
|
||||
async fn thread_share(
|
||||
endpoint: &Endpoint,
|
||||
config_overrides: &[String],
|
||||
thread_id: String,
|
||||
) -> Result<()> {
|
||||
with_client("thread-share", endpoint, config_overrides, |client| {
|
||||
let initialize = client.initialize_with_experimental_api(true)?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.thread_share(ThreadShareParams { thread_id })?;
|
||||
println!("< thread/share response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_revoke(
|
||||
endpoint: &Endpoint,
|
||||
config_overrides: &[String],
|
||||
share_id: String,
|
||||
) -> Result<()> {
|
||||
with_client("share-revoke", endpoint, config_overrides, |client| {
|
||||
let initialize = client.initialize_with_experimental_api(true)?;
|
||||
println!("< initialize response: {initialize:?}");
|
||||
|
||||
let response = client.share_revoke(ShareRevokeParams { share_id })?;
|
||||
println!("< share/revoke response: {response:?}");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn with_client<T>(
|
||||
command_name: &'static str,
|
||||
endpoint: &Endpoint,
|
||||
@@ -1681,6 +1741,26 @@ impl CodexClient {
|
||||
self.send_request(request, request_id, "thread/decrement_elicitation")
|
||||
}
|
||||
|
||||
fn thread_share(&mut self, params: ThreadShareParams) -> Result<ThreadShareResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ThreadShare {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "thread/share")
|
||||
}
|
||||
|
||||
fn share_revoke(&mut self, params: ShareRevokeParams) -> Result<ShareRevokeResponse> {
|
||||
let request_id = self.request_id();
|
||||
let request = ClientRequest::ShareRevoke {
|
||||
request_id: request_id.clone(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_request(request, request_id, "share/revoke")
|
||||
}
|
||||
|
||||
fn wait_for_account_login_completion(
|
||||
&mut self,
|
||||
expected_login_id: &str,
|
||||
|
||||
@@ -367,6 +367,25 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
|
||||
} }
|
||||
```
|
||||
|
||||
### Example: Create a shareable snapshot link (experimental)
|
||||
|
||||
`thread/share` creates a markdown snapshot of a persisted thread, publishes it through the Codex backend, and returns a share URL. This method rejects threads that have not materialized a rollout yet and requires ChatGPT authentication.
|
||||
|
||||
```json
|
||||
{ "method": "thread/share", "id": 24, "params": { "threadId": "thr_123" } }
|
||||
{ "id": 24, "result": {
|
||||
"shareId": "019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1",
|
||||
"shareUrl": "https://chatgpt.com/s/019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1"
|
||||
} }
|
||||
```
|
||||
|
||||
`share/revoke` revokes a share previously created by the authenticated user.
|
||||
|
||||
```json
|
||||
{ "method": "share/revoke", "id": 25, "params": { "shareId": "019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1" } }
|
||||
{ "id": 25, "result": { "revoked": true } }
|
||||
```
|
||||
|
||||
### Example: Update stored thread metadata
|
||||
|
||||
Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value.
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::outgoing_message::RequestContext;
|
||||
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
|
||||
use crate::thread_status::ThreadWatchManager;
|
||||
use crate::thread_status::resolve_thread_status;
|
||||
use anyhow::Context;
|
||||
use chrono::DateTime;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
@@ -112,6 +113,8 @@ use codex_app_server_protocol::ReviewTarget as ApiReviewTarget;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::ShareRevokeParams;
|
||||
use codex_app_server_protocol::ShareRevokeResponse;
|
||||
use codex_app_server_protocol::SkillSummary;
|
||||
use codex_app_server_protocol::SkillsConfigWriteParams;
|
||||
use codex_app_server_protocol::SkillsConfigWriteResponse;
|
||||
@@ -159,6 +162,7 @@ use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadShareParams;
|
||||
use codex_app_server_protocol::ThreadShellCommandParams;
|
||||
use codex_app_server_protocol::ThreadShellCommandResponse;
|
||||
use codex_app_server_protocol::ThreadSortKey;
|
||||
@@ -189,6 +193,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::CreateThreadShareRequest;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_cloud_requirements::cloud_requirements_loader;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
@@ -197,7 +202,6 @@ use codex_core::Cursor as RolloutCursor;
|
||||
use codex_core::ForkSnapshot;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::SessionMeta;
|
||||
use codex_core::SteerInputError;
|
||||
use codex_core::ThreadConfigSnapshot;
|
||||
use codex_core::ThreadManager;
|
||||
@@ -293,6 +297,7 @@ use codex_protocol::protocol::ReviewRequest;
|
||||
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::ThreadNameUpdatedEvent;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
@@ -789,6 +794,14 @@ impl CodexMessageProcessor {
|
||||
self.thread_shell_command(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ThreadShare { request_id, params } => {
|
||||
self.thread_share(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::ShareRevoke { request_id, params } => {
|
||||
self.share_revoke(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::SkillsList { request_id, params } => {
|
||||
self.skills_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -3714,6 +3727,212 @@ impl CodexMessageProcessor {
|
||||
self.outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
|
||||
async fn thread_share(&self, request_id: ConnectionRequestId, params: ThreadShareParams) {
|
||||
let thread_uuid = match ThreadId::from_string(¶ms.thread_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
self.send_invalid_request_error(request_id, format!("invalid thread id: {err}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let thread_id = thread_uuid.to_string();
|
||||
let rollout_path = match find_thread_path_by_id_str(&self.config.codex_home, &thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => {
|
||||
match find_archived_thread_path_by_id_str(&self.config.codex_home, &thread_id).await
|
||||
{
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!(
|
||||
"thread {thread_uuid} is not materialized yet; thread/share is only available for persisted conversations"
|
||||
),
|
||||
data: Some(serde_json::json!({
|
||||
"reason": "thread_not_persisted"
|
||||
})),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to locate archived thread {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to locate thread {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut thread = match load_thread_summary_for_rollout(
|
||||
&self.config,
|
||||
thread_uuid,
|
||||
rollout_path.as_path(),
|
||||
self.config.model_provider_id.as_str(),
|
||||
/*persisted_metadata*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(thread) => thread,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to snapshot thread {thread_uuid} for sharing: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(message) = populate_thread_turns(
|
||||
&mut thread,
|
||||
ThreadTurnSource::RolloutPath(rollout_path.as_path()),
|
||||
/*active_turn*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.send_internal_error(request_id, message).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let auth = match self.auth_manager.auth().await {
|
||||
Some(auth) if auth.is_chatgpt_auth() => auth,
|
||||
Some(_) | None => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"chatgpt authentication required to create share link".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let title = thread
|
||||
.name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if thread.preview.trim().is_empty() {
|
||||
format!("Codex thread {}", thread.id)
|
||||
} else {
|
||||
thread.preview.clone()
|
||||
}
|
||||
});
|
||||
let markdown = match serde_json::to_string_pretty(&thread)
|
||||
.map(|snapshot| format!("# {title}\n\n```json\n{snapshot}\n```\n"))
|
||||
.context("serialize thread snapshot")
|
||||
{
|
||||
Ok(markdown) => markdown,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to render thread {thread_uuid} for sharing: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client = match BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to construct backend client: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request = CreateThreadShareRequest { title, markdown };
|
||||
match client.create_thread_share(&request).await {
|
||||
Ok(response) => {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
codex_app_server_protocol::ThreadShareResponse {
|
||||
share_id: response.share_id,
|
||||
share_url: response.share_url,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to create backend share link for thread {thread_uuid}: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn share_revoke(&self, request_id: ConnectionRequestId, params: ShareRevokeParams) {
|
||||
let share_id = params.share_id.trim();
|
||||
let has_path_chars = share_id.chars().any(|ch| matches!(ch, '/' | '?' | '#'));
|
||||
if share_id.is_empty() || has_path_chars {
|
||||
self.send_invalid_request_error(request_id, "shareId is invalid".to_string())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let auth = match self.auth_manager.auth().await {
|
||||
Some(auth) if auth.is_chatgpt_auth() => auth,
|
||||
Some(_) | None => {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"chatgpt authentication required to revoke share link".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client = match BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to construct backend client: {err}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
match client.revoke_thread_share(share_id).await {
|
||||
Ok(response) => {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
ShareRevokeResponse {
|
||||
revoked: response.revoked,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.send_internal_error(request_id, format!("failed to revoke share link: {err}"))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver<ThreadId> {
|
||||
self.thread_manager.subscribe_thread_created()
|
||||
}
|
||||
@@ -9253,11 +9472,14 @@ mod tests {
|
||||
use crate::outgoing_message::OutgoingEnvelope;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_core::SessionMeta;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -58,6 +58,7 @@ use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ShareRevokeParams;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
use codex_app_server_protocol::ThreadCompactStartParams;
|
||||
@@ -74,6 +75,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadShareParams;
|
||||
use codex_app_server_protocol::ThreadShellCommandParams;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadUnarchiveParams;
|
||||
@@ -451,6 +453,24 @@ impl McpProcess {
|
||||
self.send_request("thread/read", params).await
|
||||
}
|
||||
|
||||
/// Send a `thread/share` JSON-RPC request.
|
||||
pub async fn send_thread_share_request(
|
||||
&mut self,
|
||||
params: ThreadShareParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("thread/share", params).await
|
||||
}
|
||||
|
||||
/// Send a `share/revoke` JSON-RPC request.
|
||||
pub async fn send_share_revoke_request(
|
||||
&mut self,
|
||||
params: ShareRevokeParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("share/revoke", params).await
|
||||
}
|
||||
|
||||
/// Send a `model/list` JSON-RPC request.
|
||||
pub async fn send_list_models_request(
|
||||
&mut self,
|
||||
|
||||
@@ -42,6 +42,7 @@ mod thread_name_websocket;
|
||||
mod thread_read;
|
||||
mod thread_resume;
|
||||
mod thread_rollback;
|
||||
mod thread_share;
|
||||
mod thread_shell_command;
|
||||
mod thread_start;
|
||||
mod thread_status;
|
||||
|
||||
154
codex-rs/app-server/tests/suite/v2/thread_share.rs
Normal file
154
codex-rs/app-server/tests/suite/v2/thread_share.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ShareRevokeParams;
|
||||
use codex_app_server_protocol::ThreadShareParams;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_share_requires_chatgpt_auth_for_persisted_threads() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let filename_ts = "2025-01-05T12-00-00";
|
||||
let thread_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
filename_ts,
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let share_request_id = mcp
|
||||
.send_thread_share_request(ThreadShareParams {
|
||||
thread_id: thread_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let share_error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(share_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
share_error
|
||||
.error
|
||||
.message
|
||||
.contains("chatgpt authentication required to create share link"),
|
||||
"unexpected error: {}",
|
||||
share_error.error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_share_rejects_unmaterialized_threads() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_response)?;
|
||||
assert!(
|
||||
!thread.path.as_ref().expect("thread path").exists(),
|
||||
"fresh thread rollout should not be materialized yet"
|
||||
);
|
||||
|
||||
let share_request_id = mcp
|
||||
.send_thread_share_request(ThreadShareParams {
|
||||
thread_id: thread.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let share_error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(share_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
share_error
|
||||
.error
|
||||
.message
|
||||
.contains("thread/share is only available for persisted conversations"),
|
||||
"unexpected error: {}",
|
||||
share_error.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
share_error.error.data,
|
||||
Some(json!({"reason": "thread_not_persisted"}))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn share_revoke_requires_chatgpt_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_minimal_config(codex_home.path())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let revoke_request_id = mcp
|
||||
.send_share_revoke_request(ShareRevokeParams {
|
||||
share_id: "share_123".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let revoke_error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(revoke_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
revoke_error
|
||||
.error
|
||||
.message
|
||||
.contains("chatgpt authentication required to revoke share link"),
|
||||
"unexpected error: {}",
|
||||
revoke_error.error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_minimal_config(codex_home: &Path) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::types::CodeTaskDetailsResponse;
|
||||
use crate::types::ConfigFileResponse;
|
||||
use crate::types::CreateThreadShareRequest;
|
||||
use crate::types::CreateThreadShareResponse;
|
||||
use crate::types::PaginatedListTaskListItem;
|
||||
use crate::types::RateLimitStatusPayload;
|
||||
use crate::types::RevokeThreadShareResponse;
|
||||
use crate::types::TurnAttemptsSiblingTurnsResponse;
|
||||
use anyhow::Result;
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
@@ -357,6 +360,34 @@ impl Client {
|
||||
.map_err(RequestError::from)
|
||||
}
|
||||
|
||||
pub async fn create_thread_share(
|
||||
&self,
|
||||
request: &CreateThreadShareRequest,
|
||||
) -> Result<CreateThreadShareResponse> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/thread-shares", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/thread-shares", self.base_url),
|
||||
};
|
||||
let req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.headers())
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(request);
|
||||
let (body, ct) = self.exec_request(req, "POST", &url).await?;
|
||||
self.decode_json::<CreateThreadShareResponse>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
pub async fn revoke_thread_share(&self, share_id: &str) -> Result<RevokeThreadShareResponse> {
|
||||
let url = match self.path_style {
|
||||
PathStyle::CodexApi => format!("{}/api/codex/thread-shares/{share_id}", self.base_url),
|
||||
PathStyle::ChatGptApi => format!("{}/wham/thread-shares/{share_id}", self.base_url),
|
||||
};
|
||||
let req = self.http.delete(&url).headers(self.headers());
|
||||
let (body, ct) = self.exec_request(req, "DELETE", &url).await?;
|
||||
self.decode_json::<RevokeThreadShareResponse>(&url, &ct, &body)
|
||||
}
|
||||
|
||||
/// Create a new task (user turn) by POSTing to the appropriate backend path
|
||||
/// based on `path_style`. Returns the created task id.
|
||||
pub async fn create_task(&self, request_body: serde_json::Value) -> Result<String> {
|
||||
|
||||
@@ -6,6 +6,9 @@ pub use client::RequestError;
|
||||
pub use types::CodeTaskDetailsResponse;
|
||||
pub use types::CodeTaskDetailsResponseExt;
|
||||
pub use types::ConfigFileResponse;
|
||||
pub use types::CreateThreadShareRequest;
|
||||
pub use types::CreateThreadShareResponse;
|
||||
pub use types::PaginatedListTaskListItem;
|
||||
pub use types::RevokeThreadShareResponse;
|
||||
pub use types::TaskListItem;
|
||||
pub use types::TurnAttemptsSiblingTurnsResponse;
|
||||
|
||||
@@ -8,10 +8,31 @@ pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
|
||||
pub use codex_backend_openapi_models::models::TaskListItem;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::Deserializer;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateThreadShareRequest {
|
||||
pub title: String,
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateThreadShareResponse {
|
||||
pub share_id: String,
|
||||
pub share_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevokeThreadShareResponse {
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// Hand-rolled models for the Cloud Tasks task-details response.
|
||||
/// The generated OpenAPI models are pretty bad. This is a half-step
|
||||
/// towards hand-rolling them.
|
||||
|
||||
Reference in New Issue
Block a user