Skip to main content

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}