aa-cli 0.0.1-alpha.9

aasm — command-line tool for Agent Assembly
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
//! Data models for the `aasm status` command.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// API response from `GET /api/v1/health`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthResponse {
    /// Liveness status string, always `"ok"` when the service is running.
    pub status: String,
    /// Server uptime in seconds since startup.
    #[serde(default)]
    pub uptime_secs: u64,
    /// Number of currently active WebSocket/SSE connections.
    #[serde(default)]
    pub active_connections: i64,
    /// Pipeline processing lag in milliseconds.
    #[serde(default)]
    pub pipeline_lag_ms: u64,
}

/// Redact the password component of a database connection URL.
///
/// Replaces the password segment of the userinfo portion of an authority-bearing
/// URL with `***`. Used by the `aasm status` deployment overview so a postgres
/// `database_url` can be displayed without leaking the credential.
///
/// Returns the input unchanged when:
/// * the input is not a `scheme://...` URL,
/// * the authority contains no `user:pass@` userinfo, or
/// * the userinfo contains a username only (no `:password`).
///
/// The split point between userinfo and host is the rightmost `@` inside the
/// authority — tolerating an `@` inside the password is intentional, since
/// well-formed URLs percent-encode any such occurrence.
pub fn redact_database_url(url: &str) -> String {
    let Some(scheme_end) = url.find("://") else {
        return url.to_string();
    };
    let authority_start = scheme_end + 3;
    let authority_end = url[authority_start..]
        .find(['/', '?', '#'])
        .map(|i| authority_start + i)
        .unwrap_or(url.len());
    let authority = &url[authority_start..authority_end];

    let Some(at_idx) = authority.rfind('@') else {
        return url.to_string();
    };
    let userinfo = &authority[..at_idx];
    let Some(colon_idx) = userinfo.find(':') else {
        return url.to_string();
    };

    let user = &userinfo[..colon_idx];
    let host_and_rest = &url[authority_start + at_idx..];
    format!("{}://{}:***{}", &url[..scheme_end], user, host_and_rest)
}

/// API response from `GET /healthz` — the lightweight gateway liveness probe.
///
/// Mirrors the wire contract published by `aa-gateway::routes::healthz::HealthzBody`
/// (landed under AAASM-1577 ST-1). Field names are part of that contract — do
/// not rename without a coordinated server-side update.
///
/// `storage_path` and `database_url` are reserved for the richer
/// `GET /api/v1/admin/status` response (AAASM-1474) and remain `None` when the
/// gateway exposes only the minimum `/healthz` body; the `aasm status`
/// deployment overview opportunistically surfaces them when present.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthzResponse {
    /// Deployment mode label: `"local"` or `"remote"`.
    pub mode: String,
    /// Gateway crate version.
    pub version: String,
    /// Storage backend label: `"sqlite"`, `"postgres"`, or `"memory"`.
    pub storage: String,
    /// Seconds elapsed since the gateway became ready to serve traffic.
    pub uptime_secs: u64,
    /// Local-mode SQLite file path, when reported.
    #[serde(default)]
    pub storage_path: Option<String>,
    /// Raw PostgreSQL connection URL, when reported.
    ///
    /// Password redaction is applied by the display-layer composer, not here —
    /// this model preserves the wire shape the gateway sent.
    #[serde(default)]
    pub database_url: Option<String>,
}

/// Hot-tier row-count snapshot returned inside the `/api/v1/admin/status`
/// storage block (AAASM-1591 / Epic 18 S-J).
///
/// Field names mirror the server-side `aa_gateway::routes::admin_status::
/// RowCountsBlock` wire contract verbatim. `#[serde(deny_unknown_fields)]`
/// is intentionally omitted so future warm-/cold-tier counts can be added
/// server-side without breaking older CLIs.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AdminRowCountsBlock {
    /// Audit events in the hot tier (uncompressed, queryable).
    pub audit_events_hot: u64,
    /// Registered agents.
    pub agents: u64,
    /// Total policy versions across all policy names.
    pub policy_versions: u64,
}

