car_sync/conversation.rs
1//! Transcript resume: the conversation surface as an ordered, role-threaded
2//! projection of the oplog (slice **B2** of
3//! `docs/proposals/multi-device-sync.md`), hardened by kernel review.
4//!
5//! # What this closes
6//!
7//! `docs/solutions/conversation-persistence-removed-in-0.25.md` records that
8//! the disk-backed `ConversationStore` island was **removed in 0.25** — dead
9//! code with a latent *compaction-vs-store incoherence* bug (compaction
10//! summarized the in-memory graph but never wrote the summaries back to the
11//! JSONL store, so a resume would have reloaded stale raw turns). That doc's
12//! forward path: "an append-only oplog as the source of truth with files/caches
13//! as projections." B2 is that path, oplog-native — a conversation is a
14//! projection of ordered turn ops, not a resurrected second store.
15//!
16//! # The model: a transcript is a projection, not a store
17//!
18//! A conversation turn is an [`crate::oplog::OpRecord`] on the
19//! [`crate::oplog::Surface::Conversation`] surface. The transcript is a *pure
20//! fold* of those ops: filter by `conversation_id`, order by the canonical
21//! `(hlc, op_id)` total order the crate already agrees on
22//! ([`crate::fold::SyncState::log_entries`]), project each payload into a typed
23//! [`Turn`]. One source of truth; no second store to drift.
24//!
25//! # Turn identity: an EVENT STREAM (op_id-keyed), not a content entity
26//!
27//! **Kernel-review correction (reversed from the first B2 cut).** A conversation
28//! turn folds as an **event-stream multiset keyed by `op_id`**
29//! ([`crate::oplog::Surface::is_event_stream`] returns `true` for
30//! `Conversation`), NOT by content. The content-keyed first cut had a
31//! reproduced **silent data-loss** bug: `stable_key` hashes the caller's
32//! *payload* — `{conversation_id, role, content, timestamp}` — so two genuine
33//! user "yes" turns stamped at the same payload timestamp (second-granularity
34//! stamps, a cached `now()`, a rapid double-confirm) collapsed to ONE entry.
35//! The earlier justification ("a repeated utterance differs in timestamp / the
36//! HLC advances") was wrong: the fold keyed on the payload timestamp, not the
37//! HLC.
38//!
39//! The right identity: **a turn has exactly one author and propagates by op
40//! replication, so op identity IS turn identity.** Keyed by `op_id`, a *resent*
41//! op dedups (retransmission), while two *distinct* authorings never collapse —
42//! even byte-identical ones. This reuses B1's multiset machinery (the same the
43//! routing observations use). Conversation differs from routing only in that
44//! its entries are **independent** (no path-dependent EMA replay), so it
45//! tolerates `LastN` retention where routing forbids any trim — see
46//! [`crate::oplog::Surface::is_replay_stream`].
47//!
48//! # Ordering across devices
49//!
50//! HLC gives causal order; two devices talking to the same agent concurrently
51//! interleave deterministically by `Hlc`'s derived `Ord`
52//! (`(wall_ms, counter, device_id)`), tie-broken on `op_id`. A turn that
53//! causally follows another (its writer `observe`d it) always sorts after it;
54//! genuinely concurrent turns fall back to the stable `device_id` tiebreak. So
55//! every device reconstructs a byte-identical transcript from any delivery
56//! order.
57//!
58//! **But determinism ≠ provider-validity** (the second kernel-review defect).
59//! Causal order says nothing about *concurrent* turns: two devices each replying
60//! to the same user turn yield `[user, assistant, assistant]` — which Anthropic
61//! 400s. So [`crate::fold::SyncState::resume_messages`] does not emit the raw
62//! transcript; it runs a **repair** that guarantees a provider-valid `Message`
63//! sequence (the "runtime validates" thesis applied to the projection): adjacent
64//! same-role turns are coalesced, an orphan `tool_result` (one not answering a
65//! preceding assistant `tool_call` — e.g. a `LastN` window that cut inside a
66//! tool exchange) is dropped, and a dangling assistant `tool_call` with no
67//! following `tool_result` has its calls stripped. See [`repair`].
68//!
69//! # Compaction coherence (why the 0.25 bug cannot return)
70//!
71//! Conversation retention is B4's `RetentionRule::LastN` over the folded
72//! snapshot; the checkpoint keeps the last N turns by `timestamp` and older raw
73//! turns drop from the read model. The 0.25 incoherence was structural — *two*
74//! stores on two write paths. B2 has **one** source of truth (the oplog) and the
75//! transcript is a projection of the *same* folded state B4's checkpoint
76//! serializes. `apply_retention` over the compacted device's state equals
77//! `apply_retention` over a fresh full fold (byte- and hash-identical). The
78//! **semantic** summarization of aged-out turns lives in memgine
79//! (`ConversationSummary` nodes, a B6 concern); B2 supplies the ordered raw
80//! turns it summarizes and the last-N window, nothing lossy.
81//!
82//! # The resume bridge
83//!
84//! [`Turn::to_message`] builds each turn as the **real**
85//! [`car_inference_types::Message`] (`user` / `assistant {content, tool_calls}`
86//! / `tool_result {tool_use_id, content}`), and `resume_messages` returns the
87//! repaired `Vec<Message>` car-inference's multi-turn path replays. Because
88//! car-sync depends on the shared `car-inference-types` crate — not a hand-copied
89//! mirror — a change to `Message`'s shape is a **compile error** here, not a
90//! runtime `from_value::<Message>` break in the daemon. The daemon/memgine
91//! adoption (feeding these into the engine's multi-turn path) is B6.
92
93use crate::fold::{FoldedRecord, SyncState};
94use crate::oplog::Hlc;
95use car_inference_types::{Message, Provenance, ToolCall};
96use serde::{Deserialize, Serialize};
97use serde_json::{json, Value};
98
99/// The conversation a turn belongs to when its payload carries no explicit
100/// `conversation_id` (e.g. the legacy `{speaker, text, timestamp}` turns the
101/// B4 tests emit). Such turns fold into one unnamed default transcript.
102pub const DEFAULT_CONVERSATION: &str = "";
103
104/// A conversation turn's role. Serializes snake_case (`user`/`assistant`/
105/// `tool`); [`Turn::to_message`] maps `Tool` onto the `Message::ToolResult`
106/// role.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum Role {
110 /// A user turn (`Message::User`).
111 User,
112 /// An assistant turn, possibly with `tool_calls` (`Message::Assistant`).
113 Assistant,
114 /// A tool-result turn (`Message::ToolResult`).
115 Tool,
116}
117
118impl Role {
119 /// Parse the `role`/`speaker` string a Conversation payload carries.
120 /// Tolerant of the legacy `speaker` values and of `tool_result` (the
121 /// Message role name) as well as `tool`.
122 fn parse(s: &str) -> Role {
123 match s {
124 "assistant" => Role::Assistant,
125 "tool" | "tool_result" => Role::Tool,
126 _ => Role::User,
127 }
128 }
129}
130
131/// One folded, ordered transcript turn — the typed projection of a
132/// [`crate::oplog::Surface::Conversation`] op.
133///
134/// `hlc`/`op_id` are provenance/ordering only (not part of the replayed
135/// `Message`): they record where the turn sits in the causal order and which
136/// op produced it (op identity IS turn identity — see the module docs).
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct Turn {
139 /// Which conversation this turn threads into ([`DEFAULT_CONVERSATION`] when
140 /// the payload names none).
141 pub conversation_id: String,
142 /// user / assistant / tool.
143 pub role: Role,
144 /// The turn text (`content`, falling back to the legacy `text` field).
145 pub content: String,
146 /// Assistant tool calls — the REAL [`car_inference_types::ToolCall`] type,
147 /// parsed from the payload. Empty for non-assistant turns or an assistant
148 /// turn that called no tool.
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub tool_calls: Vec<ToolCall>,
151 /// For a `Tool` turn: the id of the tool call this result answers
152 /// (`Message::ToolResult.tool_use_id`).
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub tool_use_id: Option<String>,
155 /// For a `Tool` turn: whether the result came from outside the trust
156 /// boundary (car#723).
157 ///
158 /// Carried through the oplog on purpose. A marking that survives the live
159 /// turn but not a resume is worse than none — the same fetched bytes would
160 /// come back as trusted after a restart, and the one path where nobody is
161 /// watching is exactly where that matters. `#[serde(default)]` reads turns
162 /// recorded before this field as `Internal`, which is how they were treated.
163 #[serde(default, skip_serializing_if = "Provenance::is_internal")]
164 pub provenance: Provenance,
165 /// Turn timestamp (ms) — the recency key B4's `LastN` conversation
166 /// retention orders on.
167 pub timestamp: u64,
168 /// The causal-order stamp this turn folded with (ordering/provenance).
169 pub hlc: Hlc,
170 /// The op that produced this turn (provenance; the fold identity).
171 pub op_id: String,
172}
173
174impl Turn {
175 /// Build the Conversation op **payload** for a user turn — append it via
176 /// `DeviceLog::append(scope, Surface::Conversation, payload)`.
177 pub fn user_payload(conversation_id: &str, content: &str, timestamp: u64) -> Value {
178 json!({
179 "conversation_id": conversation_id,
180 "role": "user",
181 "content": content,
182 "timestamp": timestamp,
183 })
184 }
185
186 /// Build the Conversation op payload for an assistant turn. `tool_calls`
187 /// are raw `ToolCall` JSON values (`{id?, name, arguments}` — how a model
188 /// or the daemon already holds them); [`Turn::from_record`] parses them
189 /// into the typed [`ToolCall`]. Empty for a plain text reply.
190 pub fn assistant_payload(
191 conversation_id: &str,
192 content: &str,
193 tool_calls: Vec<Value>,
194 timestamp: u64,
195 ) -> Value {
196 json!({
197 "conversation_id": conversation_id,
198 "role": "assistant",
199 "content": content,
200 "tool_calls": tool_calls,
201 "timestamp": timestamp,
202 })
203 }
204
205 /// Build the Conversation op payload for a tool-result turn.
206 pub fn tool_payload(
207 conversation_id: &str,
208 tool_use_id: &str,
209 content: &str,
210 timestamp: u64,
211 ) -> Value {
212 Self::tool_payload_with_provenance(
213 conversation_id,
214 tool_use_id,
215 content,
216 timestamp,
217 Provenance::Internal,
218 )
219 }
220
221 /// [`Turn::tool_payload`] for a result whose bytes came from outside the
222 /// trust boundary. Separate constructor rather than a defaulted argument so
223 /// the caller has to say which it is; `tool_payload` keeps the common case
224 /// short and every existing call site correct.
225 pub fn tool_payload_with_provenance(
226 conversation_id: &str,
227 tool_use_id: &str,
228 content: &str,
229 timestamp: u64,
230 provenance: Provenance,
231 ) -> Value {
232 let mut v = json!({
233 "conversation_id": conversation_id,
234 "role": "tool",
235 "tool_use_id": tool_use_id,
236 "content": content,
237 "timestamp": timestamp,
238 });
239 if provenance.is_external() {
240 v["provenance"] = json!("external");
241 }
242 v
243 }
244
245 /// Project a folded conversation record into a typed [`Turn`]. Returns
246 /// `None` for a tombstone stub (a retention-dropped entity's referential
247 /// placeholder — never a real turn).
248 ///
249 /// Tolerant of both the rich B2 payload (`role`/`content`/`tool_calls`) and
250 /// the legacy `(speaker, text, timestamp)` shape the B4 tests use, so one
251 /// transcript view serves every conversation op ever written.
252 pub fn from_record(record: &FoldedRecord) -> Option<Turn> {
253 if crate::compact::is_tombstone(record) {
254 return None;
255 }
256 let p = &record.payload;
257 let role = p
258 .get("role")
259 .or_else(|| p.get("speaker"))
260 .and_then(Value::as_str)
261 .map(Role::parse)
262 .unwrap_or(Role::User);
263 let content = p
264 .get("content")
265 .or_else(|| p.get("text"))
266 .and_then(Value::as_str)
267 .unwrap_or("")
268 .to_string();
269 let conversation_id = p
270 .get("conversation_id")
271 .and_then(Value::as_str)
272 .unwrap_or(DEFAULT_CONVERSATION)
273 .to_string();
274 // Parse the tool_calls array into the REAL ToolCall type (best-effort:
275 // a malformed entry is dropped rather than poisoning the whole turn).
276 let tool_calls = p
277 .get("tool_calls")
278 .and_then(Value::as_array)
279 .map(|arr| {
280 arr.iter()
281 .filter_map(|v| serde_json::from_value::<ToolCall>(v.clone()).ok())
282 .collect()
283 })
284 .unwrap_or_default();
285 let tool_use_id = p
286 .get("tool_use_id")
287 .and_then(Value::as_str)
288 .map(str::to_string);
289 // Unknown/absent → Internal. An unrecognised value is treated as
290 // Internal rather than rejected: a fold that errors on an unfamiliar
291 // string would make a newer peer's op poison an older peer's replay,
292 // and the CRDT fold has to stay total.
293 let provenance = match p.get("provenance").and_then(|v| v.as_str()) {
294 Some("external") => Provenance::External,
295 _ => Provenance::Internal,
296 };
297 let timestamp = p
298 .get("timestamp")
299 .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
300 .unwrap_or(0);
301 Some(Turn {
302 conversation_id,
303 role,
304 content,
305 tool_calls,
306 tool_use_id,
307 provenance,
308 timestamp,
309 hlc: record.hlc.clone(),
310 op_id: record.op_id.clone(),
311 })
312 }
313
314 /// Build the REAL [`car_inference_types::Message`] for this turn (typed —
315 /// a shape change to `Message` is a compile error here). The mapping:
316 ///
317 /// - [`Role::User`] → `Message::User`
318 /// - [`Role::Assistant`] → `Message::Assistant { content, tool_calls }`
319 /// - [`Role::Tool`] → `Message::ToolResult { tool_use_id, content, provenance }`
320 pub fn to_message(&self) -> Message {
321 match self.role {
322 Role::User => Message::User {
323 content: self.content.clone(),
324 },
325 Role::Assistant => Message::Assistant {
326 content: self.content.clone(),
327 tool_calls: self.tool_calls.clone(),
328 // Cross-session thinking-block resume is a follow-up: the oplog
329 // Turn does not (yet) record thinking, so resumed turns replay
330 // without it. Empty keeps the wire valid (Anthropic drops absent
331 // thinking silently on a fresh session's first turn).
332 thinking: Vec::new(),
333 model_id: None,
334 local_last_resort: false,
335 },
336 Role::Tool => Message::ToolResult {
337 tool_use_id: self.tool_use_id.clone().unwrap_or_default(),
338 content: self.content.clone(),
339 provenance: self.provenance,
340 },
341 }
342 }
343}
344
345/// Join two turn bodies for a coalesced turn (skip an empty side).
346fn join_content(a: &str, b: &str) -> String {
347 match (a.is_empty(), b.is_empty()) {
348 (true, _) => b.to_string(),
349 (_, true) => a.to_string(),
350 _ => format!("{a}\n\n{b}"),
351 }
352}
353
354/// Repair an ordered transcript into a **provider-valid** `Message` sequence.
355///
356/// The raw transcript is causally ordered but not provider-valid: concurrent
357/// turns can produce invalid adjacencies (`[user, assistant, assistant]` →
358/// Anthropic 400), and a `LastN` window can cut inside a tool exchange leaving
359/// an orphan `tool_result` at the head (also a 400). This is the runtime
360/// validating its own projection. Guarantees on the output:
361///
362/// 1. **No adjacent same-role messages** — consecutive `User`s (or `Assistant`s)
363/// are coalesced into one, their bodies joined and (for assistants) their
364/// tool_calls concatenated. Two concurrent replies to one user turn merge;
365/// an assistant "threaded after" an unrelated user turn it never observed is
366/// merged rather than mis-attributed (best-effort — the deterministic order
367/// is preserved, the invalid adjacency is not emitted).
368/// 2. **No orphan `tool_result`** — a `Tool` turn is kept only if it answers a
369/// preceding assistant that carried `tool_calls` (or a run of such results);
370/// otherwise it is dropped (the `LastN`-cut-mid-exchange case).
371/// 3. **No dangling assistant `tool_call`** — an assistant whose `tool_calls`
372/// are not answered by a following `tool_result` has its calls stripped
373/// (kept as a plain text turn), so the sequence never presents an
374/// unanswered tool_use.
375///
376/// Leading-role normalization (ensuring the sequence *starts* with a user turn,
377/// a per-provider requirement) is the caller/protocol-handler's concern — it
378/// already folds the system prompt and prepends the next user message; `repair`
379/// only guarantees internal adjacency + tool-pairing validity.
380pub fn repair(turns: Vec<Turn>) -> Vec<Message> {
381 let mut out: Vec<Turn> = Vec::new();
382 // Does the last emitted turn leave a tool exchange open (an assistant with
383 // tool_calls, or a tool_result continuing one)? Only then is a tool_result
384 // valid.
385 let mut tool_open = false;
386 for t in turns {
387 match t.role {
388 Role::Tool => {
389 if tool_open {
390 out.push(t); // a valid result; the exchange stays open
391 }
392 // else: orphan tool_result → dropped
393 }
394 Role::User => {
395 if let Some(last) = out.last_mut() {
396 if last.role == Role::User {
397 last.content = join_content(&last.content, &t.content);
398 continue;
399 }
400 }
401 tool_open = false;
402 out.push(t);
403 }
404 Role::Assistant => {
405 if let Some(last) = out.last_mut() {
406 if last.role == Role::Assistant {
407 last.content = join_content(&last.content, &t.content);
408 last.tool_calls.extend(t.tool_calls);
409 tool_open = !last.tool_calls.is_empty();
410 continue;
411 }
412 }
413 tool_open = !t.tool_calls.is_empty();
414 out.push(t);
415 }
416 }
417 }
418 // Post-pass: strip a dangling assistant tool_call (no following tool_result),
419 // so no unanswered tool_use is ever presented to a provider.
420 for i in 0..out.len() {
421 if out[i].role == Role::Assistant && !out[i].tool_calls.is_empty() {
422 let answered = out.get(i + 1).is_some_and(|n| n.role == Role::Tool);
423 if !answered {
424 out[i].tool_calls.clear();
425 }
426 }
427 }
428 out.iter().map(Turn::to_message).collect()
429}
430
431impl SyncState {
432 /// The ordered, role-threaded transcript for one conversation — the
433 /// causally `(hlc, op_id)`-ordered `Vec<Turn>` (B2). This is the raw
434 /// projection (every folded turn, in order); [`SyncState::resume_messages`]
435 /// is the provider-valid `Message` view. Turns whose payload names no
436 /// `conversation_id` belong to [`DEFAULT_CONVERSATION`]; tombstone stubs are
437 /// skipped. Order-independent of delivery, byte-identical on every device
438 /// that folded the same op-set.
439 pub fn transcript(&self, conversation_id: &str) -> Vec<Turn> {
440 self.log_entries(&crate::oplog::Surface::Conversation.tag())
441 .into_iter()
442 .filter_map(Turn::from_record)
443 .filter(|t| t.conversation_id == conversation_id)
444 .collect()
445 }
446
447 /// Every conversation id present in the folded state, in stable sorted
448 /// order (includes [`DEFAULT_CONVERSATION`] when unnamed turns exist).
449 pub fn conversation_ids(&self) -> Vec<String> {
450 let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
451 for rec in self.log_entries(&crate::oplog::Surface::Conversation.tag()) {
452 if let Some(turn) = Turn::from_record(rec) {
453 ids.insert(turn.conversation_id);
454 }
455 }
456 ids.into_iter().collect()
457 }
458
459 /// Reconstruct the runtime's multi-turn conversation state: the ordered,
460 /// **provider-valid** [`car_inference_types::Message`] sequence
461 /// car-inference's multi-turn path replays to continue the conversation
462 /// (B2's resume bridge). The raw transcript is [`repair`]ed first, so the
463 /// result never contains an invalid role adjacency or an orphan/dangling
464 /// tool exchange. The daemon/memgine adoption is B6.
465 pub fn resume_messages(&self, conversation_id: &str) -> Vec<Message> {
466 repair(self.transcript(conversation_id))
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::compact::{
474 apply_retention, plan_compaction, AckTable, RetentionPolicy, RetentionRule,
475 };
476 use crate::fold::{fold, fold_onto, state_hash};
477 use crate::oplog::{DeviceLog, Scope, Surface};
478
479 /// A tool result fetched from the open internet must still be marked as
480 /// such after a restart. If provenance survived the live turn but not the
481 /// oplog, the same bytes would come back trusted on resume — and resume is
482 /// exactly the path where nobody is watching.
483 #[test]
484 fn external_provenance_survives_the_oplog_round_trip() {
485 let mut d = DeviceLog::new("dev-a");
486 let ops = vec![
487 d.append(
488 Scope::Personal,
489 Surface::Conversation,
490 Turn::user_payload("c1", "what does that page say?", 10),
491 ),
492 d.append(
493 Scope::Personal,
494 Surface::Conversation,
495 Turn::assistant_payload(
496 "c1",
497 "",
498 vec![serde_json::json!({"id": "call_0", "name": "web_search",
499 "arguments": {"q": "x"}})],
500 11,
501 ),
502 ),
503 d.append(
504 Scope::Personal,
505 Surface::Conversation,
506 Turn::tool_payload_with_provenance(
507 "c1",
508 "call_0",
509 "fetched page text",
510 12,
511 Provenance::External,
512 ),
513 ),
514 ];
515 let messages: Vec<Message> = fold(&ops).resume_messages("c1");
516 let tool = messages
517 .iter()
518 .find(|m| matches!(m, Message::ToolResult { .. }))
519 .expect("the tool result must survive resume");
520 match tool {
521 Message::ToolResult {
522 content,
523 provenance,
524 ..
525 } => {
526 assert_eq!(content, "fetched page text");
527 assert_eq!(
528 *provenance,
529 Provenance::External,
530 "resume downgraded external content to trusted"
531 );
532 }
533 other => panic!("expected ToolResult, got {other:?}"),
534 }
535 }
536
537 #[test]
538 fn plain_tool_payload_is_internal_and_omits_the_field() {
539 let payload = Turn::tool_payload("c1", "call_0", "exit 0", 10);
540 assert!(
541 payload.get("provenance").is_none(),
542 "internal must add no bytes: {payload}"
543 );
544 let external =
545 Turn::tool_payload_with_provenance("c1", "call_0", "x", 10, Provenance::External);
546 assert_eq!(external["provenance"], "external");
547 }
548
549 #[test]
550 fn unknown_provenance_value_folds_as_internal_rather_than_failing() {
551 // The CRDT fold has to stay total: a newer peer writing a value this
552 // build does not know must not poison an older peer's replay.
553 let mut d = DeviceLog::new("dev-a");
554 let mut payload = Turn::tool_payload("c1", "call_0", "x", 10);
555 payload["provenance"] = serde_json::json!("from-the-future");
556 let ops = vec![d.append(Scope::Personal, Surface::Conversation, payload)];
557 let turns = fold(&ops).transcript("c1");
558 assert_eq!(turns.len(), 1);
559 assert_eq!(turns[0].provenance, Provenance::Internal);
560 }
561
562 /// Assert a repaired sequence is provider-valid: no adjacent same-role
563 /// (assistant/assistant, user/user), no orphan/leading tool_result, no
564 /// dangling assistant tool_call.
565 fn assert_provider_valid(ms: &[Message]) {
566 for (i, w) in ms.windows(2).enumerate() {
567 let dup = matches!(
568 (&w[0], &w[1]),
569 (Message::Assistant { .. }, Message::Assistant { .. })
570 | (Message::User { .. }, Message::User { .. })
571 );
572 assert!(!dup, "invalid adjacency at {i}: {:?} then {:?}", w[0], w[1]);
573 }
574 for (i, m) in ms.iter().enumerate() {
575 if matches!(m, Message::ToolResult { .. }) {
576 let opens_here = i > 0
577 && (matches!(&ms[i - 1], Message::Assistant { tool_calls, .. } if !tool_calls.is_empty())
578 || matches!(&ms[i - 1], Message::ToolResult { .. }));
579 assert!(opens_here, "orphan tool_result at index {i}");
580 }
581 if let Message::Assistant { tool_calls, .. } = m {
582 if !tool_calls.is_empty() {
583 let answered = ms
584 .get(i + 1)
585 .is_some_and(|n| matches!(n, Message::ToolResult { .. }));
586 assert!(answered, "dangling assistant tool_call at index {i}");
587 }
588 }
589 }
590 }
591
592 /// Append the three turn kinds to one conversation on a single device.
593 fn one_device_conversation(conv: &str) -> Vec<crate::oplog::OpRecord> {
594 let mut d = DeviceLog::new("dev-a");
595 vec![
596 d.append(
597 Scope::Personal,
598 Surface::Conversation,
599 Turn::user_payload(conv, "what's the weather?", 10),
600 ),
601 d.append(
602 Scope::Personal,
603 Surface::Conversation,
604 Turn::assistant_payload(
605 conv,
606 "",
607 vec![
608 json!({"id": "call_0", "name": "get_weather", "arguments": {"city": "SF"}}),
609 ],
610 11,
611 ),
612 ),
613 d.append(
614 Scope::Personal,
615 Surface::Conversation,
616 Turn::tool_payload(conv, "call_0", "sunny, 72F", 12),
617 ),
618 d.append(
619 Scope::Personal,
620 Surface::Conversation,
621 Turn::assistant_payload(conv, "It's sunny and 72F in SF.", vec![], 13),
622 ),
623 ]
624 }
625
626 #[test]
627 fn transcript_folds_in_causal_order_regardless_of_delivery() {
628 let ops = one_device_conversation("c1");
629 let expected = [
630 "what's the weather?",
631 "",
632 "sunny, 72F",
633 "It's sunny and 72F in SF.",
634 ];
635 for delivery in [ops.clone(), ops.iter().rev().cloned().collect::<Vec<_>>()] {
636 let turns = fold(&delivery).transcript("c1");
637 let texts: Vec<&str> = turns.iter().map(|t| t.content.as_str()).collect();
638 assert_eq!(texts, expected);
639 assert_eq!(
640 turns.iter().map(|t| t.role).collect::<Vec<_>>(),
641 vec![Role::User, Role::Assistant, Role::Tool, Role::Assistant]
642 );
643 }
644 }
645
646 #[test]
647 fn resume_produces_a_valid_message_sequence_of_the_real_type() {
648 // The resume bridge builds the REAL Message type (compile-checked), and
649 // the sequence is provider-valid.
650 let ops = one_device_conversation("c1");
651 let messages: Vec<Message> = fold(&ops).resume_messages("c1");
652 assert_eq!(messages.len(), 4);
653 assert_provider_valid(&messages);
654
655 match &messages[0] {
656 Message::User { content } => assert_eq!(content, "what's the weather?"),
657 other => panic!("turn 0 should be a user message, got {other:?}"),
658 }
659 match &messages[1] {
660 Message::Assistant { tool_calls, .. } => {
661 assert_eq!(
662 tool_calls.len(),
663 1,
664 "assistant tool_calls round-trip as the real ToolCall"
665 );
666 assert_eq!(tool_calls[0].name, "get_weather");
667 }
668 other => panic!("turn 1 should be an assistant tool call, got {other:?}"),
669 }
670 match &messages[2] {
671 Message::ToolResult {
672 tool_use_id,
673 content,
674 provenance,
675 } => {
676 assert_eq!(tool_use_id, "call_0");
677 assert_eq!(content, "sunny, 72F");
678 assert_eq!(*provenance, Provenance::Internal);
679 }
680 other => panic!("turn 2 should be a tool_result, got {other:?}"),
681 }
682 match &messages[3] {
683 Message::Assistant {
684 content,
685 tool_calls,
686 ..
687 } => {
688 assert_eq!(content, "It's sunny and 72F in SF.");
689 assert!(tool_calls.is_empty());
690 }
691 other => panic!("turn 3 should be a plain assistant reply, got {other:?}"),
692 }
693 }
694
695 // ------------------------------------------------------------------
696 // CRIT-2 (reproduced): content-keyed identity DROPPED distinct turns. Now a
697 // conversation turn is an event stream — op identity is turn identity — so
698 // two distinct authorings never collapse; only a resent op dedups.
699 // ------------------------------------------------------------------
700 #[test]
701 fn crit2_two_genuine_same_payload_turns_do_not_collapse() {
702 // Two genuine user "yes" turns stamped at the SAME payload timestamp
703 // (second-granularity clock / rapid double-confirm). Under the old
704 // content-key fold these were ONE entry (silent data loss). As an event
705 // stream they are two distinct ops → two transcript entries.
706 let mut d = DeviceLog::new("dev-a");
707 let yes = || Turn::user_payload("c1", "yes", 5); // identical payload incl. timestamp
708 let o1 = d.append(Scope::Personal, Surface::Conversation, yes());
709 let o2 = d.append(Scope::Personal, Surface::Conversation, yes());
710 assert_ne!(
711 o1.op_id, o2.op_id,
712 "distinct ops (different seq/hlc) → distinct op_id"
713 );
714 assert_eq!(
715 fold(&[o1.clone(), o2]).transcript("c1").len(),
716 2,
717 "both genuine turns survive (CRIT-2 fixed)"
718 );
719 // A RESENT op (same op_id, retransmission) still dedups to one.
720 assert_eq!(
721 fold(&[o1.clone(), o1]).transcript("c1").len(),
722 1,
723 "resent op dedups on op_id"
724 );
725 }
726
727 #[test]
728 fn same_payload_turns_on_two_devices_are_distinct_events() {
729 // Two devices independently authoring a byte-identical turn are TWO
730 // events (distinct op_id via distinct device_id), not one.
731 let mut a = DeviceLog::new("dev-a");
732 let mut b = DeviceLog::new("dev-b");
733 let payload = Turn::user_payload("c1", "hello", 5);
734 let oa = a.append(Scope::Personal, Surface::Conversation, payload.clone());
735 let ob = b.append(Scope::Personal, Surface::Conversation, payload);
736 assert_eq!(fold(&[oa, ob]).transcript("c1").len(), 2);
737 }
738
739 // ------------------------------------------------------------------
740 // CRIT-1 (reproduced): concurrent-device replies produced an invalid
741 // [user, assistant, assistant] adjacency. resume_messages now repairs it.
742 // ------------------------------------------------------------------
743 #[test]
744 fn crit1_concurrent_assistant_replies_repair_to_a_valid_sequence() {
745 let mut a = DeviceLog::new("dev-a");
746 let mut b = DeviceLog::new("dev-b");
747 let u = a.append(
748 Scope::Personal,
749 Surface::Conversation,
750 Turn::user_payload("c1", "hi", 1),
751 );
752 b.observe(&u.hlc);
753 // Two concurrent replies to u (neither observed the other).
754 let ra = a.append(
755 Scope::Personal,
756 Surface::Conversation,
757 Turn::assistant_payload("c1", "reply A", vec![], 2),
758 );
759 let rb = b.append(
760 Scope::Personal,
761 Surface::Conversation,
762 Turn::assistant_payload("c1", "reply B", vec![], 2),
763 );
764
765 let ops = vec![u, ra, rb];
766 // Raw transcript has the invalid [user, assistant, assistant]…
767 let raw = fold(&ops).transcript("c1");
768 assert_eq!(raw.len(), 3);
769 assert_eq!(
770 (raw[1].role, raw[2].role),
771 (Role::Assistant, Role::Assistant)
772 );
773 // …but resume repairs it, order-independently over every delivery.
774 let baseline = fold(&ops).resume_messages("c1");
775 assert_eq!(baseline.len(), 2, "the two concurrent replies coalesce");
776 assert_provider_valid(&baseline);
777 assert!(matches!(&baseline[0], Message::User { .. }));
778 assert!(
779 matches!(&baseline[1], Message::Assistant { content, .. } if content.contains("reply A") && content.contains("reply B"))
780 );
781 for perm in permutations(&ops) {
782 assert_eq!(
783 fold(&perm).resume_messages("c1"),
784 baseline,
785 "repair is delivery-order-independent"
786 );
787 }
788 }
789
790 #[test]
791 fn adjacent_user_turns_coalesce_before_a_reply() {
792 // Two user turns land back-to-back (userA, then userB observing it),
793 // then a reply that observed both. Raw = [user, user, assistant] — the
794 // two users are an invalid adjacency. Repair coalesces them so the
795 // assistant follows a single merged user context, never a mis-thread.
796 let mut a = DeviceLog::new("dev-a");
797 let mut b = DeviceLog::new("dev-b");
798 let ua = a.append(
799 Scope::Personal,
800 Surface::Conversation,
801 Turn::user_payload("c1", "userA", 1),
802 );
803 b.observe(&ua.hlc);
804 let ub = b.append(
805 Scope::Personal,
806 Surface::Conversation,
807 Turn::user_payload("c1", "userB", 2),
808 );
809 a.observe(&ub.hlc);
810 let ra = a.append(
811 Scope::Personal,
812 Surface::Conversation,
813 Turn::assistant_payload("c1", "reply", vec![], 3),
814 );
815
816 let ops = vec![ua, ub, ra];
817 assert_eq!(
818 fold(&ops)
819 .transcript("c1")
820 .iter()
821 .map(|t| t.role)
822 .collect::<Vec<_>>(),
823 vec![Role::User, Role::User, Role::Assistant],
824 "raw has the invalid user/user adjacency"
825 );
826 let ms = fold(&ops).resume_messages("c1");
827 assert_eq!(ms.len(), 2, "the two users coalesce");
828 assert_provider_valid(&ms);
829 assert!(
830 matches!(&ms[0], Message::User { content } if content.contains("userA") && content.contains("userB"))
831 );
832 assert!(matches!(&ms[1], Message::Assistant { .. }));
833 }
834
835 // ------------------------------------------------------------------
836 // CRIT-3 (reproduced): a LastN window cut inside a tool exchange orphaned a
837 // leading tool_result. resume_messages drops it.
838 // ------------------------------------------------------------------
839 #[test]
840 fn crit3_lastn_orphan_tool_result_is_dropped_on_resume() {
841 // [user, assistant(tool_call), tool_result, assistant] under LastN{2}
842 // retains [tool_result, assistant] — a LEADING orphan tool_result
843 // (provider 400). (This ALSO exercises that LastN is ALLOWED on the
844 // event-stream conversation surface — the retention-guard reconciliation.)
845 let ops = one_device_conversation("c1"); // ts 10..13
846 let mut policy = RetentionPolicy::keep_all();
847 policy
848 .rules
849 .insert("conversation".to_string(), RetentionRule::LastN { n: 2 });
850 let (retained, _) = apply_retention(&fold(&ops), &policy, 1_000).unwrap();
851
852 let raw = retained.transcript("c1");
853 assert_eq!(
854 raw.iter().map(|t| t.role).collect::<Vec<_>>(),
855 vec![Role::Tool, Role::Assistant],
856 "the retained window is the orphan [tool_result, assistant]"
857 );
858
859 let ms = retained.resume_messages("c1");
860 assert!(
861 !matches!(ms.first(), Some(Message::ToolResult { .. })),
862 "leading orphan tool_result dropped"
863 );
864 assert_provider_valid(&ms);
865 assert_eq!(ms.len(), 1);
866 assert!(matches!(&ms[0], Message::Assistant { .. }));
867 }
868
869 #[test]
870 fn repair_drops_a_leading_orphan_tool_result_directly() {
871 // Unit-level: repair guarantees no leading/orphan tool_result even from
872 // a hand-built transcript that starts mid-exchange.
873 let hlc = Hlc {
874 wall_ms: 0,
875 counter: 0,
876 device_id: "d".into(),
877 };
878 let orphan = Turn {
879 conversation_id: "c".into(),
880 role: Role::Tool,
881 content: "res".into(),
882 tool_calls: vec![],
883 tool_use_id: Some("call_0".into()),
884 provenance: Provenance::Internal,
885 timestamp: 1,
886 hlc: hlc.clone(),
887 op_id: "op-x".into(),
888 };
889 let asst = Turn {
890 conversation_id: "c".into(),
891 role: Role::Assistant,
892 content: "done".into(),
893 tool_calls: vec![],
894 tool_use_id: None,
895 provenance: Provenance::Internal,
896 timestamp: 2,
897 hlc,
898 op_id: "op-y".into(),
899 };
900 let ms = repair(vec![orphan, asst]);
901 assert!(!matches!(ms.first(), Some(Message::ToolResult { .. })));
902 assert_provider_valid(&ms);
903 }
904
905 #[test]
906 fn concurrent_device_turns_interleave_deterministically_and_order_independently() {
907 let mut a = DeviceLog::new("dev-a");
908 let mut b = DeviceLog::new("dev-b");
909 let a1 = a.append(
910 Scope::Personal,
911 Surface::Conversation,
912 Turn::user_payload("c1", "from A", 1),
913 );
914 b.observe(&a1.hlc);
915 let b1 = b.append(
916 Scope::Personal,
917 Surface::Conversation,
918 Turn::assistant_payload("c1", "B replies to A", vec![], 2),
919 );
920 let a2 = a.append(
921 Scope::Personal,
922 Surface::Conversation,
923 Turn::user_payload("c1", "A concurrent", 3),
924 );
925 let b2 = b.append(
926 Scope::Personal,
927 Surface::Conversation,
928 Turn::user_payload("c1", "B concurrent", 3),
929 );
930
931 let ops = vec![a1, b1, a2, b2];
932 let baseline = fold(&ops).transcript("c1");
933 assert_eq!(baseline.len(), 4);
934 let texts: Vec<&str> = baseline.iter().map(|t| t.content.as_str()).collect();
935 let pos = |s: &str| texts.iter().position(|t| *t == s).unwrap();
936 assert_eq!(pos("from A"), 0);
937 assert!(pos("from A") < pos("B replies to A"), "causality survives");
938
939 let baseline_hash = state_hash(&fold(&ops));
940 for perm in permutations(&ops) {
941 let folded = fold(&perm);
942 assert_eq!(
943 folded.transcript("c1"),
944 baseline,
945 "transcript is delivery-order-independent"
946 );
947 assert_eq!(state_hash(&folded), baseline_hash);
948 }
949 }
950
951 #[test]
952 fn transcripts_are_partitioned_by_conversation_id() {
953 let ops = {
954 let mut v = one_device_conversation("work");
955 let mut d = DeviceLog::new("dev-b");
956 v.push(d.append(
957 Scope::Personal,
958 Surface::Conversation,
959 Turn::user_payload("home", "dinner?", 20),
960 ));
961 v
962 };
963 let state = fold(&ops);
964 assert_eq!(
965 state.conversation_ids(),
966 vec!["home".to_string(), "work".to_string()]
967 );
968 assert_eq!(state.transcript("work").len(), 4);
969 assert_eq!(state.transcript("home").len(), 1);
970 assert!(state.transcript("nonexistent").is_empty());
971 }
972
973 #[test]
974 fn legacy_speaker_text_turns_project_and_resume() {
975 let mut d = DeviceLog::new("dev-a");
976 let ops = vec![
977 d.append(
978 Scope::Personal,
979 Surface::Conversation,
980 json!({"speaker": "user", "text": "hi", "timestamp": 1}),
981 ),
982 d.append(
983 Scope::Personal,
984 Surface::Conversation,
985 json!({"speaker": "assistant", "text": "hello", "timestamp": 2}),
986 ),
987 ];
988 let state = fold(&ops);
989 let turns = state.transcript(DEFAULT_CONVERSATION);
990 assert_eq!(turns.len(), 2);
991 assert_eq!(
992 (turns[0].role, turns[1].role),
993 (Role::User, Role::Assistant)
994 );
995 let ms = state.resume_messages(DEFAULT_CONVERSATION);
996 assert_provider_valid(&ms);
997 assert!(matches!(&ms[0], Message::User { content } if content == "hi"));
998 assert!(matches!(&ms[1], Message::Assistant { content, .. } if content == "hello"));
999 }
1000
1001 #[test]
1002 fn lastn_compaction_keeps_the_last_n_in_order_and_round_trips() {
1003 // Conversation is an event stream (op_id-keyed) but INDEPENDENT, so
1004 // LastN is allowed and works. After LastN{2} the resumable transcript is
1005 // the retained snapshot window + live tail, in order — the same folded
1006 // state compaction produced (0.25 incoherence cannot recur).
1007 let mut a = DeviceLog::new("dev-a");
1008 let mut b = DeviceLog::new("dev-b");
1009 let mut ops = vec![
1010 a.append(
1011 Scope::Personal,
1012 Surface::Conversation,
1013 Turn::user_payload("c1", "t0", 100),
1014 ),
1015 a.append(
1016 Scope::Personal,
1017 Surface::Conversation,
1018 Turn::assistant_payload("c1", "t1", vec![], 101),
1019 ),
1020 a.append(
1021 Scope::Personal,
1022 Surface::Conversation,
1023 Turn::user_payload("c1", "t2", 102),
1024 ),
1025 a.append(
1026 Scope::Personal,
1027 Surface::Conversation,
1028 Turn::assistant_payload("c1", "t3", vec![], 103),
1029 ),
1030 ];
1031 let split = ops.len();
1032 for op in &ops {
1033 b.observe(&op.hlc);
1034 }
1035 ops.push(b.append(
1036 Scope::Personal,
1037 Surface::Conversation,
1038 Turn::user_payload("c1", "t4", 104),
1039 ));
1040 ops.push(b.append(
1041 Scope::Personal,
1042 Surface::Conversation,
1043 Turn::assistant_payload("c1", "t5", vec![], 105),
1044 ));
1045
1046 let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
1047 let mut acks = AckTable::new();
1048 for op in &ops {
1049 acks.ack(op.device_id.clone(), frontier.clone());
1050 }
1051 let mut policy = RetentionPolicy::keep_all();
1052 policy
1053 .rules
1054 .insert("conversation".to_string(), RetentionRule::LastN { n: 2 });
1055 let plan = plan_compaction(&ops, &acks, &policy, Some(1_000)).unwrap();
1056
1057 let ckpt = plan.checkpoint.state.transcript("c1");
1058 assert_eq!(
1059 ckpt.iter().map(|t| t.content.as_str()).collect::<Vec<_>>(),
1060 vec!["t2", "t3"]
1061 );
1062
1063 // Checkpoint round-trip: serialize+deserialize; transcript unchanged.
1064 let ckpt_json = serde_json::to_string(&plan.checkpoint.state).unwrap();
1065 let ckpt_back: SyncState = serde_json::from_str(&ckpt_json).unwrap();
1066 assert_eq!(
1067 ckpt_back.transcript("c1"),
1068 plan.checkpoint.state.transcript("c1")
1069 );
1070
1071 let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
1072 let resumed: Vec<String> = reconstructed
1073 .transcript("c1")
1074 .iter()
1075 .map(|t| t.content.clone())
1076 .collect();
1077 assert_eq!(
1078 resumed,
1079 vec!["t2", "t3", "t4", "t5"],
1080 "resume = retained window + live tail, in order"
1081 );
1082 assert_provider_valid(&reconstructed.resume_messages("c1"));
1083
1084 let (global, _) = apply_retention(&fold(&ops), &policy, 1_000).unwrap();
1085 let (local, _) = apply_retention(&reconstructed, &policy, 1_000).unwrap();
1086 assert_eq!(local.transcript("c1"), global.transcript("c1"));
1087 assert_eq!(
1088 global
1089 .transcript("c1")
1090 .iter()
1091 .map(|t| t.content.clone())
1092 .collect::<Vec<_>>(),
1093 vec!["t4", "t5"],
1094 "the last-N display window is the same on every device"
1095 );
1096 assert_eq!(state_hash(&local), state_hash(&global));
1097 }
1098
1099 /// Heap's algorithm — every permutation, no rand dependency.
1100 fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
1101 fn heap<T: Clone>(k: usize, arr: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
1102 if k == 1 {
1103 out.push(arr.clone());
1104 return;
1105 }
1106 for i in 0..k {
1107 heap(k - 1, arr, out);
1108 if k.is_multiple_of(2) {
1109 arr.swap(i, k - 1);
1110 } else {
1111 arr.swap(0, k - 1);
1112 }
1113 }
1114 }
1115 let mut arr = items.to_vec();
1116 let mut out = Vec::new();
1117 heap(arr.len(), &mut arr, &mut out);
1118 out
1119 }
1120}