Skip to main content

cognee_http_server/dto/
sessions.rs

1//! DTOs for `/api/v1/sessions/*` (E-09 owns the list endpoint).
2//!
3//! ## Wire shape — Python parity carve-out
4//!
5//! Unlike most v2 body DTOs (Decision 10 → camelCase), the sessions list
6//! response wire shape is **snake_case** because Python returns a plain
7//! `dict` via `JSONResponse(content={...})` rather than an `OutDTO`
8//! subclass — `to_camel` does not apply to plain dicts. The per-row keys
9//! mirror `SessionRecord.to_dict()` ([`models.py:68-86`](https://github.com/topoteretes/cognee/blob/main/cognee/modules/session_lifecycle/models.py#L68-L86))
10//! plus the read-time `effective_status`, and the envelope keys mirror
11//! `get_sessions_router.py:99-107`. Both `SessionListResponseDTO` and
12//! `SessionRowDTO` are therefore on the snake_case allow-list in
13//! `tests/test_openapi_camelcase.rs`.
14//!
15//! Query-parameter struct (`ListSessionsQuery`) keeps its literal
16//! parameter names on the wire — Python's `Query()` does not apply
17//! `alias_generator` to query params (see `dto/mod.rs` doc).
18//!
19//! Decision 9 / divergence D-1: the `OrderBy` enum rejects unknown
20//! variants at deserialization time. Python's handler silently falls
21//! back to `last_activity_at`; Rust deliberately diverges to surface
22//! client typos (see [`README.md §1.2`](../../../../docs/http-api-v2/README.md#12-v2-acknowledged-divergences-changes-to-steady-state-wire-output)).
23
24use serde::{Deserialize, Serialize};
25use utoipa::{IntoParams, ToSchema};
26
27// ─── Query parameter enums ────────────────────────────────────────────────────
28
29/// Time-window filter for `GET /api/v1/sessions`.
30///
31/// Mirrors Python's `_RangeLiteral` at
32/// [`get_sessions_router.py:36`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L36)
33/// — strict-parity: the four variants are `24h | 7d | 30d | all`. The
34/// previous draft (`90d`) is **not** a Python value and is dropped.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ToSchema)]
36pub enum RangeWindow {
37    #[serde(rename = "24h")]
38    H24,
39    #[serde(rename = "7d")]
40    D7,
41    #[default]
42    #[serde(rename = "30d")]
43    D30,
44    #[serde(rename = "all")]
45    All,
46}
47
48/// Sortable columns for `GET /api/v1/sessions`.
49///
50/// Decision 9 / divergence D-1: typed enum rejects unknown variants at
51/// deserialization time. Python's [`metrics.py:415-423`](https://github.com/topoteretes/cognee/blob/main/cognee/modules/session_lifecycle/metrics.py#L415-L423)
52/// silently falls back to `last_activity_at` for unknown inputs; Rust
53/// surfaces `400` with the Python validation envelope.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ToSchema)]
55#[serde(rename_all = "snake_case")]
56pub enum OrderBy {
57    #[default]
58    LastActivityAt,
59    StartedAt,
60    EndedAt,
61    CostUsd,
62    TokensIn,
63    TokensOut,
64}
65
66impl OrderBy {
67    /// String form passed to LIB-05's `SessionListFilters::order_by`.
68    /// The canonical column names match Python's `sortable` lookup at
69    /// [`metrics.py:415-423`](https://github.com/topoteretes/cognee/blob/main/cognee/modules/session_lifecycle/metrics.py#L415-L423).
70    pub fn as_column(self) -> &'static str {
71        match self {
72            Self::LastActivityAt => "last_activity_at",
73            Self::StartedAt => "started_at",
74            Self::EndedAt => "ended_at",
75            Self::CostUsd => "cost_usd",
76            Self::TokensIn => "tokens_in",
77            Self::TokensOut => "tokens_out",
78        }
79    }
80}
81
82impl RangeWindow {
83    /// Wire-string form of the variant. Used by `GET /sessions/stats` to
84    /// echo the input `range` back into the response body — Python emits
85    /// the literal input string at
86    /// [`get_sessions_router.py:181`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L181),
87    /// even when the input was the default.
88    pub fn as_wire_str(self) -> &'static str {
89        match self {
90            Self::H24 => "24h",
91            Self::D7 => "7d",
92            Self::D30 => "30d",
93            Self::All => "all",
94        }
95    }
96}
97
98// ─── Query struct ─────────────────────────────────────────────────────────────
99
100fn default_limit() -> u32 {
101    50
102}
103
104fn default_descending() -> bool {
105    true
106}
107
108/// Query parameters for `GET /api/v1/sessions`.
109///
110/// Wire names match the literal Rust field names (snake_case) — Python's
111/// `Query()` defaults at
112/// [`get_sessions_router.py:64-72`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L64-L72)
113/// expose the same names. Out of scope for Decision 10's camelCase rule
114/// (which targets `OutDTO`/`InDTO` body fields, not query strings).
115#[derive(Debug, Clone, Deserialize, IntoParams)]
116#[into_params(parameter_in = Query)]
117pub struct ListSessionsQuery {
118    /// Time window. Default `30d`.
119    #[serde(default)]
120    pub range: RangeWindow,
121    /// Optional effective-status filter (`completed` / `failed` /
122    /// `abandoned` / `running`). String passthrough — LIB-05 applies the
123    /// `effective_status` SQL expression so `abandoned` matches running
124    /// rows past the idle threshold.
125    #[serde(default)]
126    pub status: Option<String>,
127    /// Page size, validated `1..=500` in the handler. Default `50`.
128    #[serde(default = "default_limit")]
129    pub limit: u32,
130    /// Page offset (`u32` enforces `>= 0`).
131    #[serde(default)]
132    pub offset: u32,
133    /// Sort column. Default `last_activity_at`. Decision 9 / D-1 rejects
134    /// unknown variants with 400.
135    #[serde(default)]
136    pub order_by: OrderBy,
137    /// Direction. `true` → DESC. Default `true`.
138    #[serde(default = "default_descending")]
139    pub descending: bool,
140}
141
142/// Query parameters for `GET /api/v1/sessions/stats`.
143///
144/// Wire names match the literal Rust field names (snake_case) — Python's
145/// `Query()` does not apply `alias_generator` to query params. Out of
146/// scope for Decision 10 (which targets body DTOs, not query strings).
147///
148/// Mirrors Python's `Query(...)` defaults at
149/// [`get_sessions_router.py:114-115`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L114-L115).
150#[derive(Debug, Clone, Deserialize, IntoParams)]
151#[into_params(parameter_in = Query)]
152pub struct StatsQuery {
153    /// Time window. Default `30d`.
154    #[serde(default)]
155    pub range: RangeWindow,
156}
157
158/// Query parameters for `GET /api/v1/sessions/cost-by-model`.
159///
160/// Wire names match the literal Rust field names (snake_case) — Python's
161/// `Query()` does not apply `alias_generator` to query params. Out of
162/// scope for Decision 10 (which targets body DTOs, not query strings).
163///
164/// Mirrors Python's `Query(...)` default at
165/// [`get_sessions_router.py:200`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L200).
166#[derive(Debug, Clone, Deserialize, IntoParams)]
167#[into_params(parameter_in = Query)]
168pub struct CostByModelQuery {
169    /// Time window. Default `30d`.
170    #[serde(default)]
171    pub range: RangeWindow,
172}
173
174// ─── Response DTOs (snake_case wire) ──────────────────────────────────────────
175
176/// Paginated envelope for `GET /api/v1/sessions`.
177///
178/// snake_case wire — Python returns a plain dict via `jsonable_encoder`
179/// ([`get_sessions_router.py:99-107`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L99-L107)),
180/// not an `OutDTO`, so `to_camel` does not apply.
181#[derive(Debug, Clone, Serialize, ToSchema)]
182pub struct SessionListResponseDTO {
183    pub sessions: Vec<SessionRowDTO>,
184    pub total: i64,
185    pub limit: u32,
186    pub offset: u32,
187    pub has_more: bool,
188}
189
190/// Per-row body for the sessions list. snake_case keys mirror Python
191/// `SessionRecord.to_dict()` at
192/// [`models.py:68-86`](https://github.com/topoteretes/cognee/blob/main/cognee/modules/session_lifecycle/models.py#L68-L86)
193/// plus the read-time `effective_status`. Every `DateTime<Utc>` field
194/// uses the Decision 6 `iso8601_offset` serde helper (`+00:00` shape with
195/// microsecond precision).
196#[derive(Debug, Clone, Serialize, ToSchema)]
197pub struct SessionRowDTO {
198    pub session_id: String,
199    pub user_id: String,
200    pub dataset_id: Option<String>,
201    pub status: String,
202    #[serde(with = "crate::dto::util::iso8601_offset")]
203    pub started_at: chrono::DateTime<chrono::Utc>,
204    #[serde(with = "crate::dto::util::iso8601_offset")]
205    pub last_activity_at: chrono::DateTime<chrono::Utc>,
206    #[serde(with = "crate::dto::util::iso8601_offset_option", default)]
207    pub ended_at: Option<chrono::DateTime<chrono::Utc>>,
208    pub tokens_in: i32,
209    pub tokens_out: i32,
210    pub cost_usd: f64,
211    pub error_count: i32,
212    pub last_model: Option<String>,
213    pub effective_status: String,
214}
215
216/// Response envelope for `GET /api/v1/sessions/stats`.
217///
218/// snake_case wire — Python returns a plain dict via `jsonable_encoder`
219/// at
220/// [`get_sessions_router.py:179-196`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L179-L196),
221/// not an `OutDTO`, so `to_camel` does not apply (same parity carve-out
222/// as the list endpoint).
223///
224/// Field-for-field parity with the Python response body. The first field
225/// (`range`) is the input echo Python emits at
226/// [`:181`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L181);
227/// the remaining 13 fields come from
228/// [`cognee_database::SessionStats`](../../../cognee_database/struct.SessionStats.html).
229#[derive(Debug, Clone, Serialize, ToSchema)]
230pub struct SessionStatsDTO {
231    /// Echo of the input `range` query parameter (literal string, even
232    /// when the input was the default).
233    pub range: String,
234    pub sessions: i64,
235    pub total_spend_usd: f64,
236    pub avg_spend_per_session_usd: f64,
237    pub tokens_in: i64,
238    pub tokens_out: i64,
239    pub tokens_total: i64,
240    pub agent_time_s: f64,
241    pub avg_session_s: f64,
242    pub success_rate: f64,
243    pub completed: i64,
244    pub failed: i64,
245    pub abandoned: i64,
246    pub running: i64,
247}
248
249/// Per-model row for `GET /api/v1/sessions/cost-by-model`.
250///
251/// snake_case wire — Python returns a plain list-of-dicts via
252/// `jsonable_encoder` ([`get_sessions_router.py:241-251`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L241-L251)),
253/// not an `OutDTO`, so `to_camel` does not apply (same parity carve-out
254/// as the list and stats endpoints). Field-for-field parity with
255/// [`cognee_database::CostByModelRow`](../../../cognee_database/struct.CostByModelRow.html).
256#[derive(Debug, Clone, Serialize, ToSchema)]
257pub struct CostByModelDTO {
258    pub model: String,
259    pub session_count: i64,
260    pub cost_usd: f64,
261    pub tokens_in: i64,
262    pub tokens_out: i64,
263}
264
265impl From<cognee_database::CostByModelRow> for CostByModelDTO {
266    fn from(row: cognee_database::CostByModelRow) -> Self {
267        Self {
268            model: row.model,
269            session_count: row.session_count,
270            cost_usd: row.cost_usd,
271            tokens_in: row.tokens_in,
272            tokens_out: row.tokens_out,
273        }
274    }
275}
276
277/// Response DTO for `GET /api/v1/sessions/{session_id}` (E-12).
278///
279/// snake_case wire — Python returns a plain dict via `jsonable_encoder`
280/// at [`get_sessions_router.py:289-307`](https://github.com/topoteretes/cognee/blob/main/cognee/api/v1/sessions/routers/get_sessions_router.py#L289-L307),
281/// not an `OutDTO`, so `to_camel` does not apply (same parity carve-out
282/// as the three sibling DTOs).
283///
284/// The body is the `SessionRowWithStatus.to_dict()` shape (12 + 1 keys —
285/// 12 from `SessionRecord.to_dict()` plus the read-time `effective_status`)
286/// extended with five extra keys: `label`, `msg_count`, `tool_calls`, and
287/// the truncated `qas` / `traces` lists. `#[serde(flatten)]` on `record`
288/// keeps the row keys at the top level for byte parity with Python.
289///
290/// `qas` / `traces` are typed as `serde_json::Value` to match Python's
291/// untyped dicts coming out of `SessionStore::get_latest_qa_entries` and
292/// `SessionManager::get_agent_trace_session`.
293#[derive(Debug, Clone, Serialize, ToSchema)]
294pub struct SessionDetailDTO {
295    /// The `SessionRowWithStatus.to_dict()` row body, flattened to the
296    /// top level (12 row fields + `effective_status`).
297    #[serde(flatten)]
298    pub record: SessionRowDTO,
299    /// First non-empty QA `question` truncated to 120 chars, else first
300    /// non-empty trace `origin_function`, else `None`.
301    pub label: Option<String>,
302    /// Pre-truncation length of the QA list.
303    pub msg_count: usize,
304    /// Pre-truncation length of the trace list.
305    pub tool_calls: usize,
306    /// Trailing-20 QA entries (oldest of the 20 first), serialized as
307    /// untyped JSON dicts to match Python's wire shape.
308    pub qas: Vec<serde_json::Value>,
309    /// Trailing-20 trace steps (oldest of the 20 first), serialized as
310    /// untyped JSON dicts to match Python's wire shape.
311    pub traces: Vec<serde_json::Value>,
312}
313
314impl From<cognee_database::SessionRowWithStatus> for SessionRowDTO {
315    fn from(row: cognee_database::SessionRowWithStatus) -> Self {
316        let cognee_database::SessionRowWithStatus {
317            record,
318            effective_status,
319        } = row;
320        Self {
321            session_id: record.session_id,
322            user_id: record.user_id,
323            dataset_id: record.dataset_id,
324            status: record.status,
325            started_at: record.started_at,
326            last_activity_at: record.last_activity_at,
327            ended_at: record.ended_at,
328            tokens_in: record.tokens_in,
329            tokens_out: record.tokens_out,
330            cost_usd: record.cost_usd,
331            error_count: record.error_count,
332            last_model: record.last_model,
333            effective_status,
334        }
335    }
336}
337
338#[cfg(test)]
339#[allow(
340    clippy::unwrap_used,
341    clippy::expect_used,
342    reason = "test code — panics are acceptable failures"
343)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn range_window_default_is_30d() {
349        assert_eq!(RangeWindow::default(), RangeWindow::D30);
350    }
351
352    #[test]
353    fn order_by_default_is_last_activity_at() {
354        assert_eq!(OrderBy::default(), OrderBy::LastActivityAt);
355        assert_eq!(OrderBy::LastActivityAt.as_column(), "last_activity_at");
356        assert_eq!(OrderBy::CostUsd.as_column(), "cost_usd");
357    }
358
359    #[test]
360    fn list_sessions_query_defaults() {
361        let q: ListSessionsQuery = serde_urlencoded::from_str("").expect("empty query");
362        assert_eq!(q.range, RangeWindow::D30);
363        assert_eq!(q.limit, 50);
364        assert_eq!(q.offset, 0);
365        assert_eq!(q.order_by, OrderBy::LastActivityAt);
366        assert!(q.descending);
367        assert!(q.status.is_none());
368    }
369
370    #[test]
371    fn list_sessions_query_parses_all_fields() {
372        let q: ListSessionsQuery = serde_urlencoded::from_str(
373            "range=24h&status=running&limit=200&offset=10&order_by=cost_usd&descending=false",
374        )
375        .expect("parse query");
376        assert_eq!(q.range, RangeWindow::H24);
377        assert_eq!(q.status.as_deref(), Some("running"));
378        assert_eq!(q.limit, 200);
379        assert_eq!(q.offset, 10);
380        assert_eq!(q.order_by, OrderBy::CostUsd);
381        assert!(!q.descending);
382    }
383
384    #[test]
385    fn range_window_rejects_90d() {
386        // 90d is NOT a valid Python value — strict parity drops it.
387        let res: Result<ListSessionsQuery, _> = serde_urlencoded::from_str("range=90d");
388        assert!(res.is_err(), "90d must be rejected");
389    }
390
391    #[test]
392    fn order_by_rejects_unknown_variant() {
393        // Decision 9 / D-1: typed enum rejects unknown variants.
394        let res: Result<ListSessionsQuery, _> = serde_urlencoded::from_str("order_by=banana");
395        assert!(res.is_err(), "unknown order_by must be rejected");
396    }
397
398    #[test]
399    fn stats_query_defaults_to_30d() {
400        let q: StatsQuery = serde_urlencoded::from_str("").expect("empty query");
401        assert_eq!(q.range, RangeWindow::D30);
402    }
403
404    #[test]
405    fn range_window_as_wire_str_round_trips() {
406        assert_eq!(RangeWindow::H24.as_wire_str(), "24h");
407        assert_eq!(RangeWindow::D7.as_wire_str(), "7d");
408        assert_eq!(RangeWindow::D30.as_wire_str(), "30d");
409        assert_eq!(RangeWindow::All.as_wire_str(), "all");
410    }
411
412    #[test]
413    fn session_stats_dto_emits_snake_case_keys() {
414        let dto = SessionStatsDTO {
415            range: "30d".into(),
416            sessions: 3,
417            total_spend_usd: 1.5,
418            avg_spend_per_session_usd: 0.5,
419            tokens_in: 10,
420            tokens_out: 20,
421            tokens_total: 30,
422            agent_time_s: 12.5,
423            avg_session_s: 4.0,
424            success_rate: 0.75,
425            completed: 2,
426            failed: 1,
427            abandoned: 0,
428            running: 0,
429        };
430        let s = serde_json::to_string(&dto).expect("serialize");
431        // snake_case wire keys — Python parity (plain dict response).
432        assert!(s.contains("\"range\""), "expected range key: {s}");
433        assert!(
434            s.contains("\"total_spend_usd\""),
435            "expected snake_case total_spend_usd: {s}"
436        );
437        assert!(
438            s.contains("\"avg_spend_per_session_usd\""),
439            "expected snake_case avg_spend_per_session_usd: {s}"
440        );
441        assert!(
442            s.contains("\"success_rate\""),
443            "expected snake_case success_rate: {s}"
444        );
445        assert!(
446            s.contains("\"agent_time_s\""),
447            "expected snake_case agent_time_s: {s}"
448        );
449        assert!(
450            s.contains("\"avg_session_s\""),
451            "expected snake_case avg_session_s: {s}"
452        );
453        assert!(
454            s.contains("\"tokens_total\""),
455            "expected snake_case tokens_total: {s}"
456        );
457        // Reject camelCase variants — these would indicate Decision 10
458        // accidentally applied.
459        assert!(!s.contains("totalSpendUsd"), "must not emit camelCase: {s}");
460        assert!(!s.contains("successRate"), "must not emit camelCase: {s}");
461    }
462
463    #[test]
464    fn cost_by_model_query_defaults_to_30d() {
465        let q: CostByModelQuery = serde_urlencoded::from_str("").expect("empty query");
466        assert_eq!(q.range, RangeWindow::D30);
467    }
468
469    #[test]
470    fn cost_by_model_dto_emits_snake_case_keys() {
471        let dto = CostByModelDTO {
472            model: "gpt-4o-mini".into(),
473            session_count: 3,
474            cost_usd: 1.25,
475            tokens_in: 100,
476            tokens_out: 200,
477        };
478        let s = serde_json::to_string(&dto).expect("serialize");
479        // snake_case wire keys — Python parity (plain list-of-dicts response).
480        assert!(s.contains("\"model\""), "expected model key: {s}");
481        assert!(
482            s.contains("\"session_count\""),
483            "expected snake_case session_count: {s}"
484        );
485        assert!(
486            s.contains("\"cost_usd\""),
487            "expected snake_case cost_usd: {s}"
488        );
489        assert!(
490            s.contains("\"tokens_in\""),
491            "expected snake_case tokens_in: {s}"
492        );
493        assert!(
494            s.contains("\"tokens_out\""),
495            "expected snake_case tokens_out: {s}"
496        );
497        // Reject camelCase variants — these would indicate Decision 10
498        // accidentally applied.
499        assert!(!s.contains("sessionCount"), "must not emit camelCase: {s}");
500        assert!(!s.contains("costUsd"), "must not emit camelCase: {s}");
501        assert!(!s.contains("tokensIn"), "must not emit camelCase: {s}");
502        assert!(!s.contains("tokensOut"), "must not emit camelCase: {s}");
503    }
504
505    #[test]
506    fn session_detail_dto_emits_snake_case_keys_and_flattens_record() {
507        use chrono::TimeZone;
508        let row = SessionRowDTO {
509            session_id: "s".into(),
510            user_id: "u".into(),
511            dataset_id: None,
512            status: "running".into(),
513            started_at: chrono::Utc
514                .with_ymd_and_hms(2026, 4, 29, 0, 0, 0)
515                .single()
516                .expect("valid"),
517            last_activity_at: chrono::Utc
518                .with_ymd_and_hms(2026, 4, 29, 0, 0, 1)
519                .single()
520                .expect("valid"),
521            ended_at: None,
522            tokens_in: 1,
523            tokens_out: 2,
524            cost_usd: 0.5,
525            error_count: 0,
526            last_model: Some("gpt-4o".into()),
527            effective_status: "running".into(),
528        };
529        let dto = SessionDetailDTO {
530            record: row,
531            label: Some("hello".into()),
532            msg_count: 3,
533            tool_calls: 4,
534            qas: vec![serde_json::json!({"question": "q?", "answer": "a"})],
535            traces: vec![serde_json::json!({"origin_function": "tool"})],
536        };
537        let s = serde_json::to_string(&dto).expect("serialize");
538        // Flattened row keys live at top level (no `record` wrapper).
539        assert!(
540            s.contains("\"session_id\""),
541            "expected flattened session_id: {s}"
542        );
543        assert!(
544            s.contains("\"effective_status\""),
545            "expected flattened effective_status: {s}"
546        );
547        assert!(
548            !s.contains("\"record\""),
549            "must not wrap row in `record`: {s}"
550        );
551        // Five extra fields, snake_case.
552        assert!(s.contains("\"label\""), "expected label key: {s}");
553        assert!(
554            s.contains("\"msg_count\""),
555            "expected snake_case msg_count: {s}"
556        );
557        assert!(
558            s.contains("\"tool_calls\""),
559            "expected snake_case tool_calls: {s}"
560        );
561        assert!(s.contains("\"qas\""), "expected qas key: {s}");
562        assert!(s.contains("\"traces\""), "expected traces key: {s}");
563        // Decision 6 timestamp shape leaks through the flatten.
564        assert!(
565            s.contains("+00:00"),
566            "expected Decision-6 +00:00 timestamp: {s}"
567        );
568        // Reject camelCase variants.
569        assert!(!s.contains("msgCount"), "must not emit camelCase: {s}");
570        assert!(!s.contains("toolCalls"), "must not emit camelCase: {s}");
571    }
572
573    #[test]
574    fn session_row_dto_emits_snake_case_keys() {
575        use chrono::TimeZone;
576        let dto = SessionRowDTO {
577            session_id: "s".into(),
578            user_id: "u".into(),
579            dataset_id: None,
580            status: "running".into(),
581            started_at: chrono::Utc
582                .with_ymd_and_hms(2026, 4, 29, 0, 0, 0)
583                .single()
584                .expect("valid"),
585            last_activity_at: chrono::Utc
586                .with_ymd_and_hms(2026, 4, 29, 0, 0, 1)
587                .single()
588                .expect("valid"),
589            ended_at: None,
590            tokens_in: 1,
591            tokens_out: 2,
592            cost_usd: 0.5,
593            error_count: 0,
594            last_model: Some("gpt-4o".into()),
595            effective_status: "running".into(),
596        };
597        let s = serde_json::to_string(&dto).expect("serialize");
598        // snake_case wire keys + Decision 6 timestamp shape.
599        assert!(
600            s.contains("\"session_id\""),
601            "expected snake_case session_id: {s}"
602        );
603        assert!(
604            s.contains("\"last_activity_at\""),
605            "expected snake_case last_activity_at: {s}"
606        );
607        assert!(
608            s.contains("\"effective_status\""),
609            "expected snake_case effective_status: {s}"
610        );
611        assert!(
612            s.contains("+00:00"),
613            "expected Decision-6 +00:00 timestamp: {s}"
614        );
615    }
616}