/// TimescaleDB chunk + compression rollup, present in the
/// `/api/v1/admin/status` storage block only when the PostgreSQL backend
/// is connected to a cluster with the TimescaleDB extension installed.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminTimescaleDbBlock {
    /// Always `true` when the block is present — present-but-disabled is
    /// reserved for a future state.
    pub enabled: bool,
    /// Total number of chunks across the gateway's hypertables.
    pub total_chunks: u32,
    /// Subset of `total_chunks` already compressed by the auto-policy.
    pub compressed_chunks: u32,
    /// Aggregate uncompressed/compressed size ratio as a human-friendly
    /// float (e.g. `11.4` = 11.4× size reduction).
    pub compression_ratio: f32,
}

/// Storage health block returned under `body.storage` in the
/// `/api/v1/admin/status` response (AAASM-1591 / Epic 18 S-J).
///
/// Mirrors the server-side `aa_gateway::routes::admin_status::
/// StorageHealthBlock` wire contract. Per-backend optional fields
/// follow the same shape: `path` only on sqlite, `database_url`
/// (already redacted by the gateway) only on postgres, `timescaledb`
/// only when the TimescaleDB extension is active.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminStorageHealthBlock {
    /// Static backend identifier — e.g. `"sqlite"`, `"postgres"`,
    /// `"memory"`, or `"unknown"` when the probe itself failed.
    pub backend: String,
    /// Local-mode SQLite file path. Present only when
    /// `backend == "sqlite"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    /// PostgreSQL connection URL — already redacted server-side, so the
    /// CLI never sees the raw password. Present only when
    /// `backend == "postgres"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub database_url: Option<String>,
    /// Coarse health label: `"ok"`, `"degraded"`, or `"unavailable"`.
    pub health: String,
    /// Latency of the gateway's healthcheck probe in milliseconds.
    pub latency_ms: u32,
    /// Hot-tier row-count snapshot taken during the probe.
    pub row_counts: AdminRowCountsBlock,
    /// TimescaleDB rollup, present only when the extension is active.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timescaledb: Option<AdminTimescaleDbBlock>,
}

/// Top-level response from `GET /api/v1/admin/status`.
///
/// Mirrors `aa_gateway::routes::admin_status::AdminStatusBody`. The CLI
/// only fetches and renders the `storage` block today; `mode`, `version`,
/// and `uptime_secs` are deserialised so the response can be round-
/// tripped if a future consumer needs them.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct AdminStatusResponse {
    /// Deployment mode label: `"local"` or `"remote"`.
    pub mode: String,
    /// Gateway crate version.
    pub version: String,
    /// Seconds elapsed since the gateway became ready to serve traffic.
    pub uptime_secs: u64,
    /// Storage health block.
    pub storage: AdminStorageHealthBlock,
}

/// Display model for the `aasm status` deployment-overview header.
///
/// The serialised shape is the JSON contract for `aasm status --json` — field
/// names must stay in lockstep with the AAASM-1579 story description so
/// scripting and CI consumers can rely on them. `storage_path` and
/// `database_url_redacted` are `Option` to allow them to be omitted in the
/// minimum `/healthz` body case rather than emitted as `null`.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DeploymentOverview {
    /// Deployment mode label: `"local"` or `"remote"` (or `"unknown"` when unreachable).
    pub mode: String,
    /// Gateway base URL the CLI was configured to talk to.
    pub gateway_url: String,
    /// Storage backend label: `"sqlite"`, `"postgres"`, `"memory"`, or `"unknown"`.
    pub storage_backend: String,
    /// Local-mode SQLite file path, when reported by the gateway.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub storage_path: Option<String>,
    /// PostgreSQL connection URL with the password segment replaced by `***`,
    /// when reported by the gateway.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub database_url_redacted: Option<String>,
    /// Gateway crate version.
    pub version: String,
    /// Seconds elapsed since the gateway became ready to serve traffic.
    pub uptime_secs: u64,
    /// Overall health label: `"ok"` when the gateway responded, `"unreachable"` otherwise.
    pub health: String,
}

/// Computed runtime health for display.
#[derive(Debug, Clone, Serialize)]
pub struct RuntimeHealth {
    /// Whether the API gateway is reachable.
    pub reachable: bool,
    /// Status string from the health endpoint (e.g. `"ok"`).
    pub status: String,
    /// Server uptime in seconds since startup.
    pub uptime_secs: u64,
    /// Number of currently active WebSocket/SSE connections.
    pub active_connections: i64,
    /// Pipeline processing lag in milliseconds.
    pub pipeline_lag_ms: u64,
}

