Skip to main content

bcp_types/
conversation.rs

1use crate::enums::Role;
2use crate::error::TypeError;
3use crate::fields::{
4    decode_bytes_value, decode_field_header, decode_varint_value, encode_bytes_field,
5    encode_varint_field, skip_field,
6};
7
8/// CONVERSATION block — represents a single chat turn.
9///
10/// Each turn in a conversation (system prompt, user message, assistant
11/// response, tool output) becomes one CONVERSATION block. The `role`
12/// field determines the speaker, and `content` holds the message body.
13///
14/// Field layout within body:
15///
16/// ```text
17/// ┌──────────┬───────────┬──────────────┬──────────────────────────┐
18/// │ Field ID │ Wire Type │ Name         │ Description              │
19/// ├──────────┼───────────┼──────────────┼──────────────────────────┤
20/// │ 1        │ Varint    │ role         │ Role enum byte           │
21/// │ 2        │ Bytes     │ content      │ Message body (UTF-8)     │
22/// │ 3        │ Bytes     │ tool_call_id │ Tool call ID (optional)  │
23/// └──────────┴───────────┴──────────────┴──────────────────────────┘
24/// ```
25///
26/// Field 3 is only present when `role` is `Tool`, linking the response
27/// back to the tool invocation that produced it.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ConversationBlock {
30    pub role: Role,
31    pub content: Vec<u8>,
32    /// Optional tool call ID, present only for `Role::Tool` turns.
33    pub tool_call_id: Option<String>,
34}
35
36impl ConversationBlock {
37    /// Serialize this block's fields into a TLV-encoded body.
38    pub fn encode_body(&self) -> Vec<u8> {
39        let mut buf = Vec::new();
40        encode_varint_field(&mut buf, 1, u64::from(self.role.to_wire_byte()));
41        encode_bytes_field(&mut buf, 2, &self.content);
42        if let Some(ref id) = self.tool_call_id {
43            encode_bytes_field(&mut buf, 3, id.as_bytes());
44        }
45        buf
46    }
47
48    /// Deserialize a CONVERSATION block from a TLV-encoded body.
49    pub fn decode_body(mut buf: &[u8]) -> Result<Self, TypeError> {
50        let mut role: Option<Role> = None;
51        let mut content: Option<Vec<u8>> = None;
52        let mut tool_call_id: Option<String> = None;
53
54        while !buf.is_empty() {
55            let (header, n) = decode_field_header(buf)?;
56            buf = &buf[n..];
57
58            match header.field_id {
59                1 => {
60                    let (v, n) = decode_varint_value(buf)?;
61                    buf = &buf[n..];
62                    role = Some(Role::from_wire_byte(v as u8)?);
63                }
64                2 => {
65                    let (data, n) = decode_bytes_value(buf)?;
66                    buf = &buf[n..];
67                    content = Some(data.to_vec());
68                }
69                3 => {
70                    let (data, n) = decode_bytes_value(buf)?;
71                    buf = &buf[n..];
72                    tool_call_id = Some(String::from_utf8_lossy(data).into_owned());
73                }
74                _ => {
75                    let n = skip_field(buf, header.wire_type)?;
76                    buf = &buf[n..];
77                }
78            }
79        }
80
81        Ok(Self {
82            role: role.ok_or(TypeError::MissingRequiredField { field: "role" })?,
83            content: content.ok_or(TypeError::MissingRequiredField { field: "content" })?,
84            tool_call_id,
85        })
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn roundtrip_user_message() {
95        let block = ConversationBlock {
96            role: Role::User,
97            content: b"What is Rust?".to_vec(),
98            tool_call_id: None,
99        };
100        let body = block.encode_body();
101        let decoded = ConversationBlock::decode_body(&body).unwrap();
102        assert_eq!(decoded, block);
103    }
104
105    #[test]
106    fn roundtrip_tool_with_call_id() {
107        let block = ConversationBlock {
108            role: Role::Tool,
109            content: b"{ \"result\": 42 }".to_vec(),
110            tool_call_id: Some("call_abc123".to_string()),
111        };
112        let body = block.encode_body();
113        let decoded = ConversationBlock::decode_body(&body).unwrap();
114        assert_eq!(decoded, block);
115    }
116
117    #[test]
118    fn tool_call_id_absent_when_not_tool() {
119        let block = ConversationBlock {
120            role: Role::Assistant,
121            content: b"Here's the answer.".to_vec(),
122            tool_call_id: None,
123        };
124        let body = block.encode_body();
125        let decoded = ConversationBlock::decode_body(&body).unwrap();
126        assert_eq!(decoded.tool_call_id, None);
127    }
128}