1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
use crate::references::ReferenceMeta;
use crate::refs::OutputRef;
use crate::tool::ToolCall;
use crate::trust::TrustEnvelope;
use serde::{Deserialize, Serialize};
/// A transcript entry. Discriminated union over the conversation roles plus the compaction boundary
/// marker. Reasoning content is a normalized channel on `Assistant` — never folded into ordinary
/// `content`, because per-provider re-feed rules differ (DeepSeek forbids re-feeding CoT; Kimi
/// requires echoing it in tool-call turns).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "$kind")]
pub enum TranscriptMessage {
#[serde(rename = "system", rename_all = "camelCase")]
System {
id: String,
created_at: String,
content: String,
pinned: bool,
},
#[serde(rename = "user", rename_all = "camelCase")]
User {
id: String,
created_at: String,
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
trust: Option<TrustEnvelope>,
pinned: bool,
/// D2 — typed, persisted references attached to this user turn.
/// Metadata-only on the wire (no image bytes — those continue to
/// travel through the legacy `Attachment` channel). `serde-default`
/// + skip-when-empty keeps every pre-D2 transcript row wire-
/// compatible: rows written before D2 deserialize into an empty
/// vec, rows written after D2 skip the key when no references are
/// attached (so an empty user turn still serializes to the legacy
/// 5-field shape).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<ReferenceMeta>,
},
#[serde(rename = "assistant", rename_all = "camelCase")]
Assistant {
id: String,
created_at: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
pinned: bool,
},
/// The result of a tool call. `summary` is the compact transcript line; the full (possibly
/// large) payload is referenced by `output` and kept out of context.
#[serde(rename = "tool", rename_all = "camelCase")]
Tool {
id: String,
created_at: String,
tool_call_id: String,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<OutputRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trust: Option<TrustEnvelope>,
is_error: bool,
pinned: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
process_id: Option<String>,
},
/// A compaction/tombstone boundary (G7). Marks where older turns were summarized and dropped.
/// Visible in the transcript — compaction is never silent.
#[serde(rename = "compact", rename_all = "camelCase")]
Compact {
id: String,
created_at: String,
summary: String,
dropped_count: i32,
pinned: bool,
},
/// A context-fold boundary. Marks where a subtask's sub-trajectory was folded into
/// a summary. The full sub-trajectory is archived to disk and retrievable via `archive_ref`.
/// Structural (per-subtask), complementary to the 70% emergency compactor.
#[serde(rename = "fold", rename_all = "camelCase")]
Fold {
id: String,
created_at: String,
subtask_id: String,
summary: String,
dropped_count: i32,
/// Reference to the archived sub-trajectory on disk.
archive_ref: String,
pinned: bool,
},
}
impl TranscriptMessage {
/// Rewrite the message id. Used at the storage boundary to migrate legacy
/// constant IDs (`tr`, `final`, etc.) to the row-derived canonical form
/// and by the live engine when a re-emitted assistant completion
/// disagrees with the persisted row sequence.
pub fn set_id(&mut self, value: String) {
match self {
Self::System { id, .. }
| Self::User { id, .. }
| Self::Assistant { id, .. }
| Self::Tool { id, .. }
| Self::Compact { id, .. }
| Self::Fold { id, .. } => *id = value,
}
}
/// Read the message id without disturbing the variant.
pub fn id(&self) -> &str {
match self {
Self::System { id, .. }
| Self::User { id, .. }
| Self::Assistant { id, .. }
| Self::Tool { id, .. }
| Self::Compact { id, .. }
| Self::Fold { id, .. } => id.as_str(),
}
}
/// Created-at timestamp; same regardless of variant.
pub fn created_at(&self) -> &str {
match self {
Self::System { created_at, .. }
| Self::User { created_at, .. }
| Self::Assistant { created_at, .. }
| Self::Tool { created_at, .. }
| Self::Compact { created_at, .. }
| Self::Fold { created_at, .. } => created_at.as_str(),
}
}
/// Canonical constructor for a `User` transcript row. Defaults
/// `attachments` to empty so the many call sites that don't carry
/// references don't have to spell out the field. Callers that DO
/// attach references should use [`TranscriptMessage::user_with_attachments`].
pub fn user(
id: impl Into<String>,
created_at: impl Into<String>,
content: impl Into<String>,
trust: Option<TrustEnvelope>,
pinned: bool,
) -> Self {
Self::User {
id: id.into(),
created_at: created_at.into(),
content: content.into(),
trust,
pinned,
attachments: Vec::new(),
}
}
/// Canonical constructor for a `User` transcript row that carries typed
/// D2 references. Use this at the canonical write path (`transcript.rs`)
/// and the message-submission entry point (`api_sessions.rs`); all other
/// call sites should use [`TranscriptMessage::user`].
pub fn user_with_attachments(
id: impl Into<String>,
created_at: impl Into<String>,
content: impl Into<String>,
trust: Option<TrustEnvelope>,
pinned: bool,
attachments: Vec<ReferenceMeta>,
) -> Self {
Self::User {
id: id.into(),
created_at: created_at.into(),
content: content.into(),
trust,
pinned,
attachments,
}
}
pub fn is_provider_visible(&self) -> bool {
match self {
Self::Tool { output, .. } => output
.as_ref()
.map(|output| output.kind != crate::refs::TOOL_OUTPUT_INTERNAL_KIND)
.unwrap_or(true),
_ => true,
}
}
/// Borrow the user-row attachments (empty vec for any non-user variant).
/// Used by the persistence layer to round-trip references across
/// write / read boundaries without coupling to `..` destructure shape.
pub fn user_attachments(&self) -> &[ReferenceMeta] {
match self {
Self::User { attachments, .. } => attachments.as_slice(),
_ => &[],
}
}
/// Owned copy of the user-row attachments (empty vec for any non-user
/// variant). Useful at submission time when the row needs to forward
/// attachments to a downstream consumer.
pub fn into_user_attachments(self) -> Vec<ReferenceMeta> {
match self {
Self::User { attachments, .. } => attachments,
_ => Vec::new(),
}
}
}