Skip to main content

lb_rs/model/
chat.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use uuid::Uuid;
4
5#[derive(Serialize, Deserialize, Clone, PartialEq)]
6pub struct Message {
7    pub from: String,
8    pub content: String,
9    pub ts: i64,
10    /// Unique per message. `None` on messages written before ids existed.
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub id: Option<Uuid>,
13    /// The message this one follows: its parent in the conversation tree.
14    /// Sibling messages (same parent) are alternate timelines — edits and
15    /// regenerations append siblings rather than deleting. `Uuid::nil()`
16    /// means "first message" (no parent). `None` on messages written before
17    /// threading: their implicit parent is the preceding message in ts
18    /// order, so a legacy transcript reads as one linear chain.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub reply_to: Option<Uuid>,
21    /// Sent by `from`'s agent rather than typed by them. Agent messages
22    /// never trigger agent invocation.
23    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
24    pub agent: bool,
25    /// A tool round-trip by `from`'s agent; `content` is a short human
26    /// summary ("read_file /notes/todo.md") for rendering. The record makes
27    /// the transcript the agent's complete memory: reopening a chat replays
28    /// these into model context, so a restarted agent knows everything the
29    /// live one did.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub tool: Option<ToolRecord>,
32    /// On agent replies: token usage of the turn that produced this message.
33    /// Chat-lifetime usage is the fold of these over the transcript.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub usage: Option<Usage>,
36    /// A harness-level error ("rate limited", "network down") from `from`'s
37    /// agent. Rendered as a dim red row; excluded from agent context (the
38    /// model never saw it).
39    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
40    pub error: bool,
41    /// A `from`-authored configuration entry rather than a chat message (no
42    /// `content`). Carries this user's per-chat agent settings; the latest by
43    /// `ts` wins. Excluded from rendering and from agent context.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub config: Option<ChatConfig>,
46    /// Fields this client doesn't know about, preserved verbatim so a merge
47    /// performed by an older client can't strip them.
48    #[serde(flatten)]
49    pub extra: Map<String, Value>,
50}
51
52/// Per-user, per-chat agent configuration, carried as a non-message entry in
53/// the `.chat` log. Each user's latest entry (by `ts`) is their selection for
54/// this chat; provider *credentials* stay device-local, never in the log.
55#[derive(Serialize, Deserialize, Clone, PartialEq, Default)]
56pub struct ChatConfig {
57    /// The model this user drives this chat with; absent → the global default.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub model: Option<ModelSelection>,
60    /// Reasoning effort for this chat, overriding the provider file's
61    /// default where the model supports it; absent → the file default.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub effort: Option<String>,
64}
65
66/// A provider+model selection. `provider` names a provider config file
67/// (`/.agent/providers/<provider>.json`); `model` may be empty to mean the
68/// provider file's default.
69#[derive(Serialize, Deserialize, Clone, PartialEq)]
70pub struct ModelSelection {
71    pub provider: String,
72    pub model: String,
73}
74
75/// Token usage of the agent turn that produced a message. The four fields
76/// are disjoint — `input` excludes cached tokens, so total context is their
77/// sum; writers normalize wire formats that report overlapping counts.
78#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
79pub struct Usage {
80    pub input: u64,
81    pub output: u64,
82    pub cache_read: u64,
83    pub cache_write: u64,
84}
85
86#[derive(Serialize, Deserialize, Clone, PartialEq)]
87pub struct ToolRecord {
88    pub name: String,
89    pub args: Value,
90    /// The result text the model saw (possibly truncated by the writer).
91    pub result: String,
92}
93
94impl Message {
95    pub fn new(from: String, content: String, ts: i64) -> Self {
96        Self {
97            from,
98            content,
99            ts,
100            id: Some(Uuid::new_v4()),
101            reply_to: None,
102            agent: false,
103            tool: None,
104            usage: None,
105            error: false,
106            config: None,
107            extra: Map::new(),
108        }
109    }
110
111    /// A `from`-authored config entry — carries no chat content, isn't rendered,
112    /// and never enters agent context.
113    pub fn config_entry(from: String, ts: i64, config: ChatConfig) -> Self {
114        let mut m = Self::new(from, String::new(), ts);
115        m.config = Some(config);
116        m
117    }
118}
119
120pub struct Buffer {
121    pub messages: Vec<Message>,
122}
123
124impl Buffer {
125    pub fn new(bytes: &[u8]) -> Self {
126        let mut messages: Vec<Message> = std::str::from_utf8(bytes)
127            .unwrap_or_default()
128            .lines()
129            .filter_map(|line| serde_json::from_str(line).ok())
130            .collect();
131        messages.sort_by_key(|m| m.ts);
132        Self { messages }
133    }
134
135    pub fn merge(base: &[u8], local: &[u8], remote: &[u8]) -> Vec<u8> {
136        let base = Self::new(base);
137        let mut local = Self::new(local);
138        let remote = Self::new(remote);
139
140        for msg in &remote.messages {
141            if !base.messages.contains(msg) && !local.messages.contains(msg) {
142                local.messages.push(msg.clone());
143            }
144        }
145
146        local
147            .messages
148            .retain(|msg| !base.messages.contains(msg) || remote.messages.contains(msg));
149
150        local.messages.sort_by_key(|m| m.ts);
151        local.serialize()
152    }
153
154    pub fn serialize(&self) -> Vec<u8> {
155        let mut out = self
156            .messages
157            .iter()
158            .filter_map(|m| serde_json::to_string(m).ok())
159            .collect::<Vec<_>>()
160            .join("\n");
161        if !out.is_empty() {
162            out.push('\n');
163        }
164        out.into_bytes()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn line(from: &str, content: &str, ts: i64) -> String {
173        serde_json::to_string(&Message::new(from.into(), content.into(), ts)).unwrap() + "\n"
174    }
175
176    /// Concurrent appends on a shared base union to all turns, each once,
177    /// ordered by ts — the case the sync engine's `Chat` arm relies on.
178    #[test]
179    fn merge_unions_concurrent_appends() {
180        let base = line("a", "hello", 1);
181        let local = base.clone() + &line("a", "one", 2);
182        let remote = base.clone() + &line("b", "two", 3);
183
184        let merged =
185            Buffer::new(&Buffer::merge(base.as_bytes(), local.as_bytes(), remote.as_bytes()))
186                .messages;
187
188        let contents: Vec<_> = merged.iter().map(|m| m.content.as_str()).collect();
189        assert_eq!(contents, ["hello", "one", "two"]);
190    }
191
192    /// Clear vs. concurrent send: the clear erases exactly what the clearer
193    /// had seen (base-contained messages), while a message written
194    /// concurrently on the other side survives — words are never silently
195    /// destroyed. Both sync orders converge on the same result.
196    #[test]
197    fn clear_vs_concurrent_send_converges() {
198        let base = line("a", "one", 1) + &line("b", "two", 2);
199        let cleared = String::new();
200        let extended = base.clone() + &line("b", "three", 3);
201
202        // The clearer pushed first: the sender merges their send into it.
203        let sender_view = Buffer::merge(base.as_bytes(), extended.as_bytes(), cleared.as_bytes());
204        // The sender pushed first: the clearer merges the send into the clear.
205        let clearer_view = Buffer::merge(base.as_bytes(), cleared.as_bytes(), extended.as_bytes());
206
207        let contents = |bytes: &[u8]| {
208            Buffer::new(bytes)
209                .messages
210                .iter()
211                .map(|m| m.content.clone())
212                .collect::<Vec<_>>()
213        };
214        assert_eq!(contents(&sender_view), ["three"]);
215        assert_eq!(contents(&clearer_view), ["three"]);
216    }
217
218    /// A client that doesn't know a field must carry it through parse →
219    /// merge → serialize untouched, or newer clients' data gets stripped.
220    #[test]
221    fn merge_preserves_unknown_fields() {
222        let base = line("a", "hello", 1);
223        let remote = base.clone()
224            + "{\"from\":\"b\",\"content\":\"hi\",\"ts\":2,\"reactions\":\"abc\",\"agent\":true}\n";
225
226        let merged = Buffer::merge(base.as_bytes(), base.as_bytes(), remote.as_bytes());
227        let merged = String::from_utf8(merged).unwrap();
228
229        assert!(merged.contains("\"reactions\":\"abc\""), "unknown field stripped: {merged}");
230        assert!(merged.contains("\"agent\":true"));
231    }
232}