mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
Support archiving and restoring cloud threads (#60635)
## What changed - Add cloud archive and unarchive requests and support listing archived threads. - Detach successfully archived threads and emit archive notifications. Report loaded cloud threads from live attachments rather than cached resume metadata, including after archive failures. - Scope resume picker archive state and transcript previews by backend and thread so archiving a cloud session preserves a local session with the same ID. - Limit cloud task archive cleanup to the selected thread, preserving child agents and their active session state, and update the confirmation text accordingly. ## Testing Add regression coverage for archive acknowledgments, archived-thread discovery and restoration, attachment state after archive failures, same-ID sessions across backends, and preservation of attached child agents. GitOrigin-RevId: bd155be85ee911375d4d326b18f9b5abe8e82b3d
This commit is contained in:
committed by
copyberry
parent
627b579219
commit
df82e2fe0a
@@ -109,6 +109,29 @@ async fn activation_sends_only_identity_and_excludes_turns() {
|
||||
assert_request(&mut fixture, "UpdateConfig", &[56, 1, 0x92, 1, 1, b't']).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_addresses_the_requested_thread_and_requires_acknowledgment() {
|
||||
let mut fixture = fixture_responses(vec![
|
||||
response(vec![frame(&[])], tonic::Status::ok("")),
|
||||
response(
|
||||
vec![frame(&[])],
|
||||
tonic::Status::permission_denied("synthetic denial"),
|
||||
),
|
||||
])
|
||||
.await;
|
||||
|
||||
fixture.client.archive_thread("t").await.unwrap();
|
||||
assert_request(&mut fixture, "ArchiveThread", b"\x0a\x01t").await;
|
||||
|
||||
let error = fixture.client.archive_thread("u").await.unwrap_err();
|
||||
let MutationError::OutcomeUnknown(Error::Rpc(status)) = error else {
|
||||
panic!("expected a remote archive failure");
|
||||
};
|
||||
assert_eq!(status.code, tonic::Code::PermissionDenied as i32);
|
||||
assert_request(&mut fixture, "ArchiveThread", b"\x0a\x01u").await;
|
||||
assert!(fixture.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_preserves_native_settings_and_thread_metadata() {
|
||||
// Native Thread fields encoded independently of the client's wire types.
|
||||
|
||||
@@ -23,14 +23,15 @@ pub enum ThreadSortDirection {
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Lists unarchived threads with the server's default source selection.
|
||||
/// Reuse the same originators and order when continuing with an opaque cursor.
|
||||
/// Lists threads with the server's default source selection.
|
||||
/// Reuse the same filters and order when continuing with an opaque cursor.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ThreadListQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub sort_key: ThreadSortKey,
|
||||
pub sort_direction: ThreadSortDirection,
|
||||
pub archived: bool,
|
||||
/// Empty means all originators, including conversations created by other clients.
|
||||
pub originators: Vec<String>,
|
||||
}
|
||||
@@ -88,7 +89,7 @@ impl Client {
|
||||
ThreadSortDirection::Asc => 1,
|
||||
ThreadSortDirection::Desc => 2,
|
||||
}),
|
||||
archived: Some(false),
|
||||
archived: Some(query.archived),
|
||||
originators: Some(wire::StringList {
|
||||
values: query.originators.clone(),
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ async fn discovery_preserves_pagination_and_order_on_each_request() {
|
||||
limit: Some(7),
|
||||
sort_key: ThreadSortKey::UpdatedAt,
|
||||
sort_direction: ThreadSortDirection::Asc,
|
||||
archived: false,
|
||||
originators: vec!["tui".into()],
|
||||
};
|
||||
let page = fixture.client.list_threads(&query).await.unwrap();
|
||||
@@ -46,6 +47,45 @@ async fn discovery_preserves_pagination_and_order_on_each_request() {
|
||||
assert!(fixture.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_lists_archived_threads_and_restores_the_selected_thread() {
|
||||
let thread = Bytes::from_static(
|
||||
b"\x22\x00\x28\x00\x32\x01/\x48\x00\x72\x06legacy\x82\x01\x00\xaa\x01\x00\xda\x01\x00\xe2\x01\x02\x08\x01\xea\x01\x02\x12\x00\xfa\x01\x00\x80\x02\x00",
|
||||
);
|
||||
let mut response_thread = Vec::new();
|
||||
prost::encoding::bytes::encode(1, &thread, &mut response_thread);
|
||||
let mut response_page = Vec::new();
|
||||
prost::encoding::bytes::encode(1, &Bytes::from(response_thread.clone()), &mut response_page);
|
||||
let mut fixture = fixture_responses(vec![
|
||||
response(vec![frame(&response_page)], tonic::Status::ok("")),
|
||||
response(vec![frame(&response_thread)], tonic::Status::ok("")),
|
||||
])
|
||||
.await;
|
||||
|
||||
let page = fixture
|
||||
.client
|
||||
.list_threads(&ThreadListQuery {
|
||||
archived: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let [row] = page.data.as_slice() else {
|
||||
panic!("expected one archived conversation");
|
||||
};
|
||||
assert_eq!(row.id, "legacy");
|
||||
assert_request(
|
||||
&mut fixture,
|
||||
"ListThreads",
|
||||
b"\x18\x01\x20\x02\x28\x01\x32\x00",
|
||||
)
|
||||
.await;
|
||||
|
||||
let restored = fixture.client.unarchive_thread(&row.id).await.unwrap();
|
||||
assert_eq!(restored, *row);
|
||||
assert_request(&mut fixture, "UnarchiveThread", b"\x0a\x06legacy").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_preserves_native_snapshots_and_read_state() {
|
||||
// Independent native Thread encodings: Git metadata and future field (99), and a legacy row.
|
||||
|
||||
@@ -89,6 +89,7 @@ use codex_http_client::RouteAwareClientPool;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::BoxStream;
|
||||
use http_body_util::BodyExt;
|
||||
use prost::Message;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use tonic::body::Body;
|
||||
@@ -217,7 +218,7 @@ impl Client {
|
||||
http::uri::PathAndQuery::from_static(
|
||||
"/oaiproto.codex_app_server_backend.ThreadService/Resume",
|
||||
),
|
||||
ProstCodec::<ResumeRequest, wire::ResumeResponse>::default(),
|
||||
ProstCodec::<ResumeRequest, wire::EmptyResponse>::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
@@ -226,6 +227,49 @@ impl Client {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Archives a conversation using the current account, without opening an attachment.
|
||||
pub async fn archive_thread(&self, thread_id: &str) -> Result<(), MutationError> {
|
||||
if thread_id.trim().is_empty() {
|
||||
return Err(MutationError::InvalidRequest);
|
||||
}
|
||||
let payload = self
|
||||
.mutate(
|
||||
wire::ThreadIdRequest {
|
||||
thread_id: thread_id.to_owned(),
|
||||
},
|
||||
"/oaiproto.codex_app_server_backend.ThreadService/ArchiveThread",
|
||||
)
|
||||
.await?;
|
||||
wire::EmptyResponse::decode(payload)
|
||||
.map_err(|_| MutationError::OutcomeUnknown(Error::InvalidResponse))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restores an archived conversation without resuming it.
|
||||
pub async fn unarchive_thread(&self, thread_id: &str) -> Result<ThreadSnapshot, MutationError> {
|
||||
if thread_id.trim().is_empty() {
|
||||
return Err(MutationError::InvalidRequest);
|
||||
}
|
||||
let payload = self
|
||||
.mutate(
|
||||
wire::ThreadIdRequest {
|
||||
thread_id: thread_id.to_owned(),
|
||||
},
|
||||
"/oaiproto.codex_app_server_backend.ThreadService/UnarchiveThread",
|
||||
)
|
||||
.await?;
|
||||
let response = wire::UnarchiveResponse::decode(payload)
|
||||
.map_err(|_| MutationError::OutcomeUnknown(Error::InvalidResponse))?;
|
||||
let raw_thread = response
|
||||
.thread
|
||||
.ok_or(MutationError::OutcomeUnknown(Error::InvalidResponse))?;
|
||||
let thread = ThreadSnapshot::decode(raw_thread).map_err(MutationError::OutcomeUnknown)?;
|
||||
if thread.id != thread_id {
|
||||
return Err(MutationError::OutcomeUnknown(Error::InvalidResponse));
|
||||
}
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
/// Live events only, without history or automatic reconnect. The stream terminates
|
||||
/// on its first error. Setup is bounded; there is no idle or total stream deadline.
|
||||
pub async fn attach(
|
||||
@@ -233,7 +277,7 @@ impl Client {
|
||||
thread_id: &str,
|
||||
) -> Result<BoxStream<'static, Result<Event, Error>>, Error> {
|
||||
let mut grpc = self.grpc.clone();
|
||||
let mut request = tonic::Request::new(wire::AttachRequest {
|
||||
let mut request = tonic::Request::new(wire::ThreadIdRequest {
|
||||
thread_id: thread_id.to_owned(),
|
||||
});
|
||||
*request.metadata_mut() =
|
||||
@@ -245,7 +289,7 @@ impl Client {
|
||||
http::uri::PathAndQuery::from_static(
|
||||
"/oaiproto.codex_app_server_backend.ThreadService/Attach",
|
||||
),
|
||||
ProstCodec::<wire::AttachRequest, wire::AttachResponse>::default(),
|
||||
ProstCodec::<wire::ThreadIdRequest, wire::AttachResponse>::default(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -257,7 +257,7 @@ impl Client {
|
||||
"/oaiproto.codex_app_server_backend.ThreadService/InterruptTurn",
|
||||
)
|
||||
.await?;
|
||||
crate::wire::ResumeResponse::decode(payload)
|
||||
crate::wire::EmptyResponse::decode(payload)
|
||||
.map_err(|_| MutationError::OutcomeUnknown(Error::InvalidResponse))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,14 +8,20 @@ use std::marker::PhantomData;
|
||||
use tonic::codec::Codec;
|
||||
|
||||
#[derive(Clone, prost::Message)]
|
||||
pub(crate) struct ResumeResponse {}
|
||||
pub(crate) struct EmptyResponse {}
|
||||
|
||||
#[derive(Clone, prost::Message)]
|
||||
pub(crate) struct AttachRequest {
|
||||
pub(crate) struct ThreadIdRequest {
|
||||
#[prost(string, tag = "1")]
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, prost::Message)]
|
||||
pub(crate) struct UnarchiveResponse {
|
||||
#[prost(bytes = "bytes", optional, tag = "1")]
|
||||
pub thread: Option<Bytes>,
|
||||
}
|
||||
|
||||
#[derive(Clone, prost::Message)]
|
||||
pub(crate) struct AttachResponse {
|
||||
#[prost(oneof = "Payload", tags = "1, 2")]
|
||||
@@ -38,7 +44,7 @@ impl<T: Message + Send + 'static> Codec for RawResponseCodec<T> {
|
||||
type Encoder = tonic_prost::ProstEncoder<T>;
|
||||
type Decoder = RawDecoder;
|
||||
fn encoder(&mut self) -> Self::Encoder {
|
||||
tonic_prost::ProstCodec::<T, ResumeResponse>::default().encoder()
|
||||
tonic_prost::ProstCodec::<T, EmptyResponse>::default().encoder()
|
||||
}
|
||||
fn decoder(&mut self) -> Self::Decoder {
|
||||
RawDecoder
|
||||
|
||||
Reference in New Issue
Block a user