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