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:
Benjamin Carlsson
2026-11-10 18:19:11 +00:00
committed by copyberry
parent 627b579219
commit df82e2fe0a
16 changed files with 698 additions and 78 deletions
+23
View File
@@ -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.
+4 -3
View File
@@ -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.
+47 -3
View File
@@ -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
+1 -1
View File
@@ -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(())
}
+9 -3
View File
@@ -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