From 9c616093c88aa2bab5ce58a3a93dd1781f4ca787 Mon Sep 17 00:00:00 2001 From: dmichelin Date: Tue, 11 Aug 2026 22:57:34 +0000 Subject: [PATCH 1/7] feat(REMOTE-2661): support purpose-tagged bootstrap agent prompts Adds an AgentPromptPurpose (SetupFailureDebug) and idempotency_key to AgentPromptRequest, so a token-less request can be explicitly authorized to bootstrap a conversation for a specific reason (e.g. debugging a retained environment-setup-failure session) instead of being treated as an ordinary new-conversation prompt. Adds AcknowledgeAgentPromptRequest (sharer -> server) so the sharer can report which conversation it created or reused for such a request, and threads idempotency_key through RejectAgentPromptRequest so a rejection can be recorded under the same key a retry will look up. Co-Authored-By: Warp Agent --- src/common/agent_prompt.rs | 41 ++++++++++++++++++++++++++++++++++++++ src/sharer.rs | 27 ++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/common/agent_prompt.rs b/src/common/agent_prompt.rs index 40465f3..8ab4cc7 100644 --- a/src/common/agent_prompt.rs +++ b/src/common/agent_prompt.rs @@ -30,6 +30,30 @@ pub enum AgentPromptFailureReason { // There is a long running command that is already in progress. CommandInProgress, + + /// The sharer is not eligible to bootstrap a `purpose`-tagged prompt (e.g. the retained + /// setup-failure debug window has closed). Only ever produced for a request that carried + /// a `purpose` (REMOTE-2661). + NotEligibleForPurpose, +} + +/// Authorizes a no-`server_conversation_token` [`AgentPromptRequest`] to create or reuse a +/// conversation for a specific, non-ordinary purpose, instead of being treated as an ordinary +/// new conversation from a live viewer. +/// +/// Each variant is a distinct authorization the sharer must independently recognize; a sharer +/// that does not understand a given purpose must reject the request (via +/// [`super::super::sharer::UpstreamMessage::RejectAgentPromptRequest`]) rather than silently +/// starting an ordinary conversation, since a token-less prompt with an unrecognized purpose +/// may be authorized for reasons an ordinary new conversation is not. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AgentPromptPurpose { + /// Authorizes the sharer to create or reuse the debug conversation for a retained + /// environment-setup-failure session (REMOTE-2661). The server sets this only after its + /// own eligibility check (open debug window, authorized caller) succeeds; the sharer must + /// still independently confirm it is still in a retained setup-failure state before acting + /// on it. + SetupFailureDebug, } /// Represents an AI agent attachment that can be sent with a prompt. @@ -113,4 +137,21 @@ pub struct AgentPromptRequest { /// Optional attachments (blocks, files, etc.) referenced in the prompt. #[serde(default)] pub attachments: Vec, + + /// Authorizes a `server_conversation_token: None` request to create or reuse a + /// conversation for a specific purpose instead of an ordinary new conversation + /// (REMOTE-2661). `None` for every ordinary agent prompt request from a live viewer. + /// Old sharers ignore this field and treat the request as an ordinary new-conversation + /// prompt; only a sharer new enough to recognize the given purpose grants it any special + /// authorization. + #[serde(default)] + pub purpose: Option, + + /// Idempotency key for a `purpose`-tagged request, so a redelivered bootstrap (the + /// original acknowledgement was lost) reuses the same conversation and reports the same + /// result, rather than starting a second conversation and abandoning the first turn + /// (REMOTE-2661). Always `None` when `purpose` is `None`. The sharer is responsible for + /// remembering the outcome for a given key for at least as long as the server may retry. + #[serde(default)] + pub idempotency_key: Option, } diff --git a/src/sharer.rs b/src/sharer.rs index 283cbff..a70be80 100644 --- a/src/sharer.rs +++ b/src/sharer.rs @@ -17,9 +17,10 @@ use crate::common::{ CommandExecutionRequestId, ControlAction, ControlActionFailureReason, ControlActionRequestId, FeatureSupport, InputOperationId, InputReplicaId, InputUpdate, InputUpdateFailureReason, OrderedTerminalEvent, ParticipantId, ParticipantList, ParticipantPresenceUpdate, Role, - RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, SessionId, SessionSecret, - TelemetryContext, UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate, UserID, - WindowSize, WriteToPtyFailureReason, WriteToPtyRequestId, + RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, ServerConversationToken, + SessionId, SessionSecret, TelemetryContext, UniversalDeveloperInputContext, + UniversalDeveloperInputContextUpdate, UserID, WindowSize, WriteToPtyFailureReason, + WriteToPtyRequestId, }; use super::common::Scrollback; @@ -579,6 +580,26 @@ pub enum UpstreamMessage { id: AgentPromptRequestId, participant_id: ParticipantId, reason: AgentPromptFailureReason, + /// Echoes the originating request's idempotency key when it was a `purpose`-tagged + /// bootstrap request (REMOTE-2661), so the server can persist the rejection under the + /// same key a caller's retry will look up. `None` for an ordinary rejection. + #[serde(default)] + idempotency_key: Option, + }, + + /// Reports the conversation the sharer created or reused for a `purpose`-tagged agent + /// prompt request that carried no `server_conversation_token` (REMOTE-2661). Never sent + /// for an ordinary agent prompt request (one with `purpose: None`), since the server + /// already knows that conversation's token by other means. The server must persist this + /// before the request may be treated as delivered: a lost acknowledgement here is what + /// would otherwise let a retry start a second, independent conversation. + AcknowledgeAgentPromptRequest { + id: AgentPromptRequestId, + participant_id: ParticipantId, + server_conversation_token: ServerConversationToken, + /// Echoes the originating request's idempotency key, which the server correlates + /// against its own pending wait for this bootstrap's result. + idempotency_key: String, }, /// The given control action request was denied for the specified `reason`. From 38fc158eae29abb67ed9abd082966a6dee073785 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 19:38:45 +0000 Subject: [PATCH 2/7] Drop the purpose surface from the bootstrap prompt contract REMOTE-2661's spec (TECH.md:278-282) records purpose-as-authorization as considered and rejected in favor of the setupFailureDebugAuthorization callback, and nothing consumes it. The load-bearing half is bootstrap correlation: no server_conversation_token plus an idempotency_key, on an authenticated injection. Removes AgentPromptPurpose, AgentPromptRequest.purpose, and AgentPromptFailureReason::NotEligibleForPurpose. Keeps AgentPromptRequest.idempotency_key unchanged. --- src/common/agent_prompt.rs | 43 ++++++-------------------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/src/common/agent_prompt.rs b/src/common/agent_prompt.rs index 8ab4cc7..4842259 100644 --- a/src/common/agent_prompt.rs +++ b/src/common/agent_prompt.rs @@ -30,30 +30,6 @@ pub enum AgentPromptFailureReason { // There is a long running command that is already in progress. CommandInProgress, - - /// The sharer is not eligible to bootstrap a `purpose`-tagged prompt (e.g. the retained - /// setup-failure debug window has closed). Only ever produced for a request that carried - /// a `purpose` (REMOTE-2661). - NotEligibleForPurpose, -} - -/// Authorizes a no-`server_conversation_token` [`AgentPromptRequest`] to create or reuse a -/// conversation for a specific, non-ordinary purpose, instead of being treated as an ordinary -/// new conversation from a live viewer. -/// -/// Each variant is a distinct authorization the sharer must independently recognize; a sharer -/// that does not understand a given purpose must reject the request (via -/// [`super::super::sharer::UpstreamMessage::RejectAgentPromptRequest`]) rather than silently -/// starting an ordinary conversation, since a token-less prompt with an unrecognized purpose -/// may be authorized for reasons an ordinary new conversation is not. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum AgentPromptPurpose { - /// Authorizes the sharer to create or reuse the debug conversation for a retained - /// environment-setup-failure session (REMOTE-2661). The server sets this only after its - /// own eligibility check (open debug window, authorized caller) succeeds; the sharer must - /// still independently confirm it is still in a retained setup-failure state before acting - /// on it. - SetupFailureDebug, } /// Represents an AI agent attachment that can be sent with a prompt. @@ -138,19 +114,12 @@ pub struct AgentPromptRequest { #[serde(default)] pub attachments: Vec, - /// Authorizes a `server_conversation_token: None` request to create or reuse a - /// conversation for a specific purpose instead of an ordinary new conversation - /// (REMOTE-2661). `None` for every ordinary agent prompt request from a live viewer. - /// Old sharers ignore this field and treat the request as an ordinary new-conversation - /// prompt; only a sharer new enough to recognize the given purpose grants it any special - /// authorization. - #[serde(default)] - pub purpose: Option, - - /// Idempotency key for a `purpose`-tagged request, so a redelivered bootstrap (the - /// original acknowledgement was lost) reuses the same conversation and reports the same - /// result, rather than starting a second conversation and abandoning the first turn - /// (REMOTE-2661). Always `None` when `purpose` is `None`. The sharer is responsible for + /// Idempotency key for a bootstrap request: set, together with a + /// `server_conversation_token` of `None`, when the request comes from an authenticated + /// server-side injection that must create or reuse exactly one conversation (REMOTE-2661). + /// A redelivery under the same key reuses that conversation and reports the same result, + /// rather than starting a second conversation and abandoning the first turn. `None` for an + /// ordinary agent prompt request from a live viewer. The sharer is responsible for /// remembering the outcome for a given key for at least as long as the server may retry. #[serde(default)] pub idempotency_key: Option, From 329e1f314e5329502bb7841c878b1a477b961ab3 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 19:39:10 +0000 Subject: [PATCH 3/7] Describe bootstrap prompts by their predicate, not a purpose tag The upstream reject/acknowledge docs referred to a `purpose`-tagged request. The predicate is now an idempotency key with no server_conversation_token. --- src/sharer.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sharer.rs b/src/sharer.rs index a70be80..344ea7f 100644 --- a/src/sharer.rs +++ b/src/sharer.rs @@ -580,16 +580,16 @@ pub enum UpstreamMessage { id: AgentPromptRequestId, participant_id: ParticipantId, reason: AgentPromptFailureReason, - /// Echoes the originating request's idempotency key when it was a `purpose`-tagged - /// bootstrap request (REMOTE-2661), so the server can persist the rejection under the - /// same key a caller's retry will look up. `None` for an ordinary rejection. + /// Echoes the originating request's idempotency key when it was a bootstrap request + /// (REMOTE-2661), so the server can persist the rejection under the same key a + /// caller's retry will look up. `None` for an ordinary rejection. #[serde(default)] idempotency_key: Option, }, - /// Reports the conversation the sharer created or reused for a `purpose`-tagged agent - /// prompt request that carried no `server_conversation_token` (REMOTE-2661). Never sent - /// for an ordinary agent prompt request (one with `purpose: None`), since the server + /// Reports the conversation the sharer created or reused for a bootstrap agent prompt + /// request — one that carried an `idempotency_key` and no `server_conversation_token` + /// (REMOTE-2661). Never sent for an ordinary agent prompt request, since the server /// already knows that conversation's token by other means. The server must persist this /// before the request may be treated as delivered: a lost acknowledgement here is what /// would otherwise let a retry start a second, independent conversation. From 0c3725acfc03c6188161e91bf0a3a9bb226f35b4 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 19:39:26 +0000 Subject: [PATCH 4/7] Let UpstreamMessage decode a variant it does not know The enum is a closed serde enum, so a peer built against a newer protocol version cannot be decoded at all: one unknown variant fails the whole decode. An untagged catch-all is tried after every externally tagged variant, so an unrecognized message degrades to something the receiver can ignore. --- src/sharer.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/sharer.rs b/src/sharer.rs index 344ea7f..fac2ce9 100644 --- a/src/sharer.rs +++ b/src/sharer.rs @@ -626,6 +626,12 @@ pub enum UpstreamMessage { /// The sharer removed a pending user as a session guest. RemovePendingGuest { email: String }, + + /// A message this build does not recognize, because a newer sharer sent a variant added + /// after this build. Deserializing into it lets the receiver ignore the message instead + /// of failing the decode. Never constructed to send. + #[serde(untagged)] + Unknown(serde_json::Value), } impl UpstreamMessage { From 890aff7561d758c70c40a5f0cd5778419833a81e Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 19:44:14 +0000 Subject: [PATCH 5/7] Pin the UpstreamMessage unknown-variant fallback with tests The fallback's whole value is tolerating a variant that does not exist yet, so no current code path exercises it and no consumer fails if it silently regresses. Cover it directly: an unrecognized variant decodes to Unknown, a recognized variant still round trips externally tagged, and a variant carrying bytes still decodes, since the trailing untagged variant makes serde buffer every variant's content. Needs no dev-dependency or test harness the crate did not already have. --- src/sharer.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/sharer.rs b/src/sharer.rs index fac2ce9..0a046be 100644 --- a/src/sharer.rs +++ b/src/sharer.rs @@ -654,3 +654,46 @@ impl UpstreamMessage { } } } + +// No current code path produces `UpstreamMessage::Unknown`, since its whole purpose is to +// tolerate a variant that does not exist yet. These pin that wire behavior, which a receiver +// built against an older revision of this crate depends on. +#[cfg(test)] +mod tests { + use super::UpstreamMessage; + + #[test] + fn unrecognized_variant_decodes_as_unknown() { + let decoded = UpstreamMessage::from_json(r#"{"SomeFutureMessage":{"whatever":1}}"#) + .expect("an unrecognized variant must not fail the decode"); + assert!( + matches!(decoded, UpstreamMessage::Unknown(_)), + "{decoded:?}" + ); + } + + #[test] + fn recognized_variant_round_trips_externally_tagged() { + let json = r#"{"AcknowledgeAgentPromptRequest":{"id":"r1","participant_id":"p1","server_conversation_token":"6f1a0d9e-0000-4000-8000-000000000000","idempotency_key":"k1"}}"#; + let decoded = UpstreamMessage::from_json(json).expect("decode"); + assert!( + matches!( + decoded, + UpstreamMessage::AcknowledgeAgentPromptRequest { .. } + ), + "{decoded:?}" + ); + assert_eq!(decoded.to_json().expect("encode"), json); + } + + /// The trailing untagged variant makes serde buffer the content of every variant, so cover + /// one carrying bytes. + #[test] + fn recognized_variant_with_bytes_decodes() { + let decoded = UpstreamMessage::from_json(r#"{"Ping":{"data":[1,2,3]}}"#).expect("decode"); + assert!( + matches!(decoded, UpstreamMessage::Ping { .. }), + "{decoded:?}" + ); + } +} From 4888e75853f3e63f7ac3c2fa44355f54e59ac284 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 21:01:00 +0000 Subject: [PATCH 6/7] Revert the UpstreamMessage unknown-variant fallback Measured in release on identical payloads, the untagged trailing variant forces serde's content-buffering path for the whole enum and roughly doubles the cost of decoding the highest-volume message: PtyBytesRead at 32 KiB went 349.5 us -> 694.9 us, and at 256 B went 4.113 us -> 6.486 us. It also bought nothing. The decode site already tolerates an undecodable message: session-sharing-server's ws_util.rs logs the error together with the raw text and continues the loop. The fallback replaced that with a successful decode into a variant carrying neither, so the observable behavior was unchanged and the diagnostics were worse. It also turned a malformed known variant into a silent skip, which for AcknowledgeAgentPromptRequest means a bootstrap failing as a timeout rather than a decode error. Reverts the variant and the tests that pinned it, restoring src/ to 329e1f3. --- src/sharer.rs | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) diff --git a/src/sharer.rs b/src/sharer.rs index 0a046be..344ea7f 100644 --- a/src/sharer.rs +++ b/src/sharer.rs @@ -626,12 +626,6 @@ pub enum UpstreamMessage { /// The sharer removed a pending user as a session guest. RemovePendingGuest { email: String }, - - /// A message this build does not recognize, because a newer sharer sent a variant added - /// after this build. Deserializing into it lets the receiver ignore the message instead - /// of failing the decode. Never constructed to send. - #[serde(untagged)] - Unknown(serde_json::Value), } impl UpstreamMessage { @@ -654,46 +648,3 @@ impl UpstreamMessage { } } } - -// No current code path produces `UpstreamMessage::Unknown`, since its whole purpose is to -// tolerate a variant that does not exist yet. These pin that wire behavior, which a receiver -// built against an older revision of this crate depends on. -#[cfg(test)] -mod tests { - use super::UpstreamMessage; - - #[test] - fn unrecognized_variant_decodes_as_unknown() { - let decoded = UpstreamMessage::from_json(r#"{"SomeFutureMessage":{"whatever":1}}"#) - .expect("an unrecognized variant must not fail the decode"); - assert!( - matches!(decoded, UpstreamMessage::Unknown(_)), - "{decoded:?}" - ); - } - - #[test] - fn recognized_variant_round_trips_externally_tagged() { - let json = r#"{"AcknowledgeAgentPromptRequest":{"id":"r1","participant_id":"p1","server_conversation_token":"6f1a0d9e-0000-4000-8000-000000000000","idempotency_key":"k1"}}"#; - let decoded = UpstreamMessage::from_json(json).expect("decode"); - assert!( - matches!( - decoded, - UpstreamMessage::AcknowledgeAgentPromptRequest { .. } - ), - "{decoded:?}" - ); - assert_eq!(decoded.to_json().expect("encode"), json); - } - - /// The trailing untagged variant makes serde buffer the content of every variant, so cover - /// one carrying bytes. - #[test] - fn recognized_variant_with_bytes_decodes() { - let decoded = UpstreamMessage::from_json(r#"{"Ping":{"data":[1,2,3]}}"#).expect("decode"); - assert!( - matches!(decoded, UpstreamMessage::Ping { .. }), - "{decoded:?}" - ); - } -} From f019c9a29e102f350bbe085524fe3aea14a727d0 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" Date: Wed, 19 Aug 2026 21:35:33 +0000 Subject: [PATCH 7/7] Put the idempotency guarantee on the side of the wire that implements it The comment asserted that the sharer must remember a key's outcome for as long as the server may retry. No sharer implements that and none ever did; the client-side map that briefly existed was keyed by task id, not by the transmitted key. Deduplication is the session-sharing service's, which claims the key before injecting, so the sharer never sees a redelivery it has already answered. --- src/common/agent_prompt.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/common/agent_prompt.rs b/src/common/agent_prompt.rs index 4842259..21c7358 100644 --- a/src/common/agent_prompt.rs +++ b/src/common/agent_prompt.rs @@ -117,10 +117,12 @@ pub struct AgentPromptRequest { /// Idempotency key for a bootstrap request: set, together with a /// `server_conversation_token` of `None`, when the request comes from an authenticated /// server-side injection that must create or reuse exactly one conversation (REMOTE-2661). - /// A redelivery under the same key reuses that conversation and reports the same result, - /// rather than starting a second conversation and abandoning the first turn. `None` for an - /// ordinary agent prompt request from a live viewer. The sharer is responsible for - /// remembering the outcome for a given key for at least as long as the server may retry. + /// `None` for an ordinary agent prompt request from a live viewer. + /// + /// The key exists so the injector can correlate a retry with its original attempt. + /// Deduplication belongs to the session-sharing service, which claims the key before + /// injecting, so a retry of a key it has already answered does not reach the sharer. The + /// sharer is not required to keep per-key state. #[serde(default)] pub idempotency_key: Option, }