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 },
334 Role::Tool => Message::ToolResult {
335 tool_use_id: self.tool_use_id.clone().unwrap_or_default(),
336 content: self.content.clone(),
337 provenance: self.provenance,
338 },
339 }
340 }
341}
342
343/// Join two turn bodies for a coalesced turn (skip an empty side).
344fn join_content(a: &str, b: &str) -> String {
345 match (a.is_empty(), b.is_empty()) {
346 (true, _) => b.to_string(),
347 (_, true) => a.to_string(),
348 _ => format!("{a}\n\n{b}"),
349 }
350}
351
352/// Repair an ordered transcript into a **provider-valid** `Message` sequence.
353///
354/// The raw transcript is causally ordered but not provider-valid: concurrent
355/// turns can produce invalid adjacencies (`[user, assistant, assistant]` →
356/// Anthropic 400), and a `LastN` window can cut inside a tool exchange leaving
357/// an orphan `tool_result` at the head (also a 400). This is the runtime
358/// validating its own projection. Guarantees on the output:
359///
360/// 1. **No adjacent same-role messages** — consecutive `User`s (or `Assistant`s)
361/// are coalesced into one, their bodies joined and (for assistants) their
362/// tool_calls concatenated. Two concurrent replies to one user turn merge;
363/// an assistant "threaded after" an unrelated user turn it never observed is
364/// merged rather than mis-attributed (best-effort — the deterministic order
365/// is preserved, the invalid adjacency is not emitted).
366/// 2. **No orphan `tool_result`** — a `Tool` turn is kept only if it answers a
367/// preceding assistant that carried `tool_calls` (or a run of such results);
368/// otherwise it is dropped (the `LastN`-cut-mid-exchange case).
369/// 3. **No dangling assistant `tool_call`** — an assistant whose `tool_calls`
370/// are not answered by a following `tool_result` has its calls stripped
371/// (kept as a plain text turn), so the sequence never presents an
372/// unanswered tool_use.
373///
374/// Leading-role normalization (ensuring the sequence *starts* with a user turn,
375/// a per-provider requirement) is the caller/protocol-handler's concern — it
376/// already folds the system prompt and prepends the next user message; `repair`
377/// only guarantees internal adjacency + tool-pairing validity.
378pub fn repair(turns: Vec<Turn>) -> Vec<Message> {
379 let mut out: Vec<Turn> = Vec::new();
380 // Does the last emitted turn leave a tool exchange open (an assistant with
381 // tool_calls, or a tool_result continuing one)? Only then is a tool_result
382 // valid.
383 let mut tool_open = false;
384 for t in turns {
385 match t.role {
386 Role::Tool => {
387 if tool_open {
388 out.push(t); // a valid result; the exchange stays open
389 }
390 // else: orphan tool_result → dropped
391 }
392 Role::User => {
393 if let Some(last) = out.last_mut() {
394 if last.role == Role::User {
395 last.content = join_content(&last.content, &t.content);
396 continue;
397 }
398 }
399 tool_open = false;
400 out.push(t);
401 }
402 Role::Assistant => {
403 if let Some(last) = out.last_mut() {
404 if last.role == Role::Assistant {
405 last.content = join_content(&last.content, &t.content);
406 last.tool_calls.extend(t.tool_calls);
407 tool_open = !last.tool_calls.is_empty();
408 continue;
409 }
410 }
411 tool_open = !t.tool_calls.is_empty();
412 out.push(t);
413 }
414 }
415 }
416 // Post-pass: strip a dangling assistant tool_call (no following tool_result),
417 // so no unanswered tool_use is ever presented to a provider.
418 for i in 0..out.len() {
419 if out[i].role == Role::Assistant && !out[i].tool_calls.is_empty() {
420 let answered = out.get(i + 1).is_some_and(|n| n.role == Role::Tool);
421 if !answered {
422 out[i].tool_calls.clear();
423 }
424 }
425 }
426 out.iter().map(Turn::to_message).collect()
427}
428
429impl SyncState {
430 /// The ordered, role-threaded transcript for one conversation — the
431 /// causally `(hlc, op_id)`-ordered `Vec<Turn>` (B2). This is the raw
432 /// projection (every folded turn, in order); [`SyncState::resume_messages`]
433 /// is the provider-valid `Message` view. Turns whose payload names no
434 /// `conversation_id` belong to [`DEFAULT_CONVERSATION`]; tombstone stubs are
435 /// skipped. Order-independent of delivery, byte-identical on every device
436 /// that folded the same op-set.
437 pub fn transcript(&self, conversation_id: &str) -> Vec<Turn> {
438 self.log_entries(&crate::oplog::Surface::Conversation.tag())
439 .into_iter()
440 .filter_map(Turn::from_record)
441 .filter(|t| t.conversation_id == conversation_id)
442 .collect()
443 }
444
445 /// Every conversation id present in the folded state, in stable sorted
446 /// order (includes [`DEFAULT_CONVERSATION`] when unnamed turns exist).
447 pub fn conversation_ids(&self) -> Vec<String> {
448 let mut ids: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
449 for rec in self.log_entries(&crate::oplog::Surface::Conversation.tag()) {
450 if let Some(turn) = Turn::from_record(rec) {
451 ids.insert(turn.conversation_id);
452 }
453 }
454 ids.into_iter().collect()
455 }
456
457 /// Reconstruct the runtime's multi-turn conversation state: the ordered,
458 /// **provider-valid** [`car_inference_types::Message`] sequence
459 /// car-inference's multi-turn path replays to continue the conversation
460 /// (B2's resume bridge). The raw transcript is [`repair`]ed first, so the
461 /// result never contains an invalid role adjacency or an orphan/dangling
462 /// tool exchange. The daemon/memgine adoption is B6.
463 pub fn resume_messages(&self, conversation_id: &str) -> Vec<Message> {
464 repair(self.transcript(conversation_id))
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::compact::{
472 apply_retention, plan_compaction, AckTable, RetentionPolicy, RetentionRule,
473 };
474 use crate::fold::{fold, fold_onto, state_hash};
475 use crate::oplog::{DeviceLog, Scope, Surface};
476
477 /// A tool result fetched from the open internet must still be marked as
478 /// such after a restart. If provenance survived the live turn but not the
479 /// oplog, the same bytes would come back trusted on resume — and resume is
480 /// exactly the path where nobody is watching.
481 #[test]
482 fn external_provenance_survives_the_oplog_round_trip() {
483 let mut d = DeviceLog::new("dev-a");
484 let ops = vec![
485 d.append(
486 Scope::Personal,
487 Surface::Conversation,
488 Turn::user_payload("c1", "what does that page say?", 10),
489 ),
490 d.append(
491 Scope::Personal,
492 Surface::Conversation,
493 Turn::assistant_payload(
494 "c1",
495 "",
496 vec![serde_json::json!({"id": "call_0", "name": "web_search",
497 "arguments": {"q": "x"}})],
498 11,
499 ),
500 ),
501 d.append(
502 Scope::Personal,
503 Surface::Conversation,
504 Turn::tool_payload_with_provenance(
505 "c1",
506 "call_0",
507 "fetched page text",
508 12,
509 Provenance::External,
510 ),
511 ),
512 ];
513 let messages: Vec<Message> = fold(&ops).resume_messages("c1");
514 let tool = messages
515 .iter()
516 .find(|m| matches!(m, Message::ToolResult { .. }))
517 .expect("the tool result must survive resume");
518 match tool {
519 Message::ToolResult {
520 content,
521 provenance,
522 ..
523 } => {
524 assert_eq!(content, "fetched page text");
525 assert_eq!(
526 *provenance,
527 Provenance::External,
528 "resume downgraded external content to trusted"
529 );
530 }
531 other => panic!("expected ToolResult, got {other:?}"),
532 }
533 }
534
535 #[test]
536 fn plain_tool_payload_is_internal_and_omits_the_field() {
537 let payload = Turn::tool_payload("c1", "call_0", "exit 0", 10);
538 assert!(
539 payload.get("provenance").is_none(),
540 "internal must add no bytes: {payload}"
541 );
542 let external =
543 Turn::tool_payload_with_provenance("c1", "call_0", "x", 10, Provenance::External);
544 assert_eq!(external["provenance"], "external");
545 }
546
547 #[test]
548 fn unknown_provenance_value_folds_as_internal_rather_than_failing() {
549 // The CRDT fold has to stay total: a newer peer writing a value this
550 // build does not know must not poison an older peer's replay.
551 let mut d = DeviceLog::new("dev-a");
552 let mut payload = Turn::tool_payload("c1", "call_0", "x", 10);
553 payload["provenance"] = serde_json::json!("from-the-future");
554 let ops = vec![d.append(Scope::Personal, Surface::Conversation, payload)];
555 let turns = fold(&ops).transcript("c1");
556 assert_eq!(turns.len(), 1);
557 assert_eq!(turns[0].provenance, Provenance::Internal);
558 }
559
560 /// Assert a repaired sequence is provider-valid: no adjacent same-role
561 /// (assistant/assistant, user/user), no orphan/leading tool_result, no
562 /// dangling assistant tool_call.
563 fn assert_provider_valid(ms: &[Message]) {
564 for (i, w) in ms.windows(2).enumerate() {
565 let dup = matches!(
566 (&w[0], &w[1]),
567 (Message::Assistant { .. }, Message::Assistant { .. })
568 | (Message::User { .. }, Message::User { .. })
569 );
570 assert!(!dup, "invalid adjacency at {i}: {:?} then {:?}", w[0], w[1]);
571 }
572 for (i, m) in ms.iter().enumerate() {
573 if matches!(m, Message::ToolResult { .. }) {
574 let opens_here = i > 0
575 && (matches!(&ms[i - 1], Message::Assistant { tool_calls, .. } if !tool_calls.is_empty())
576 || matches!(&ms[i - 1], Message::ToolResult { .. }));
577 assert!(opens_here, "orphan tool_result at index {i}");
578 }
579 if let Message::Assistant { tool_calls, .. } = m {
580 if !tool_calls.is_empty() {
581 let answered = ms
582 .get(i + 1)
583 .is_some_and(|n| matches!(n, Message::ToolResult { .. }));
584 assert!(answered, "dangling assistant tool_call at index {i}");
585 }
586 }
587 }
588 }
589
590 /// Append the three turn kinds to one conversation on a single device.
591 fn one_device_conversation(conv: &str) -> Vec<crate::oplog::OpRecord> {
592 let mut d = DeviceLog::new("dev-a");
593 vec![
594 d.append(
595 Scope::Personal,
596 Surface::Conversation,
597 Turn::user_payload(conv, "what's the weather?", 10),
598 ),
599 d.append(
600 Scope::Personal,
601 Surface::Conversation,
602 Turn::assistant_payload(
603 conv,
604 "",
605 vec![
606 json!({"id": "call_0", "name": "get_weather", "arguments": {"city": "SF"}}),
607 ],
608 11,
609 ),
610 ),
611 d.append(
612 Scope::Personal,
613 Surface::Conversation,
614 Turn::tool_payload(conv, "call_0", "sunny, 72F", 12),
615 ),
616 d.append(
617 Scope::Personal,
618 Surface::Conversation,
619 Turn::assistant_payload(conv, "It's sunny and 72F in SF.", vec![], 13),
620 ),
621 ]
622 }
623
624 #[test]
625 fn transcript_folds_in_causal_order_regardless_of_delivery() {
626 let ops = one_device_conversation("c1");
627 let expected = [
628 "what's the weather?",
629 "",
630 "sunny, 72F",
631 "It's sunny and 72F in SF.",
632 ];
633 for delivery in [ops.clone(), ops.iter().rev().cloned().collect::<Vec<_>>()] {
634 let turns = fold(&delivery).transcript("c1");
635 let texts: Vec<&str> = turns.iter().map(|t| t.content.as_str()).collect();
636 assert_eq!(texts, expected);
637 assert_eq!(
638 turns.iter().map(|t| t.role).collect::<Vec<_>>(),
639 vec![Role::User, Role::Assistant, Role::Tool, Role::Assistant]
640 );
641 }
642 }
643
644 #[test]
645 fn resume_produces_a_valid_message_sequence_of_the_real_type() {
646 // The resume bridge builds the REAL Message type (compile-checked), and
647 // the sequence is provider-valid.
648 let ops = one_device_conversation("c1");
649 let messages: Vec<Message> = fold(&ops).resume_messages("c1");
650 assert_eq!(messages.len(), 4);
651 assert_provider_valid(&messages);
652
653 match &messages[0] {
654 Message::User { content } => assert_eq!(content, "what's the weather?"),
655 other => panic!("turn 0 should be a user message, got {other:?}"),
656 }
657 match &messages[1] {
658 Message::Assistant { tool_calls, .. } => {
659 assert_eq!(
660 tool_calls.len(),
661 1,
662 "assistant tool_calls round-trip as the real ToolCall"
663 );
664 assert_eq!(tool_calls[0].name, "get_weather");
665 }
666 other => panic!("turn 1 should be an assistant tool call, got {other:?}"),
667 }
668 match &messages[2] {
669 Message::ToolResult {
670 tool_use_id,
671 content,
672 provenance,
673 } => {
674 assert_eq!(tool_use_id, "call_0");
675 assert_eq!(content, "sunny, 72F");
676 assert_eq!(*provenance, Provenance::Internal);
677 }
678 other => panic!("turn 2 should be a tool_result, got {other:?}"),
679 }
680 match &messages[3] {
681 Message::Assistant {
682 content,
683 tool_calls,
684 ..
685 } => {
686 assert_eq!(content, "It's sunny and 72F in SF.");
687 assert!(tool_calls.is_empty());
688 }
689 other => panic!("turn 3 should be a plain assistant reply, got {other:?}"),
690 }
691 }
692
693 // ------------------------------------------------------------------
694 // CRIT-2 (reproduced): content-keyed identity DROPPED distinct turns. Now a
695 // conversation turn is an event stream — op identity is turn identity — so
696 // two distinct authorings never collapse; only a resent op dedups.
697 // ------------------------------------------------------------------
698 #[test]
699 fn crit2_two_genuine_same_payload_turns_do_not_collapse() {
700 // Two genuine user "yes" turns stamped at the SAME payload timestamp
701 // (second-granularity clock / rapid double-confirm). Under the old
702 // content-key fold these were ONE entry (silent data loss). As an event
703 // stream they are two distinct ops → two transcript entries.
704 let mut d = DeviceLog::new("dev-a");
705 let yes = || Turn::user_payload("c1", "yes", 5); // identical payload incl. timestamp
706 let o1 = d.append(Scope::Personal, Surface::Conversation, yes());
707 let o2 = d.append(Scope::Personal, Surface::Conversation, yes());
708 assert_ne!(
709 o1.op_id, o2.op_id,
710 "distinct ops (different seq/hlc) → distinct op_id"
711 );
712 assert_eq!(
713 fold(&[o1.clone(), o2]).transcript("c1").len(),
714 2,
715 "both genuine turns survive (CRIT-2 fixed)"
716 );
717 // A RESENT op (same op_id, retransmission) still dedups to one.
718 assert_eq!(
719 fold(&[o1.clone(), o1]).transcript("c1").len(),
720 1,
721 "resent op dedups on op_id"
722 );
723 }
724
725 #[test]
726 fn same_payload_turns_on_two_devices_are_distinct_events() {
727 // Two devices independently authoring a byte-identical turn are TWO
728 // events (distinct op_id via distinct device_id), not one.
729 let mut a = DeviceLog::new("dev-a");
730 let mut b = DeviceLog::new("dev-b");
731 let payload = Turn::user_payload("c1", "hello", 5);
732 let oa = a.append(Scope::Personal, Surface::Conversation, payload.clone());
733 let ob = b.append(Scope::Personal, Surface::Conversation, payload);
734 assert_eq!(fold(&[oa, ob]).transcript("c1").len(), 2);
735 }
736
737 // ------------------------------------------------------------------
738 // CRIT-1 (reproduced): concurrent-device replies produced an invalid
739 // [user, assistant, assistant] adjacency. resume_messages now repairs it.
740 // ------------------------------------------------------------------
741 #[test]
742 fn crit1_concurrent_assistant_replies_repair_to_a_valid_sequence() {
743 let mut a = DeviceLog::new("dev-a");
744 let mut b = DeviceLog::new("dev-b");
745 let u = a.append(
746 Scope::Personal,
747 Surface::Conversation,
748 Turn::user_payload("c1", "hi", 1),
749 );
750 b.observe(&u.hlc);
751 // Two concurrent replies to u (neither observed the other).
752 let ra = a.append(
753 Scope::Personal,
754 Surface::Conversation,
755 Turn::assistant_payload("c1", "reply A", vec![], 2),
756 );
757 let rb = b.append(
758 Scope::Personal,
759 Surface::Conversation,
760 Turn::assistant_payload("c1", "reply B", vec![], 2),
761 );
762
763 let ops = vec![u, ra, rb];
764 // Raw transcript has the invalid [user, assistant, assistant]…
765 let raw = fold(&ops).transcript("c1");
766 assert_eq!(raw.len(), 3);
767 assert_eq!(
768 (raw[1].role, raw[2].role),
769 (Role::Assistant, Role::Assistant)
770 );
771 // …but resume repairs it, order-independently over every delivery.
772 let baseline = fold(&ops).resume_messages("c1");
773 assert_eq!(baseline.len(), 2, "the two concurrent replies coalesce");
774 assert_provider_valid(&baseline);
775 assert!(matches!(&baseline[0], Message::User { .. }));
776 assert!(
777 matches!(&baseline[1], Message::Assistant { content, .. } if content.contains("reply A") && content.contains("reply B"))
778 );
779 for perm in permutations(&ops) {
780 assert_eq!(
781 fold(&perm).resume_messages("c1"),
782 baseline,
783 "repair is delivery-order-independent"
784 );
785 }
786 }
787
788 #[test]
789 fn adjacent_user_turns_coalesce_before_a_reply() {
790 // Two user turns land back-to-back (userA, then userB observing it),
791 // then a reply that observed both. Raw = [user, user, assistant] — the
792 // two users are an invalid adjacency. Repair coalesces them so the
793 // assistant follows a single merged user context, never a mis-thread.
794 let mut a = DeviceLog::new("dev-a");
795 let mut b = DeviceLog::new("dev-b");
796 let ua = a.append(
797 Scope::Personal,
798 Surface::Conversation,
799 Turn::user_payload("c1", "userA", 1),
800 );
801 b.observe(&ua.hlc);
802 let ub = b.append(
803 Scope::Personal,
804 Surface::Conversation,
805 Turn::user_payload("c1", "userB", 2),
806 );
807 a.observe(&ub.hlc);
808 let ra = a.append(
809 Scope::Personal,
810 Surface::Conversation,
811 Turn::assistant_payload("c1", "reply", vec![], 3),
812 );
813
814 let ops = vec![ua, ub, ra];
815 assert_eq!(
816 fold(&ops)
817 .transcript("c1")
818 .iter()
819 .map(|t| t.role)
820 .collect::<Vec<_>>(),
821 vec![Role::User, Role::User, Role::Assistant],
822 "raw has the invalid user/user adjacency"
823 );
824 let ms = fold(&ops).resume_messages("c1");
825 assert_eq!(ms.len(), 2, "the two users coalesce");
826 assert_provider_valid(&ms);
827 assert!(
828 matches!(&ms[0], Message::User { content } if content.contains("userA") && content.contains("userB"))
829 );
830 assert!(matches!(&ms[1], Message::Assistant { .. }));
831 }
832
833 // ------------------------------------------------------------------
834 // CRIT-3 (reproduced): a LastN window cut inside a tool exchange orphaned a
835 // leading tool_result. resume_messages drops it.
836 // ------------------------------------------------------------------
837 #[test]
838 fn crit3_lastn_orphan_tool_result_is_dropped_on_resume() {
839 // [user, assistant(tool_call), tool_result, assistant] under LastN{2}
840 // retains [tool_result, assistant] — a LEADING orphan tool_result
841 // (provider 400). (This ALSO exercises that LastN is ALLOWED on the
842 // event-stream conversation surface — the retention-guard reconciliation.)
843 let ops = one_device_conversation("c1"); // ts 10..13
844 let mut policy = RetentionPolicy::keep_all();
845 policy
846 .rules
847 .insert("conversation".to_string(), RetentionRule::LastN { n: 2 });
848 let (retained, _) = apply_retention(&fold(&ops), &policy, 1_000).unwrap();
849
850 let raw = retained.transcript("c1");
851 assert_eq!(
852 raw.iter().map(|t| t.role).collect::<Vec<_>>(),
853 vec![Role::Tool, Role::Assistant],
854 "the retained window is the orphan [tool_result, assistant]"
855 );
856
857 let ms = retained.resume_messages("c1");
858 assert!(
859 !matches!(ms.first(), Some(Message::ToolResult { .. })),
860 "leading orphan tool_result dropped"
861 );
862 assert_provider_valid(&ms);
863 assert_eq!(ms.len(), 1);
864 assert!(matches!(&ms[0], Message::Assistant { .. }));
865 }
866
867 #[test]
868 fn repair_drops_a_leading_orphan_tool_result_directly() {
869 // Unit-level: repair guarantees no leading/orphan tool_result even from
870 // a hand-built transcript that starts mid-exchange.
871 let hlc = Hlc {
872 wall_ms: 0,
873 counter: 0,
874 device_id: "d".into(),
875 };
876 let orphan = Turn {
877 conversation_id: "c".into(),
878 role: Role::Tool,
879 content: "res".into(),
880 tool_calls: vec![],
881 tool_use_id: Some("call_0".into()),
882 provenance: Provenance::Internal,
883 timestamp: 1,
884 hlc: hlc.clone(),
885 op_id: "op-x".into(),
886 };
887 let asst = Turn {
888 conversation_id: "c".into(),
889 role: Role::Assistant,
890 content: "done".into(),
891 tool_calls: vec![],
892 tool_use_id: None,
893 provenance: Provenance::Internal,
894 timestamp: 2,
895 hlc,
896 op_id: "op-y".into(),
897 };
898 let ms = repair(vec![orphan, asst]);
899 assert!(!matches!(ms.first(), Some(Message::ToolResult { .. })));
900 assert_provider_valid(&ms);
901 }
902
903 #[test]
904 fn concurrent_device_turns_interleave_deterministically_and_order_independently() {
905 let mut a = DeviceLog::new("dev-a");
906 let mut b = DeviceLog::new("dev-b");
907 let a1 = a.append(
908 Scope::Personal,
909 Surface::Conversation,
910 Turn::user_payload("c1", "from A", 1),
911 );
912 b.observe(&a1.hlc);
913 let b1 = b.append(
914 Scope::Personal,
915 Surface::Conversation,
916 Turn::assistant_payload("c1", "B replies to A", vec![], 2),
917 );
918 let a2 = a.append(
919 Scope::Personal,
920 Surface::Conversation,
921 Turn::user_payload("c1", "A concurrent", 3),
922 );
923 let b2 = b.append(
924 Scope::Personal,
925 Surface::Conversation,
926 Turn::user_payload("c1", "B concurrent", 3),
927 );
928
929 let ops = vec![a1, b1, a2, b2];
930 let baseline = fold(&ops).transcript("c1");
931 assert_eq!(baseline.len(), 4);
932 let texts: Vec<&str> = baseline.iter().map(|t| t.content.as_str()).collect();
933 let pos = |s: &str| texts.iter().position(|t| *t == s).unwrap();
934 assert_eq!(pos("from A"), 0);
935 assert!(pos("from A") < pos("B replies to A"), "causality survives");
936
937 let baseline_hash = state_hash(&fold(&ops));
938 for perm in permutations(&ops) {
939 let folded = fold(&perm);
940 assert_eq!(
941 folded.transcript("c1"),
942 baseline,
943 "transcript is delivery-order-independent"
944 );
945 assert_eq!(state_hash(&folded), baseline_hash);
946 }
947 }
948
949 #[test]
950 fn transcripts_are_partitioned_by_conversation_id() {
951 let ops = {
952 let mut v = one_device_conversation("work");
953 let mut d = DeviceLog::new("dev-b");
954 v.push(d.append(
955 Scope::Personal,
956 Surface::Conversation,
957 Turn::user_payload("home", "dinner?", 20),
958 ));
959 v
960 };
961 let state = fold(&ops);
962 assert_eq!(
963 state.conversation_ids(),
964 vec!["home".to_string(), "work".to_string()]
965 );
966 assert_eq!(state.transcript("work").len(), 4);
967 assert_eq!(state.transcript("home").len(), 1);
968 assert!(state.transcript("nonexistent").is_empty());
969 }
970
971 #[test]
972 fn legacy_speaker_text_turns_project_and_resume() {
973 let mut d = DeviceLog::new("dev-a");
974 let ops = vec![
975 d.append(
976 Scope::Personal,
977 Surface::Conversation,
978 json!({"speaker": "user", "text": "hi", "timestamp": 1}),
979 ),
980 d.append(
981 Scope::Personal,
982 Surface::Conversation,
983 json!({"speaker": "assistant", "text": "hello", "timestamp": 2}),
984 ),
985 ];
986 let state = fold(&ops);
987 let turns = state.transcript(DEFAULT_CONVERSATION);
988 assert_eq!(turns.len(), 2);
989 assert_eq!(
990 (turns[0].role, turns[1].role),
991 (Role::User, Role::Assistant)
992 );
993 let ms = state.resume_messages(DEFAULT_CONVERSATION);
994 assert_provider_valid(&ms);
995 assert!(matches!(&ms[0], Message::User { content } if content == "hi"));
996 assert!(matches!(&ms[1], Message::Assistant { content, .. } if content == "hello"));
997 }
998
999 #[test]
1000 fn lastn_compaction_keeps_the_last_n_in_order_and_round_trips() {
1001 // Conversation is an event stream (op_id-keyed) but INDEPENDENT, so
1002 // LastN is allowed and works. After LastN{2} the resumable transcript is
1003 // the retained snapshot window + live tail, in order — the same folded
1004 // state compaction produced (0.25 incoherence cannot recur).
1005 let mut a = DeviceLog::new("dev-a");
1006 let mut b = DeviceLog::new("dev-b");
1007 let mut ops = vec![
1008 a.append(
1009 Scope::Personal,
1010 Surface::Conversation,
1011 Turn::user_payload("c1", "t0", 100),
1012 ),
1013 a.append(
1014 Scope::Personal,
1015 Surface::Conversation,
1016 Turn::assistant_payload("c1", "t1", vec![], 101),
1017 ),
1018 a.append(
1019 Scope::Personal,
1020 Surface::Conversation,
1021 Turn::user_payload("c1", "t2", 102),
1022 ),
1023 a.append(
1024 Scope::Personal,
1025 Surface::Conversation,
1026 Turn::assistant_payload("c1", "t3", vec![], 103),
1027 ),
1028 ];
1029 let split = ops.len();
1030 for op in &ops {
1031 b.observe(&op.hlc);
1032 }
1033 ops.push(b.append(
1034 Scope::Personal,
1035 Surface::Conversation,
1036 Turn::user_payload("c1", "t4", 104),
1037 ));
1038 ops.push(b.append(
1039 Scope::Personal,
1040 Surface::Conversation,
1041 Turn::assistant_payload("c1", "t5", vec![], 105),
1042 ));
1043
1044 let frontier = ops[..split].iter().map(|o| o.hlc.clone()).max().unwrap();
1045 let mut acks = AckTable::new();
1046 for op in &ops {
1047 acks.ack(op.device_id.clone(), frontier.clone());
1048 }
1049 let mut policy = RetentionPolicy::keep_all();
1050 policy
1051 .rules
1052 .insert("conversation".to_string(), RetentionRule::LastN { n: 2 });
1053 let plan = plan_compaction(&ops, &acks, &policy, Some(1_000)).unwrap();
1054
1055 let ckpt = plan.checkpoint.state.transcript("c1");
1056 assert_eq!(
1057 ckpt.iter().map(|t| t.content.as_str()).collect::<Vec<_>>(),
1058 vec!["t2", "t3"]
1059 );
1060
1061 // Checkpoint round-trip: serialize+deserialize; transcript unchanged.
1062 let ckpt_json = serde_json::to_string(&plan.checkpoint.state).unwrap();
1063 let ckpt_back: SyncState = serde_json::from_str(&ckpt_json).unwrap();
1064 assert_eq!(
1065 ckpt_back.transcript("c1"),
1066 plan.checkpoint.state.transcript("c1")
1067 );
1068
1069 let reconstructed = fold_onto(&plan.checkpoint.state, &plan.retained_ops);
1070 let resumed: Vec<String> = reconstructed
1071 .transcript("c1")
1072 .iter()
1073 .map(|t| t.content.clone())
1074 .collect();
1075 assert_eq!(
1076 resumed,
1077 vec!["t2", "t3", "t4", "t5"],
1078 "resume = retained window + live tail, in order"
1079 );
1080 assert_provider_valid(&reconstructed.resume_messages("c1"));
1081
1082 let (global, _) = apply_retention(&fold(&ops), &policy, 1_000).unwrap();
1083 let (local, _) = apply_retention(&reconstructed, &policy, 1_000).unwrap();
1084 assert_eq!(local.transcript("c1"), global.transcript("c1"));
1085 assert_eq!(
1086 global
1087 .transcript("c1")
1088 .iter()
1089 .map(|t| t.content.clone())
1090 .collect::<Vec<_>>(),
1091 vec!["t4", "t5"],
1092 "the last-N display window is the same on every device"
1093 );
1094 assert_eq!(state_hash(&local), state_hash(&global));
1095 }
1096
1097 /// Heap's algorithm — every permutation, no rand dependency.
1098 fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
1099 fn heap<T: Clone>(k: usize, arr: &mut Vec<T>, out: &mut Vec<Vec<T>>) {
1100 if k == 1 {
1101 out.push(arr.clone());
1102 return;
1103 }
1104 for i in 0..k {
1105 heap(k - 1, arr, out);
1106 if k.is_multiple_of(2) {
1107 arr.swap(i, k - 1);
1108 } else {
1109 arr.swap(0, k - 1);
1110 }
1111 }
1112 }
1113 let mut arr = items.to_vec();
1114 let mut out = Vec::new();
1115 heap(arr.len(), &mut arr, &mut out);
1116 out
1117 }
1118}