Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 212 additions & 6 deletions src/sharer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,15 @@ pub enum SessionSourceType {
},
}

/// Internal helper that mirrors all wire representations of SessionSourceType
/// (both legacy and new) so we don't recursively call SessionSourceType's
/// custom Deserialize impl.
/// Mirrors the legacy unit-variant form and the new struct-variant form so
/// the custom `Deserialize` impl can accept both shapes.
#[derive(Deserialize)]
#[serde(untagged)]
enum SessionSourceTypeWire {
/// Legacy representation: "User" or "AmbientAgent".
/// Legacy representation: bare `"User"` or `"AmbientAgent"`.
Legacy(LegacySessionSourceType),
/// New representation: externally tagged AmbientAgent with fields, e.g.
/// { "AmbientAgent": { "task_id": "..." } }.
/// New representation: externally tagged `AmbientAgent` with fields, e.g.
/// `{ "AmbientAgent": { "task_id": "..." } }`.
New {
#[serde(rename = "AmbientAgent")]
ambient_agent: AmbientAgentFields,
Expand Down Expand Up @@ -263,6 +262,13 @@ pub struct InitPayload {
#[serde(default)]
pub source_type: SessionSourceType,

/// Optional orchestrator `task_id` carried alongside `source_type`.
/// Set when the sharer wants downstream orchestration discovery to find
/// this share's children regardless of variant kind. Sidecar so the
/// `User` variant can stay a unit and old viewers ignore it.
#[serde(default)]
pub source_task_id: Option<String>,

/// Client feature support declaration.
#[serde(default)]
pub feature_support: FeatureSupport,
Expand Down Expand Up @@ -474,6 +480,10 @@ impl DownstreamMessage {
}

/// The possible messages sent from client (sharer) to server.
// `Initialize(InitPayload)` is much larger than the other variants because
// `InitPayload` carries scrollback and feature-support data. Boxing it would
// be wire-compatible but churn every call site; suppress the lint instead.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Deserialize, Serialize)]
pub enum UpstreamMessage {
/// The client sends this message to start a shared session.
Expand Down Expand Up @@ -601,3 +611,199 @@ impl UpstreamMessage {
}
}
}

#[cfg(test)]
mod session_source_type_tests {
//! Wire-compatibility tests for `SessionSourceType` after the
//! QUALITY-726 sidecar redesign reverted `User` to a strict unit
//! variant. `AmbientAgent` keeps the struct shape it already had
//! on `main`; new orchestrator `task_id`s for `User` shares ride
//! on the `InitPayload::source_task_id` sidecar instead.
use super::*;

// --- Deserialization ---

#[test]
fn deserialize_legacy_user_bare() {
let v: SessionSourceType = serde_json::from_str("\"User\"").unwrap();
assert!(matches!(v, SessionSourceType::User));
}

#[test]
fn deserialize_legacy_ambient_agent_bare() {
let v: SessionSourceType = serde_json::from_str("\"AmbientAgent\"").unwrap();
assert!(matches!(
v,
SessionSourceType::AmbientAgent { task_id: None }
));
}

#[test]
fn deserialize_new_ambient_agent_with_task_id() {
let v: SessionSourceType =
serde_json::from_str(r#"{"AmbientAgent":{"task_id":"xyz"}}"#).unwrap();
match v {
SessionSourceType::AmbientAgent {
task_id: Some(ref s),
} if s == "xyz" => {}
other => panic!("expected AmbientAgent {{ task_id: Some(\"xyz\") }}, got {other:?}"),
}
}

#[test]
fn deserialize_new_ambient_agent_with_null_task_id() {
// Guards Redis rows written before Serialize collapsed the None case.
let v: SessionSourceType =
serde_json::from_str(r#"{"AmbientAgent":{"task_id":null}}"#).unwrap();
assert!(matches!(
v,
SessionSourceType::AmbientAgent { task_id: None }
));
}

#[test]
fn deserialize_new_ambient_agent_without_task_id_field() {
let v: SessionSourceType = serde_json::from_str(r#"{"AmbientAgent":{}}"#).unwrap();
assert!(matches!(
v,
SessionSourceType::AmbientAgent { task_id: None }
));
}

// --- Serialization ---

#[test]
fn serialize_user_emits_bare_form() {
let v = SessionSourceType::User;
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, "\"User\"");
}

#[test]
fn serialize_ambient_agent_without_task_id_emits_struct_form() {
// `AmbientAgent` has been a struct variant since before QUALITY-726,
// so the derived Serialize emits the externally tagged form with a
// `null` task_id rather than the bare legacy form.
let v = SessionSourceType::AmbientAgent { task_id: None };
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, r#"{"AmbientAgent":{"task_id":null}}"#);
}

#[test]
fn serialize_ambient_agent_with_task_id_emits_struct_form() {
let v = SessionSourceType::AmbientAgent {
task_id: Some("xyz".to_string()),
};
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, r#"{"AmbientAgent":{"task_id":"xyz"}}"#);
}

// --- Roundtrip ---

#[test]
fn roundtrip_user() {
let json = serde_json::to_string(&SessionSourceType::User).unwrap();
let parsed: SessionSourceType = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, SessionSourceType::User));
}

#[test]
fn roundtrip_ambient_agent_with_task_id() {
let v = SessionSourceType::AmbientAgent {
task_id: Some("xyz".to_string()),
};
let json = serde_json::to_string(&v).unwrap();
let parsed: SessionSourceType = serde_json::from_str(&json).unwrap();
match parsed {
SessionSourceType::AmbientAgent {
task_id: Some(ref s),
} if s == "xyz" => {}
other => panic!("roundtrip altered value: {other:?}"),
}
}

// --- Helpers ---

#[test]
fn from_user_maps_to_legacy_user() {
assert!(matches!(
LegacySessionSourceType::from(&SessionSourceType::User),
LegacySessionSourceType::User
));
}

#[test]
fn from_ambient_agent_maps_to_legacy_ambient_agent_regardless_of_task_id() {
let no_task = SessionSourceType::AmbientAgent { task_id: None };
assert!(matches!(
LegacySessionSourceType::from(&no_task),
LegacySessionSourceType::AmbientAgent
));

let with_task = SessionSourceType::AmbientAgent {
task_id: Some("xyz".to_string()),
};
assert!(matches!(
LegacySessionSourceType::from(&with_task),
LegacySessionSourceType::AmbientAgent
));
}

#[test]
fn default_is_user() {
let v = SessionSourceType::default();
assert!(matches!(v, SessionSourceType::User));
}
}

#[cfg(test)]
mod init_payload_tests {
//! Wire-compatibility tests for the `source_task_id` sidecar on
//! `InitPayload`. The sidecar is the canonical way to carry an
//! orchestrator `task_id` for `SessionSourceType::User` shares,
//! since the `User` variant is unit-shaped.
use super::*;
use crate::common::{ActivePrompt, BlockId, InputReplicaId, Selection, UserID, WindowSize};

fn make_payload(source_task_id: Option<String>) -> InitPayload {
InitPayload {
scrollback: Scrollback {
blocks: Vec::new(),
is_alt_screen_active: false,
},
active_prompt: ActivePrompt::PS1,
window_size: WindowSize {
num_rows: 24,
num_cols: 80,
},
user_id: UserID::default(),
selection: Selection::None,
init_block_id: BlockId::default(),
input_replica_id: InputReplicaId::default(),
telemetry_context: None,
lifetime: Lifetime::default(),
universal_developer_input_context: None,
source_type: SessionSourceType::User,
source_task_id,
feature_support: FeatureSupport::default(),
}
}

#[test]
fn source_task_id_defaults_to_none_when_field_missing() {
// Older clients pre-sidecar omit the field entirely; the server
// must still accept that payload shape and treat the task id as
// absent.
let mut value = serde_json::to_value(make_payload(None)).unwrap();
value.as_object_mut().unwrap().remove("source_task_id");
let parsed: InitPayload = serde_json::from_value(value).unwrap();
assert!(parsed.source_task_id.is_none());
}

#[test]
fn source_task_id_roundtrips_when_present() {
let json = serde_json::to_string(&make_payload(Some("abc".to_string()))).unwrap();
let parsed: InitPayload = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.source_task_id.as_deref(), Some("abc"));
}
}
7 changes: 7 additions & 0 deletions src/viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ pub enum DownstreamMessage {
/// The detailed source type for this shared session.
#[serde(default)]
detailed_source_type: SessionSourceType,

/// Optional orchestrator `task_id` carried alongside the source
/// type, mirroring `sharer::InitPayload::source_task_id`. Lets
/// viewers find this share's orchestrator task without keying
/// off the source-type variant kind.
#[serde(default)]
source_task_id: Option<String>,
},

/// The server sends this message when the session was successfully rejoined.
Expand Down