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    /// The paths this chat's agent may read and edit when the model is
65    /// remote: folder roots (trailing '/') and single notes. The full list
66    /// is carried per entry; absent → no change, latest wins. Absent in
67    /// every entry → nothing granted.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub scope: Option<Vec<String>>,
70    /// Config fields this client doesn't know about, preserved verbatim so a
71    /// merge performed by an older client can't strip them (mirrors
72    /// [`Message::extra`]).
73    #[serde(flatten)]
74    pub extra: Map<String, Value>,
75}
76
77/// A provider+model selection. `provider` names a provider config file
78/// (`/.agent/providers/<provider>.json`); `model` may be empty to mean the
79/// provider file's default.
80#[derive(Serialize, Deserialize, Clone, PartialEq)]
81pub struct ModelSelection {
82    pub provider: String,
83    pub model: String,
84}
85
86/// Token usage of the agent turn that produced a message. The four fields
87/// are disjoint — `input` excludes cached tokens, so total context is their
88/// sum; writers normalize wire formats that report overlapping counts.
89#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
90pub struct Usage {
91    pub input: u64,
92    pub output: u64,
93    pub cache_read: u64,
94    pub cache_write: u64,
95}
96
97#[derive(Serialize, Deserialize, Clone, PartialEq)]
98pub struct ToolRecord {
99    pub name: String,
100    pub args: Value,
101    /// The result text the model saw (possibly truncated by the writer).
102    pub result: String,
103}
104
105impl Message {
106    pub fn new(from: String, content: String, ts: i64) -> Self {
107        Self {
108            from,
109            content,
110            ts,
111            id: Some(Uuid::new_v4()),
112            reply_to: None,
113            agent: false,
114            tool: None,
115            usage: None,
116            error: false,
117            config: None,
118            extra: Map::new(),
119        }
120    }
121
122    /// A `from`-authored config entry — carries no chat content, isn't rendered,
123    /// and never enters agent context.
124    pub fn config_entry(from: String, ts: i64, config: ChatConfig) -> Self {
125        let mut m = Self::new(from, String::new(), ts);
126        m.config = Some(config);
127        m
128    }
129}
130
131pub struct Buffer {
132    pub messages: Vec<Message>,
133}
134
135impl Buffer {
136    pub fn new(bytes: &[u8]) -> Self {
137        let mut messages: Vec<Message> = std::str::from_utf8(bytes)
138            .unwrap_or_default()
139            .lines()
140            .filter_map(|line| serde_json::from_str(line).ok())
141            .collect();
142        messages.sort_by_key(|m| m.ts);
143        Self { messages }
144    }
145
146    pub fn merge(base: &[u8], local: &[u8], remote: &[u8]) -> Vec<u8> {
147        let base = Self::new(base);
148        let mut local = Self::new(local);
149        let remote = Self::new(remote);
150
151        for msg in &remote.messages {
152            if !base.messages.contains(msg) && !local.messages.contains(msg) {
153                local.messages.push(msg.clone());
154            }
155        }
156
157        local
158            .messages
159            .retain(|msg| !base.messages.contains(msg) || remote.messages.contains(msg));
160
161        local.messages.sort_by_key(|m| m.ts);
162        local.serialize()
163    }
164
165    pub fn serialize(&self) -> Vec<u8> {
166        let mut out = self
167            .messages
168            .iter()
169            .filter_map(|m| serde_json::to_string(m).ok())
170            .collect::<Vec<_>>()
171            .join("\n");
172        if !out.is_empty() {
173            out.push('\n');
174        }
175        out.into_bytes()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn line(from: &str, content: &str, ts: i64) -> String {
184        serde_json::to_string(&Message::new(from.into(), content.into(), ts)).unwrap() + "\n"
185    }
186
187    /// Concurrent appends on a shared base union to all turns, each once,
188    /// ordered by ts — the case the sync engine's `Chat` arm relies on.
189    #[test]
190    fn merge_unions_concurrent_appends() {
191        let base = line("a", "hello", 1);
192        let local = base.clone() + &line("a", "one", 2);
193        let remote = base.clone() + &line("b", "two", 3);
194
195        let merged =
196            Buffer::new(&Buffer::merge(base.as_bytes(), local.as_bytes(), remote.as_bytes()))
197                .messages;
198
199        let contents: Vec<_> = merged.iter().map(|m| m.content.as_str()).collect();
200        assert_eq!(contents, ["hello", "one", "two"]);
201    }
202
203    /// Clear vs. concurrent send: the clear erases exactly what the clearer
204    /// had seen (base-contained messages), while a message written
205    /// concurrently on the other side survives — words are never silently
206    /// destroyed. Both sync orders converge on the same result.
207    #[test]
208    fn clear_vs_concurrent_send_converges() {
209        let base = line("a", "one", 1) + &line("b", "two", 2);
210        let cleared = String::new();
211        let extended = base.clone() + &line("b", "three", 3);
212
213        // The clearer pushed first: the sender merges their send into it.
214        let sender_view = Buffer::merge(base.as_bytes(), extended.as_bytes(), cleared.as_bytes());
215        // The sender pushed first: the clearer merges the send into the clear.
216        let clearer_view = Buffer::merge(base.as_bytes(), cleared.as_bytes(), extended.as_bytes());
217
218        let contents = |bytes: &[u8]| {
219            Buffer::new(bytes)
220                .messages
221                .iter()
222                .map(|m| m.content.clone())
223                .collect::<Vec<_>>()
224        };
225        assert_eq!(contents(&sender_view), ["three"]);
226        assert_eq!(contents(&clearer_view), ["three"]);
227    }
228
229    /// A client that doesn't know a field must carry it through parse →
230    /// merge → serialize untouched, or newer clients' data gets stripped.
231    #[test]
232    fn merge_preserves_unknown_fields() {
233        let base = line("a", "hello", 1);
234        let remote = base.clone()
235            + "{\"from\":\"b\",\"content\":\"hi\",\"ts\":2,\"reactions\":\"abc\",\"agent\":true}\n";
236
237        let merged = Buffer::merge(base.as_bytes(), base.as_bytes(), remote.as_bytes());
238        let merged = String::from_utf8(merged).unwrap();
239
240        assert!(merged.contains("\"reactions\":\"abc\""), "unknown field stripped: {merged}");
241        assert!(merged.contains("\"agent\":true"));
242    }
243
244    /// Same guarantee one level down: unknown `config` fields survive an
245    /// older client's parse → merge → serialize.
246    #[test]
247    fn merge_preserves_unknown_config_fields() {
248        let base = line("a", "hello", 1);
249        let remote = base.clone()
250            + "{\"from\":\"b\",\"content\":\"\",\"ts\":2,\"config\":{\"scope\":[\"/x/\"],\"future_knob\":7}}\n";
251
252        let merged = Buffer::merge(base.as_bytes(), base.as_bytes(), remote.as_bytes());
253        let merged = String::from_utf8(merged).unwrap();
254
255        assert!(merged.contains("\"scope\":[\"/x/\"]"), "scope stripped: {merged}");
256        assert!(merged.contains("\"future_knob\":7"), "unknown config field stripped: {merged}");
257    }
258}