/// API response item from `GET /api/v1/agents`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentResponse {
    pub id: String,
    pub name: String,
    pub framework: String,
    pub version: String,
    pub status: String,
    pub tool_names: Vec<String>,
    pub metadata: BTreeMap<String, String>,
    /// Number of sessions handled by this agent.
    #[serde(default)]
    pub session_count: u32,
    /// Number of policy violations recorded for this agent.
    #[serde(default)]
    pub policy_violations_count: u32,
    /// Governance layer this agent is assigned to.
    #[serde(default)]
    pub layer: Option<String>,
    /// ISO 8601 timestamp of the most recent event.
    #[serde(default)]
    pub last_event: Option<String>,
    /// Most recent events emitted by this agent.
    #[serde(default)]
    pub recent_events: Vec<RecentEventResponse>,
}

/// Summary of a recent event from the API response.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RecentEventResponse {
    /// Event type classification (e.g. "violation", "tool_call").
    pub event_type: String,
    /// Short human-readable summary.
    pub summary: String,
    /// ISO 8601 timestamp when the event occurred.
    pub timestamp: String,
}

/// Flattened agent row for tabular display.
#[derive(Debug, Clone, Serialize)]
pub struct AgentRow {
    pub id: String,
    pub name: String,
    pub framework: String,
    pub status: String,
    pub sessions: u32,
    pub violations_today: u32,
    pub last_event: String,
    pub layer: String,
}

/// API response item from `GET /api/v1/approvals`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApprovalResponse {
    pub id: String,
    pub agent_id: String,
    pub action: String,
    pub reason: String,
    pub status: String,
    pub created_at: String,
    /// Team this request was routed to (empty when agent has no team).
    #[serde(default)]
    pub team_id: String,
    /// Human-readable routing status, e.g. `"routed:team-x"` or `"no_team_id"`.
    #[serde(default)]
    pub routing_status: String,
}

/// Computed approvals summary for display.
#[derive(Debug, Clone, Serialize)]
pub struct ApprovalsSummary {
    /// Number of approvals currently in `"pending"` status.
    pub pending_count: usize,
    /// Human-readable age of the oldest pending approval (e.g. `"2h 15m"`).
    pub oldest_pending_age: Option<String>,
}

/// Per-agent cost entry from the API.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentCostEntry {
    pub agent_id: String,
    pub daily_spend_usd: String,
}

/// API response from `GET /api/v1/costs`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CostResponse {
    pub daily_spend_usd: String,
    pub monthly_spend_usd: Option<String>,
    pub date: String,
    #[serde(default)]
    pub daily_limit_usd: Option<String>,
    #[serde(default)]
    pub monthly_limit_usd: Option<String>,
    #[serde(default)]
    pub per_agent: Vec<AgentCostEntry>,
}

/// Budget display model combining global spend, limits, and per-agent breakdown.
#[derive(Debug, Clone, Serialize)]
pub struct BudgetRow {
    /// Total daily spend in USD.
    pub daily_spend_usd: String,
    /// Monthly spend if available.
    pub monthly_spend_usd: Option<String>,
    /// Configured daily budget limit in USD.
    pub daily_limit_usd: Option<String>,
    /// Configured monthly budget limit in USD.
    pub monthly_limit_usd: Option<String>,
    /// Reporting date.
    pub date: String,
    /// Per-agent cost breakdown sorted by spend descending.
    pub per_agent: Vec<AgentCostEntry>,
}

/// Paginated API response wrapper.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PaginatedResponse<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: u64,
}

