Skip to main content

conversation_api/
talk.rs

1//! Bounded Pod presentation protocol. This is deliberately separate from Chat targets.
2//! No histories, tool argument trees, execution commands or audio bytes cross this surface.
3use crate::{ControlVersion, ConversationSurface, RunStatus};
4use serde::{Deserialize, Serialize};
5
6pub const VIEW_GET: &str = "/pod/talk/view/get";
7pub const TURN_SUBMIT: &str = "/pod/talk/turn/submit";
8pub const REQUEST_GET: &str = "/pod/talk/request/get";
9pub const REQUEST_CANCEL: &str = "/pod/talk/request/cancel";
10pub const REPLY_READ: &str = "/pod/talk/reply/read";
11pub const INTERACTION_ANSWER: &str = "/pod/talk/interaction/answer";
12pub const AUDIO_PREPARE: &str = "/pod/talk/audio/upload/prepare";
13pub const AUDIO_TRANSCRIBE: &str = "/pod/talk/audio/transcribe";
14pub const SPEECH_PREPARE: &str = "/pod/talk/speech/prepare";
15pub const MEDIA_RESOLVE: &str = "/pod/talk/media/resolve";
16pub const CHANGED: &str = "/pod/talk/changed";
17pub const REQUEST_TARGETS: &[&str] = &[
18    VIEW_GET,
19    TURN_SUBMIT,
20    REQUEST_GET,
21    REQUEST_CANCEL,
22    REPLY_READ,
23    INTERACTION_ANSWER,
24    AUDIO_PREPARE,
25    AUDIO_TRANSCRIBE,
26    SPEECH_PREPARE,
27    MEDIA_RESOLVE,
28];
29pub fn is_request_target(target: &str) -> bool {
30    REQUEST_TARGETS.contains(&target)
31}
32
33pub const FRAME_BYTES: usize = 64 * 1024;
34pub const CHANGED_BYTES: usize = 2048;
35pub const TEXT_BYTES: usize = 4096;
36pub const REPLY_BYTES: usize = 16 * 1024;
37pub const PENDING_BYTES: usize = 4096;
38pub const ID_BYTES: usize = 256;
39pub const AUDIO_BYTES: u32 = 960_044;
40pub const AUDIO_DURATION_MS: u32 = 30_000;
41
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44pub struct Context {
45    pub owner_id: String,
46    pub scope_id: String,
47    pub surface_id: ConversationSurface,
48    pub thread_id: Option<String>,
49    pub control_version: ControlVersion,
50    /// Exact decimal u64; never decoded through an IEEE-754 number by a small client.
51    pub history_version: String,
52}
53#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct Capabilities {
56    pub submit: bool,
57    pub answer: bool,
58    pub transcribe: bool,
59    pub speak: bool,
60    pub unavailable_reason: Option<String>,
61}
62#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum Phase {
65    Unknown,
66    Idle,
67    Queued,
68    Running,
69    Pending,
70    Settled,
71}
72#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct Pending {
75    pub thread_id: String,
76    pub request_id: String,
77    pub batch_id: String,
78    pub text: String,
79    pub action_count: u32,
80    pub complete: bool,
81    /// False when any omitted detail or client task prevents a safe whole-batch approval.
82    pub can_approve: bool,
83}
84#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct Reply {
87    pub thread_id: String,
88    pub request_id: String,
89    pub message_id: String,
90    /// Content identity, not a conversation/control ordering cursor.
91    pub content_revision: String,
92    /// UTF-8 byte offsets into the same immutable plain-text content.
93    pub offset: u32,
94    pub next_offset: Option<u32>,
95    pub previous_offset: Option<u32>,
96    pub text: String,
97    pub has_unsupported_parts: bool,
98}
99#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "camelCase", deny_unknown_fields)]
101pub struct View {
102    pub context: Context,
103    pub capabilities: Capabilities,
104    pub phase: Phase,
105    pub active_request_id: Option<String>,
106    pub queue_count: u32,
107    pub pending: Option<Pending>,
108    pub reply: Option<Reply>,
109    /// Latest run's authoritative terminal outcome in context.thread_id.
110    /// Present only while phase is settled; never infer success from reply text.
111    pub settlement: Option<Settlement>,
112}
113#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct Outcome {
116    pub status: RunStatus,
117    pub error_code: Option<String>,
118    pub message: Option<String>,
119}
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct Settlement {
123    pub request_id: String,
124    /// InteractionRequired is a suspension, not a terminal settlement.
125    pub outcome: Outcome,
126}
127#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct RequestResult {
130    pub context: Context,
131    pub request_id: String,
132    pub admission: Option<crate::RequestAdmissionState>,
133    pub phase: Phase,
134    pub outcome: Option<Outcome>,
135    pub pending: Option<Pending>,
136    pub reply: Option<Reply>,
137}
138#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
139#[serde(rename_all = "camelCase", deny_unknown_fields)]
140pub struct Receipt {
141    pub request_id: String,
142    pub thread_id: Option<String>,
143    pub disposition: crate::TurnSubmitDisposition,
144}
145#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct Changed {
148    pub owner_id: String,
149    pub scope_id: String,
150    pub surface_id: ConversationSurface,
151}
152#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(deny_unknown_fields)]
154pub struct ViewGet {}
155#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct TurnSubmit {
158    pub owner_id: String,
159    pub request_id: String,
160    pub text: String,
161    pub occurred_at_unix_ms: i64,
162}
163#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
164#[serde(rename_all = "camelCase", deny_unknown_fields)]
165pub struct RequestGet {
166    pub owner_id: String,
167    pub request_id: String,
168}
169#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct RequestCancel {
172    pub owner_id: String,
173    pub request_id: String,
174    #[serde(default)]
175    pub thread_id: Option<String>,
176}
177#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
178#[serde(rename_all = "camelCase", deny_unknown_fields)]
179pub struct ReplyRead {
180    pub owner_id: String,
181    pub thread_id: String,
182    pub request_id: String,
183    pub message_id: String,
184    pub content_revision: String,
185    pub offset: u32,
186}
187#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct InteractionAnswer {
190    pub owner_id: String,
191    pub thread_id: String,
192    pub request_id: String,
193    pub batch_id: String,
194    pub operation_id: String,
195    pub text: String,
196    pub occurred_at_unix_ms: i64,
197}
198#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
199#[serde(rename_all = "snake_case")]
200pub enum AnswerDisposition {
201    Accepted,
202    Clarify,
203    Resolved,
204    Expired,
205    Unsupported,
206}
207#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct AnswerReceipt {
210    pub operation_id: String,
211    pub disposition: AnswerDisposition,
212}
213
214#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "camelCase", deny_unknown_fields)]
216pub struct AudioPrepare {
217    pub owner_id: String,
218    pub operation_id: String,
219    pub size_bytes: u32,
220    pub duration_ms: u32,
221    pub sha256: String,
222}
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
224#[serde(rename_all = "camelCase", deny_unknown_fields)]
225pub struct AudioTranscribe {
226    pub owner_id: String,
227    pub operation_id: String,
228    pub uri: String,
229}
230#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(rename_all = "camelCase", deny_unknown_fields)]
232pub struct Transcript {
233    pub operation_id: String,
234    pub text: String,
235}
236#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct SpeechPrepare {
239    pub owner_id: String,
240    pub operation_id: String,
241    pub thread_id: String,
242    pub request_id: String,
243    pub message_id: String,
244    pub content_revision: String,
245}
246#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(rename_all = "camelCase", deny_unknown_fields)]
248pub struct Speech {
249    pub operation_id: String,
250    pub uri: Option<String>,
251    pub text_only_reason: Option<String>,
252}
253#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct MediaResolve {
256    pub owner_id: String,
257    pub uri: String,
258}
259/// Finite binary HTTP grants reuse the Artifact contract and its pinned host identity.
260pub type UploadGrant = artifact_api::PrepareArtifactUploadResponse;
261#[derive(Clone, Debug, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase", deny_unknown_fields)]
263pub struct ResolvedMedia {
264    pub descriptor: artifact_api::ResolveArtifactResponse,
265    pub sha256: String,
266}
267pub const SPEECH_TEXT_BYTES: usize = 4096;
268pub const SPEECH_BYTES: usize = 1_536_044;
269pub const SPEECH_DURATION_MS: u32 = 32_000;
270/// Test the actual string-payload expansion, reserving space for the MWS envelope.
271/// Implementations must still enforce the exact final encoded envelope at egress.
272pub fn encoded_payload_fits(value: &impl Serialize, limit: usize) -> bool {
273    serde_json::to_string(value)
274        .ok()
275        .and_then(|payload| serde_json::to_string(&payload).ok())
276        .is_some_and(|quoted| quoted.len().saturating_add(1024) <= limit)
277}
278
279pub fn valid_identifier(value: &str) -> bool {
280    !value.trim().is_empty() && value.len() <= ID_BYTES && !value.chars().any(char::is_control)
281}
282
283pub fn valid_text(value: &str) -> bool {
284    !value.trim().is_empty() && value.len() <= TEXT_BYTES && !value.contains('\0')
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    #[test]
291    fn talk_targets_are_separate_from_chat() {
292        for target in REQUEST_TARGETS {
293            assert!(is_request_target(target));
294            assert!(!crate::is_conversation_request_target(target));
295        }
296    }
297    #[test]
298    fn changed_and_escaped_text_fit_the_wire_budget() {
299        let changed = Changed {
300            owner_id: "core:test".into(),
301            scope_id: "space".into(),
302            surface_id: ConversationSurface::client_personal("user").unwrap(),
303        };
304        assert!(encoded_payload_fits(&changed, CHANGED_BYTES));
305        assert!(!encoded_payload_fits(
306            &"\"\\\n".repeat(REPLY_BYTES),
307            FRAME_BYTES
308        ));
309    }
310    #[test]
311    fn input_limits_are_utf8_bytes_and_reject_nul() {
312        assert!(valid_text("打开灯"));
313        assert!(!valid_text(&"猫".repeat(TEXT_BYTES / 3 + 1)));
314        assert!(!valid_text("yes\0no"));
315        assert!(!valid_identifier("request\nother"));
316    }
317}