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}