/// Complete status snapshot combining all sections.
#[derive(Debug, Clone, Serialize)]
pub struct StatusSnapshot {
    /// Deployment-overview header: mode, gateway URL, storage backend, version, uptime, health.
    pub deployment: DeploymentOverview,
    pub runtime: RuntimeHealth,
    pub agents: Vec<AgentRow>,
    pub approvals: ApprovalsSummary,
    pub budget: BudgetRow,
    /// Storage health block from `/api/v1/admin/status`, present when the
    /// gateway exposes the route (AAASM-1591). Older gateways that only
    /// serve `/healthz` leave this `None` and the storage section is
    /// omitted from the rendered output.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub storage_health: Option<AdminStorageHealthBlock>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn health_response_deserializes_minimal() {
        let json = r#"{"status":"ok"}"#;
        let resp: HealthResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.status, "ok");
        assert_eq!(resp.uptime_secs, 0);
        assert_eq!(resp.active_connections, 0);
        assert_eq!(resp.pipeline_lag_ms, 0);
    }

    #[test]
    fn health_response_deserializes_with_new_fields() {
        let json = r#"{"status":"ok","uptime_secs":3600,"active_connections":5,"pipeline_lag_ms":12}"#;
        let resp: HealthResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.status, "ok");
        assert_eq!(resp.uptime_secs, 3600);
        assert_eq!(resp.active_connections, 5);
        assert_eq!(resp.pipeline_lag_ms, 12);
    }

    #[test]
    fn healthz_response_deserializes_minimal_body() {
        let json = r#"{"mode":"local","version":"0.0.1","storage":"sqlite","uptime_secs":0}"#;
        let resp: HealthzResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.mode, "local");
        assert_eq!(resp.version, "0.0.1");
        assert_eq!(resp.storage, "sqlite");
        assert_eq!(resp.uptime_secs, 0);
        assert!(resp.storage_path.is_none());
        assert!(resp.database_url.is_none());
    }

    #[test]
    fn redact_database_url_replaces_postgres_password() {
        let redacted = redact_database_url("postgresql://aasm:secret@aasm-db:5432/aasm");
        assert_eq!(redacted, "postgresql://aasm:***@aasm-db:5432/aasm");
    }

    #[test]
    fn redact_database_url_leaves_no_password_url_unchanged() {
        let input = "postgresql://aasm@aasm-db:5432/aasm";
        assert_eq!(redact_database_url(input), input);
    }

    #[test]
    fn redact_database_url_leaves_sqlite_url_unchanged() {
        let input = "sqlite:///home/dev/.aasm/local.db";
        assert_eq!(redact_database_url(input), input);
    }

    #[test]
    fn redact_database_url_leaves_malformed_input_unchanged() {
        for input in ["~/.aasm/local.db", "not-a-url", "://no-scheme", ""] {
            assert_eq!(redact_database_url(input), input, "input: {input:?}");
        }
    }

    #[test]
    fn deployment_overview_serialises_with_documented_field_names() {
        let overview = DeploymentOverview {
            mode: "remote".to_string(),
            gateway_url: "https://cp.company.internal:7391".to_string(),
            storage_backend: "postgres".to_string(),
            storage_path: None,
            database_url_redacted: Some("postgresql://aasm:***@aasm-db:5432/aasm".to_string()),
            version: "0.0.1".to_string(),
            uptime_secs: 8133,
            health: "ok".to_string(),
        };
        let json = serde_json::to_value(&overview).expect("DeploymentOverview must serialise");
        assert_eq!(json["mode"], "remote");
        assert_eq!(json["gateway_url"], "https://cp.company.internal:7391");
        assert_eq!(json["storage_backend"], "postgres");
        assert_eq!(json["database_url_redacted"], "postgresql://aasm:***@aasm-db:5432/aasm");
        assert_eq!(json["version"], "0.0.1");
        assert_eq!(json["uptime_secs"], 8133);
        assert_eq!(json["health"], "ok");
        // storage_path = None must be omitted, not serialised as null.
        assert!(json.get("storage_path").is_none(), "Option::None must be skipped");
    }

    #[test]
    fn healthz_response_deserializes_with_storage_path_and_database_url() {
        let json = r#"{
            "mode": "remote",
            "version": "0.0.1",
            "storage": "postgres",
            "uptime_secs": 8133,
            "storage_path": "~/.aasm/local.db",
            "database_url": "postgresql://user:secret@aasm-db:5432/aasm"
        }"#;
        let resp: HealthzResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.mode, "remote");
        assert_eq!(resp.storage, "postgres");
        assert_eq!(resp.uptime_secs, 8133);
        assert_eq!(resp.storage_path.as_deref(), Some("~/.aasm/local.db"));
        assert_eq!(
            resp.database_url.as_deref(),
            Some("postgresql://user:secret@aasm-db:5432/aasm")
        );
    }

    #[test]
    fn admin_row_counts_block_deserialises_documented_keys() {
        let json = r#"{"audit_events_hot": 14293, "agents": 8, "policy_versions": 3}"#;
        let block: AdminRowCountsBlock = serde_json::from_str(json).unwrap();
        assert_eq!(block.audit_events_hot, 14_293);
        assert_eq!(block.agents, 8);
        assert_eq!(block.policy_versions, 3);
    }

    #[test]
    fn admin_row_counts_block_tolerates_extra_keys() {
        // Future warm/cold tier additions must not break older clients.
        let json = r#"{
            "audit_events_hot": 1,
            "audit_events_warm": 99,
            "agents": 1,
            "policy_versions": 1
        }"#;
        let block: AdminRowCountsBlock = serde_json::from_str(json).unwrap();
        assert_eq!(block.audit_events_hot, 1);
    }

    #[test]
    fn admin_status_response_deserialises_postgres_with_timescaledb() {
        let json = r#"{
            "mode": "remote",
            "version": "0.0.1",
            "uptime_secs": 86400,
            "storage": {
                "backend": "postgres",
                "database_url": "postgresql://aasm:***@db.internal:5432/aasm",
                "health": "ok",
                "latency_ms": 3,
                "row_counts": {
                    "audit_events_hot": 14293,
                    "agents": 8,
                    "policy_versions": 3
                },
                "timescaledb": {
                    "enabled": true,
                    "total_chunks": 12,
                    "compressed_chunks": 8,
                    "compression_ratio": 11.4
                }
            }
        }"#;
        let resp: AdminStatusResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.mode, "remote");
        assert_eq!(resp.storage.backend, "postgres");
        assert_eq!(resp.storage.health, "ok");
        assert_eq!(resp.storage.latency_ms, 3);
        assert!(resp.storage.path.is_none(), "postgres branch must omit path");
        assert_eq!(
            resp.storage.database_url.as_deref(),
            Some("postgresql://aasm:***@db.internal:5432/aasm")
        );
        let ts = resp.storage.timescaledb.expect("timescaledb block present");
        assert_eq!(ts.total_chunks, 12);
        assert_eq!(ts.compressed_chunks, 8);
    }

    #[test]
    fn admin_status_response_deserialises_sqlite_without_timescaledb() {
        let json = r#"{
            "mode": "local",
            "version": "0.0.1",
            "uptime_secs": 60,
            "storage": {
                "backend": "sqlite",
                "path": "~/.aasm/local.db",
                "health": "ok",
                "latency_ms": 1,
                "row_counts": {
                    "audit_events_hot": 47,
                    "agents": 2,
                    "policy_versions": 1
                }
            }
        }"#;
        let resp: AdminStatusResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.storage.backend, "sqlite");
        assert_eq!(resp.storage.path.as_deref(), Some("~/.aasm/local.db"));
        assert!(
            resp.storage.database_url.is_none(),
            "sqlite branch must omit database_url"
        );
        assert!(resp.storage.timescaledb.is_none(), "sqlite must omit timescaledb block");
    }

    #[test]
    fn admin_timescaledb_block_deserialises_documented_keys() {
        let json = r#"{
            "enabled": true,
            "total_chunks": 12,
            "compressed_chunks": 8,
            "compression_ratio": 11.4
        }"#;
        let block: AdminTimescaleDbBlock = serde_json::from_str(json).unwrap();
        assert!(block.enabled);
        assert_eq!(block.total_chunks, 12);
        assert_eq!(block.compressed_chunks, 8);
        assert!((block.compression_ratio - 11.4).abs() < 0.05);
    }

    #[test]
    fn agent_response_deserializes() {
        let json = r#"{
            "id": "abc123",
            "name": "support-agent",
            "framework": "langgraph",
            "version": "1.0.0",
            "status": "Running",
            "tool_names": ["query_db", "send_slack"],
            "metadata": {"team": "support"}
        }"#;
        let resp: AgentResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.id, "abc123");
        assert_eq!(resp.name, "support-agent");
        assert_eq!(resp.framework, "langgraph");
        assert_eq!(resp.tool_names.len(), 2);
        assert_eq!(resp.metadata.get("team").unwrap(), "support");
        // New fields default when missing from JSON.
        assert_eq!(resp.session_count, 0);
        assert_eq!(resp.policy_violations_count, 0);
        assert!(resp.layer.is_none());
        assert!(resp.last_event.is_none());
        assert!(resp.recent_events.is_empty());
    }

    #[test]
    fn agent_response_deserializes_with_new_fields() {
        let json = r#"{
            "id": "abc123",
            "name": "full-agent",
            "framework": "crewai",
            "version": "2.0.0",
            "status": "Active",
            "tool_names": [],
            "metadata": {},
            "session_count": 5,
            "policy_violations_count": 2,
            "layer": "enforced",
            "last_event": "2026-05-01T08:00:00Z",
            "recent_events": [
                {"event_type": "tool_call", "summary": "called bash", "timestamp": "2026-05-01T08:00:00Z"}
            ]
        }"#;
        let resp: AgentResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.session_count, 5);
        assert_eq!(resp.policy_violations_count, 2);
        assert_eq!(resp.layer.as_deref(), Some("enforced"));
        assert_eq!(resp.last_event.as_deref(), Some("2026-05-01T08:00:00Z"));
        assert_eq!(resp.recent_events.len(), 1);
        assert_eq!(resp.recent_events[0].event_type, "tool_call");
    }

    #[test]
    fn approval_response_deserializes() {
        let json = r#"{
            "id": "ap-001",
            "agent_id": "abc123",
            "action": "process_refund",
            "reason": "amount exceeds $100",
            "status": "pending",
            "created_at": "2026-04-30T10:00:00Z"
        }"#;
        let resp: ApprovalResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.id, "ap-001");
        assert_eq!(resp.status, "pending");
        assert_eq!(resp.created_at, "2026-04-30T10:00:00Z");
        // routing fields default to empty when missing from JSON
        assert!(resp.team_id.is_empty());
        assert!(resp.routing_status.is_empty());
    }

    #[test]
    fn approval_response_deserializes_with_routing_fields() {
        let json = r#"{
            "id": "ap-002",
            "agent_id": "abc123",
            "action": "dangerous_action",
            "reason": "requires_approval",
            "status": "pending",
            "created_at": "2026-05-01T09:00:00Z",
            "team_id": "team-x",
            "routing_status": "routed:team-x"
        }"#;
        let resp: ApprovalResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.team_id, "team-x");
        assert_eq!(resp.routing_status, "routed:team-x");
    }

    #[test]
    fn cost_response_deserializes() {
        let json = r#"{
            "daily_spend_usd": "8.10",
            "monthly_spend_usd": "142.50",
            "date": "2026-04-30",
            "daily_limit_usd": "100.00",
            "monthly_limit_usd": "2000.00",
            "per_agent": [
                {"agent_id": "abc123", "daily_spend_usd": "4.10"}
            ]
        }"#;
        let resp: CostResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.daily_spend_usd, "8.10");
        assert_eq!(resp.monthly_spend_usd.as_deref(), Some("142.50"));
        assert_eq!(resp.date, "2026-04-30");
        assert_eq!(resp.daily_limit_usd.as_deref(), Some("100.00"));
        assert_eq!(resp.monthly_limit_usd.as_deref(), Some("2000.00"));
        assert_eq!(resp.per_agent.len(), 1);
        assert_eq!(resp.per_agent[0].agent_id, "abc123");
        assert_eq!(resp.per_agent[0].daily_spend_usd, "4.10");
    }

    #[test]
    fn cost_response_deserializes_without_monthly() {
        let json = r#"{"daily_spend_usd": "0.00", "date": "2026-04-30"}"#;
        let resp: CostResponse = serde_json::from_str(json).unwrap();
        assert!(resp.monthly_spend_usd.is_none());
    }

    #[test]
    fn cost_response_deserializes_without_new_fields() {
        let json = r#"{
            "daily_spend_usd": "5.00",
            "monthly_spend_usd": "50.00",
            "date": "2026-04-30"
        }"#;
        let resp: CostResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.daily_spend_usd, "5.00");
        assert!(resp.daily_limit_usd.is_none());
        assert!(resp.monthly_limit_usd.is_none());
        assert!(resp.per_agent.is_empty());
    }
}