Skip to main content

agentmux_common/
transcript_request.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `transcript_request`/`transcript_response` wire payload — `muxspect`
5//! Phase B/C's LAN/WAN conversation-visibility protocol
6//! (docs/specs/SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md,
7//! jekt tier rules confirmed in
8//! docs/specs/SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md).
9//!
10//! There is no structured `msg_type`/content-type field anywhere in the
11//! jekt wire format — every jekt is one opaque `message: String`, and that
12//! field is part of the SIGNED material (`jekt_sign::signed_material`).
13//! Adding a top-level field would mean touching every signer/verifier and
14//! every existing marker-formatting call site for a feature that's
15//! otherwise fully layerable on top. Instead, this payload is JSON-encoded
16//! and carried AS the jekt `message` — the receiving side sniffs for it
17//! (`parse_transcript_request`/`parse_transcript_response`, both `None` for
18//! any ordinary free-text message, so this can never misfire on normal
19//! jekt traffic) before falling through to ordinary message handling.
20//!
21//! Shared between `agentmux-mcp` (constructs a request, sends it as an
22//! ordinary signed jekt via the existing `/agentmux/reactive/inject` path —
23//! no new transport) and `agentmux-srv` (detects an incoming request/
24//! response payload in `Handler::inject_message_inner` and routes it to the
25//! conversation-visibility auto-resolution logic instead of ordinary
26//! delivery).
27
28use serde::{Deserialize, Serialize};
29
30pub const TRANSCRIPT_REQUEST_TYPE: &str = "transcript_request";
31pub const TRANSCRIPT_RESPONSE_TYPE: &str = "transcript_response";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TranscriptRequest {
35    #[serde(rename = "type")]
36    pub msg_type: String,
37    pub request_id: String,
38    pub max_lines: usize,
39}
40
41impl TranscriptRequest {
42    pub fn new(request_id: impl Into<String>, max_lines: usize) -> Self {
43        Self {
44            msg_type: TRANSCRIPT_REQUEST_TYPE.to_string(),
45            request_id: request_id.into(),
46            max_lines,
47        }
48    }
49
50    pub fn to_message(&self) -> String {
51        serde_json::to_string(self).unwrap_or_default()
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56#[serde(tag = "status", rename_all = "lowercase")]
57pub enum TranscriptResponseStatus {
58    Ok { lines: Vec<String> },
59    Denied,
60    Error { error: String },
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct TranscriptResponse {
65    #[serde(rename = "type")]
66    pub msg_type: String,
67    pub request_id: String,
68    #[serde(flatten)]
69    pub status: TranscriptResponseStatus,
70}
71
72impl TranscriptResponse {
73    pub fn ok(request_id: impl Into<String>, lines: Vec<String>) -> Self {
74        Self {
75            msg_type: TRANSCRIPT_RESPONSE_TYPE.to_string(),
76            request_id: request_id.into(),
77            status: TranscriptResponseStatus::Ok { lines },
78        }
79    }
80
81    pub fn denied(request_id: impl Into<String>) -> Self {
82        Self {
83            msg_type: TRANSCRIPT_RESPONSE_TYPE.to_string(),
84            request_id: request_id.into(),
85            status: TranscriptResponseStatus::Denied,
86        }
87    }
88
89    pub fn error(request_id: impl Into<String>, error: impl Into<String>) -> Self {
90        Self {
91            msg_type: TRANSCRIPT_RESPONSE_TYPE.to_string(),
92            request_id: request_id.into(),
93            status: TranscriptResponseStatus::Error { error: error.into() },
94        }
95    }
96
97    pub fn to_message(&self) -> String {
98        serde_json::to_string(self).unwrap_or_default()
99    }
100}
101
102/// Sniff a jekt's raw `message` for a `transcript_request` payload. `None`
103/// for anything else — malformed JSON, valid JSON that isn't this shape, or
104/// ordinary free text — so this can never misclassify normal jekt traffic.
105/// Deliberately checks the `type` discriminant BEFORE fully deserializing
106/// (a message that happens to parse as arbitrary JSON but isn't a
107/// transcript_request must return `None`, not an error).
108pub fn parse_transcript_request(message: &str) -> Option<TranscriptRequest> {
109    let value: serde_json::Value = serde_json::from_str(message).ok()?;
110    if value.get("type")?.as_str()? != TRANSCRIPT_REQUEST_TYPE {
111        return None;
112    }
113    serde_json::from_value(value).ok()
114}
115
116/// Sniff a jekt's raw `message` for a `transcript_response` payload. Same
117/// "never misfire" contract as [`parse_transcript_request`].
118pub fn parse_transcript_response(message: &str) -> Option<TranscriptResponse> {
119    let value: serde_json::Value = serde_json::from_str(message).ok()?;
120    if value.get("type")?.as_str()? != TRANSCRIPT_RESPONSE_TYPE {
121        return None;
122    }
123    serde_json::from_value(value).ok()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn request_round_trips_through_message_encoding() {
132        let req = TranscriptRequest::new("req-1", 100);
133        let message = req.to_message();
134        let parsed = parse_transcript_request(&message).unwrap();
135        assert_eq!(parsed, req);
136    }
137
138    #[test]
139    fn ordinary_free_text_never_parses_as_a_request() {
140        assert!(parse_transcript_request("just chatting with you").is_none());
141    }
142
143    #[test]
144    fn arbitrary_json_that_isnt_this_shape_never_parses_as_a_request() {
145        assert!(parse_transcript_request(r#"{"type":"something_else","foo":"bar"}"#).is_none());
146        assert!(parse_transcript_request(r#"{"foo":"bar"}"#).is_none());
147        assert!(parse_transcript_request("[1,2,3]").is_none());
148    }
149
150    #[test]
151    fn malformed_json_never_parses_as_a_request_and_never_panics() {
152        assert!(parse_transcript_request("{not json").is_none());
153    }
154
155    #[test]
156    fn ok_response_round_trips() {
157        let resp = TranscriptResponse::ok("req-1", vec!["line 1".to_string(), "line 2".to_string()]);
158        let message = resp.to_message();
159        let parsed = parse_transcript_response(&message).unwrap();
160        assert_eq!(parsed, resp);
161    }
162
163    #[test]
164    fn denied_response_round_trips() {
165        let resp = TranscriptResponse::denied("req-1");
166        let message = resp.to_message();
167        let parsed = parse_transcript_response(&message).unwrap();
168        assert_eq!(parsed, resp);
169    }
170
171    #[test]
172    fn error_response_round_trips() {
173        let resp = TranscriptResponse::error("req-1", "something broke");
174        let message = resp.to_message();
175        let parsed = parse_transcript_response(&message).unwrap();
176        assert_eq!(parsed, resp);
177    }
178
179    #[test]
180    fn a_request_never_parses_as_a_response_and_vice_versa() {
181        let req = TranscriptRequest::new("req-1", 100);
182        assert!(parse_transcript_response(&req.to_message()).is_none());
183        let resp = TranscriptResponse::denied("req-1");
184        assert!(parse_transcript_request(&resp.to_message()).is_none());
185    }
186}