1use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use crate::store::AgentKind;
33
34pub const PROTOCOL_VERSION: u16 = 2;
41
42pub const MAX_FRAME_SIZE: usize = 65_536;
50
51#[derive(Debug, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Request {
60 pub v: u16,
62 pub id: Uuid,
64 pub session: Uuid,
68 #[serde(default)]
73 pub agent: Option<AgentKind>,
74 pub cmd: Command,
76}
77
78#[derive(Debug, Serialize)]
82#[serde(tag = "status")]
83pub enum Response {
84 #[serde(rename = "ok")]
86 Ok { id: Uuid, data: serde_json::Value },
87 #[serde(rename = "err")]
90 Err {
91 id: Uuid,
92 code: ErrorCode,
93 message: String,
94 },
95}
96
97impl Response {
98 pub fn ok(id: Uuid, data: serde_json::Value) -> Self {
100 Self::Ok { id, data }
101 }
102
103 pub fn err(id: Uuid, code: ErrorCode, message: impl Into<String>) -> Self {
105 Self::Err {
106 id,
107 code,
108 message: message.into(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub enum ErrorCode {
126 VersionMismatch,
128 FrameTooLarge,
130 MalformedRequest,
132 SessionMismatch,
135 ValidationFailed,
137 NotFound,
139 Conflict,
141 InvalidStateTransition,
143 StoreError,
145 Internal,
147}
148
149#[derive(Debug, Serialize, Deserialize)]
158#[serde(tag = "type")]
159pub enum Command {
160 #[serde(rename = "ping")]
163 Ping,
164
165 #[serde(rename = "metrics")]
168 Metrics,
169
170 #[serde(rename = "get")]
172 Get(GetInput),
173
174 #[serde(rename = "hook_evaluate")]
176 HookEvaluate(HookEvaluateInput),
177
178 #[serde(rename = "scan_prefix")]
180 ScanPrefix(ScanPrefixInput),
181
182 #[serde(rename = "history")]
184 History(HistoryInput),
185
186 #[serde(rename = "history_since")]
188 HistorySince(HistorySinceInput),
189
190 #[serde(rename = "session_check_consulted")]
192 SessionCheckConsulted(SessionCheckConsultedInput),
193
194 #[serde(rename = "session_check_consulted_recent")]
196 SessionCheckConsultedRecent(SessionCheckConsultedRecentInput),
197
198 #[serde(rename = "mem_query")]
200 MemQuery(MemQueryInput),
201
202 #[serde(rename = "scan_enforcement_events")]
204 ScanEnforcementEvents(ScanEnforcementEventsInput),
205
206 #[serde(rename = "config_get")]
209 ConfigGet(ConfigGetInput),
210
211 #[serde(rename = "mem_get")]
214 MemGet(MemGetInput),
215
216 #[serde(rename = "mem_bootstrap")]
218 MemBootstrap(MemBootstrapInput),
219
220 #[serde(rename = "gotcha_upsert")]
223 GotchaUpsert(GotchaDraftInput),
224
225 #[serde(rename = "gotcha_confirm")]
227 GotchaConfirm(GotchaConfirmInput),
228
229 #[serde(rename = "gotcha_tombstone")]
231 GotchaTombstone(GotchaTombstoneInput),
232
233 #[serde(rename = "file_enrich")]
236 FileEnrich(FileEnrichInput),
237
238 #[serde(rename = "file_reparse")]
240 FileReparse(FileReparseInput),
241
242 #[serde(rename = "file_edit_hook")]
244 FileEditHook(FileEditHookInput),
245
246 #[serde(rename = "doc_capture")]
248 DocCapture(DocCaptureInput),
249
250 #[serde(rename = "decision_upsert")]
252 DecisionUpsert(DecisionUpsertInput),
253
254 #[serde(rename = "dev_note_upsert")]
256 DevNoteUpsert(DevNoteUpsertInput),
257
258 #[serde(rename = "config_set")]
261 ConfigSet(ConfigSetInput),
262
263 #[serde(rename = "session_log")]
265 SessionLog(SessionLogInput),
266
267 #[serde(rename = "consultation_hit")]
269 ConsultationHit(ConsultationHitInput),
270
271 #[serde(rename = "session_flush")]
273 SessionFlush,
274
275 #[serde(rename = "session_harvest")]
277 SessionHarvest,
278
279 #[serde(rename = "record_import")]
291 RecordImport(RecordImportInput),
292}
293
294#[derive(Debug, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct GetInput {
304 pub key: String,
305}
306
307#[derive(Debug, Serialize, Deserialize)]
308#[serde(deny_unknown_fields)]
309pub struct HookEvaluateInput {
310 pub file_key: String,
311 #[serde(default)]
312 pub include_recent: bool,
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct ScanPrefixInput {
318 pub prefix: String,
319}
320
321#[derive(Debug, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct ScanEnforcementEventsInput {
324 #[serde(default)]
325 pub since_seq: u64,
326 #[serde(default = "default_until_seq")]
327 pub until_seq: u64,
328}
329
330fn default_until_seq() -> u64 {
331 u64::MAX
332}
333
334#[derive(Debug, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct HistoryInput {
337 pub key: String,
338 #[serde(default = "default_history_limit")]
339 pub limit: u64,
340}
341
342#[derive(Debug, Serialize, Deserialize)]
343#[serde(deny_unknown_fields)]
344pub struct HistorySinceInput {
345 pub key: String,
346 pub since_ts: u64,
347 #[serde(default = "default_history_limit")]
348 pub limit: u64,
349}
350
351fn default_history_limit() -> u64 {
352 50
353}
354
355#[derive(Debug, Serialize, Deserialize)]
356#[serde(deny_unknown_fields)]
357pub struct SessionCheckConsultedInput {
358 pub key: String,
359}
360
361#[derive(Debug, Serialize, Deserialize)]
362#[serde(deny_unknown_fields)]
363pub struct SessionCheckConsultedRecentInput {
364 pub key: String,
365 #[serde(default = "default_ttl_secs")]
366 pub ttl_secs: u64,
367}
368
369fn default_ttl_secs() -> u64 {
370 900
371}
372
373#[derive(Debug, Serialize, Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct MemQueryInput {
376 pub query: String,
377 #[serde(default = "default_query_mode")]
378 pub mode: QueryMode,
379 #[serde(default = "default_query_limit")]
380 pub limit: u32,
381}
382
383fn default_query_mode() -> QueryMode {
384 QueryMode::Text
385}
386
387fn default_query_limit() -> u32 {
388 20
389}
390
391#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
393#[serde(rename_all = "snake_case")]
394pub enum QueryMode {
395 Text,
397 Tag,
399 Graph,
401 Semantic,
403}
404
405#[derive(Debug, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct MemGetInput {
410 pub key: String,
411}
412
413#[derive(Debug, Serialize, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct MemBootstrapInput {
416 #[serde(default)]
417 pub context_files: Vec<String>,
418}
419
420#[derive(Debug, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct GotchaDraftInput {
430 pub key: String,
432 pub rule: String,
434 pub reason: String,
436 pub severity: Severity,
438 #[serde(default)]
440 pub affected_files: Vec<String>,
441 #[serde(default)]
443 pub ref_url: Option<String>,
444 #[serde(default)]
446 pub tags: Vec<String>,
447 #[serde(default)]
449 pub priority: Priority,
450 #[serde(default)]
453 pub source: Option<String>,
454}
455
456#[derive(Debug, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct GotchaConfirmInput {
459 pub key: String,
460}
461
462#[derive(Debug, Serialize, Deserialize)]
463#[serde(deny_unknown_fields)]
464pub struct GotchaTombstoneInput {
465 pub key: String,
466}
467
468#[derive(Debug, Serialize, Deserialize)]
476#[serde(deny_unknown_fields)]
477pub struct FileEnrichInput {
478 pub path: String,
480 pub purpose: String,
482 #[serde(default)]
484 pub entry_points: Vec<String>,
485 #[serde(default)]
487 pub decision_keys: Vec<String>,
488 #[serde(default)]
490 pub todos: Vec<String>,
491 #[serde(default)]
493 pub tags: Vec<String>,
494 #[serde(default)]
496 pub priority: Priority,
497}
498
499#[derive(Debug, Serialize, Deserialize)]
500#[serde(deny_unknown_fields)]
501pub struct FileReparseInput {
502 pub path: String,
503}
504
505#[derive(Debug, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct FileEditHookInput {
508 pub path: String,
509}
510
511#[derive(Debug, Serialize, Deserialize)]
514#[serde(deny_unknown_fields)]
515pub struct DocCaptureInput {
516 pub path: String,
517}
518
519#[derive(Debug, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct DecisionUpsertInput {
522 pub slug: String,
524 pub value: String,
526 pub summary: String,
528 pub rationale: String,
530 #[serde(default)]
532 pub tags: Vec<String>,
533 #[serde(default)]
535 pub priority: Priority,
536}
537
538#[derive(Debug, Serialize, Deserialize)]
539#[serde(deny_unknown_fields)]
540pub struct DevNoteUpsertInput {
541 #[serde(default)]
544 pub key: Option<String>,
545 pub text: String,
547 #[serde(default)]
549 pub tags: Vec<String>,
550 #[serde(default)]
552 pub priority: Priority,
553}
554
555#[derive(Debug, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct SessionLogInput {
558 pub event: SessionEvent,
560 pub key: String,
562}
563
564#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
569#[serde(rename_all = "snake_case")]
570pub enum SessionEvent {
571 Miss,
572 ComplianceMiss,
573 ComplianceHit,
574 CodexShellMiss,
575 Bootstrap,
576 PromptNudge,
577}
578
579#[derive(Debug, Serialize, Deserialize)]
580#[serde(deny_unknown_fields)]
581pub struct ConsultationHitInput {
582 pub key: String,
583}
584
585#[derive(Debug, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct RecordImportInput {
591 pub records: Vec<crate::store::Record>,
592}
593
594#[derive(Debug, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct ConfigGetInput {
599 pub key: String,
600}
601
602#[derive(Debug, Serialize, Deserialize)]
605#[serde(deny_unknown_fields)]
606pub struct ConfigSetInput {
607 pub key: String,
608 pub value: String,
609}
610
611#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
615#[serde(rename_all = "snake_case")]
616pub enum Severity {
617 Critical,
618 High,
619 #[default]
620 Normal,
621 Low,
622}
623
624#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
626#[serde(rename_all = "snake_case")]
627pub enum Priority {
628 Critical,
629 High,
630 #[default]
631 Normal,
632 Low,
633}
634
635impl From<crate::store::Priority> for Severity {
638 fn from(p: crate::store::Priority) -> Self {
639 match p {
640 crate::store::Priority::Low => Severity::Low,
641 crate::store::Priority::Normal => Severity::Normal,
642 crate::store::Priority::High => Severity::High,
643 crate::store::Priority::Critical => Severity::Critical,
644 }
645 }
646}
647
648impl From<crate::store::Priority> for Priority {
649 fn from(p: crate::store::Priority) -> Self {
650 match p {
651 crate::store::Priority::Low => Priority::Low,
652 crate::store::Priority::Normal => Priority::Normal,
653 crate::store::Priority::High => Priority::High,
654 crate::store::Priority::Critical => Priority::Critical,
655 }
656 }
657}
658
659impl Command {
662 pub fn kind(&self) -> &'static str {
665 match self {
666 Self::Ping => "ping",
667 Self::Metrics => "metrics",
668 Self::Get(_) => "get",
669 Self::HookEvaluate(_) => "hook_evaluate",
670 Self::ScanPrefix(_) => "scan_prefix",
671 Self::History(_) => "history",
672 Self::HistorySince(_) => "history_since",
673 Self::SessionCheckConsulted(_) => "session_check_consulted",
674 Self::SessionCheckConsultedRecent(_) => "session_check_consulted_recent",
675 Self::MemQuery(_) => "mem_query",
676 Self::ScanEnforcementEvents(_) => "scan_enforcement_events",
677 Self::ConfigGet(_) => "config_get",
678 Self::ConfigSet(_) => "config_set",
679 Self::MemGet(_) => "mem_get",
680 Self::MemBootstrap(_) => "mem_bootstrap",
681 Self::GotchaUpsert(_) => "gotcha_upsert",
682 Self::GotchaConfirm(_) => "gotcha_confirm",
683 Self::GotchaTombstone(_) => "gotcha_tombstone",
684 Self::FileEnrich(_) => "file_enrich",
685 Self::FileReparse(_) => "file_reparse",
686 Self::FileEditHook(_) => "file_edit_hook",
687 Self::DocCapture(_) => "doc_capture",
688 Self::DecisionUpsert(_) => "decision_upsert",
689 Self::DevNoteUpsert(_) => "dev_note_upsert",
690 Self::SessionLog(_) => "session_log",
691 Self::ConsultationHit(_) => "consultation_hit",
692 Self::SessionFlush => "session_flush",
693 Self::SessionHarvest => "session_harvest",
694 Self::RecordImport(_) => "record_import",
695 }
696 }
697
698 pub fn target_key(&self) -> &str {
701 match self {
702 Self::Get(i) => &i.key,
703 Self::HookEvaluate(i) => &i.file_key,
704 Self::ScanPrefix(i) => &i.prefix,
705 Self::History(i) => &i.key,
706 Self::HistorySince(i) => &i.key,
707 Self::SessionCheckConsulted(i) => &i.key,
708 Self::SessionCheckConsultedRecent(i) => &i.key,
709 Self::MemQuery(i) => &i.query,
710 Self::MemGet(i) => &i.key,
711 Self::GotchaUpsert(i) => &i.key,
712 Self::GotchaConfirm(i) => &i.key,
713 Self::GotchaTombstone(i) => &i.key,
714 Self::FileEnrich(i) => &i.path,
715 Self::FileReparse(i) => &i.path,
716 Self::FileEditHook(i) => &i.path,
717 Self::DocCapture(i) => &i.path,
718 Self::DecisionUpsert(i) => &i.slug,
719 Self::DevNoteUpsert(i) => i.key.as_deref().unwrap_or(""),
720 Self::SessionLog(i) => &i.key,
721 Self::ConsultationHit(i) => &i.key,
722 Self::ConfigGet(i) => &i.key,
723 Self::ConfigSet(i) => &i.key,
724 Self::Ping
725 | Self::Metrics
726 | Self::MemBootstrap(_)
727 | Self::ScanEnforcementEvents(_)
728 | Self::SessionFlush
729 | Self::SessionHarvest
730 | Self::RecordImport(_) => "",
731 }
732 }
733
734 pub fn is_mutation(&self) -> bool {
741 matches!(
742 self,
743 Self::MemGet(_)
745 | Self::MemBootstrap(_)
746 | Self::GotchaUpsert(_)
748 | Self::GotchaConfirm(_)
749 | Self::GotchaTombstone(_)
750 | Self::FileEnrich(_)
751 | Self::FileReparse(_)
752 | Self::FileEditHook(_)
753 | Self::DocCapture(_)
754 | Self::DecisionUpsert(_)
755 | Self::DevNoteUpsert(_)
756 | Self::SessionLog(_)
757 | Self::ConsultationHit(_)
758 | Self::ConfigSet(_)
759 | Self::SessionFlush
760 | Self::SessionHarvest
761 | Self::RecordImport(_)
762 )
763 }
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
777pub struct AuditEntry {
778 pub ts: u64,
780 pub peer_uid: u32,
782 pub peer_pid: Option<u32>,
784 pub daemon_session: Uuid,
786 pub request_id: Uuid,
788 pub command_kind: String,
790 pub target_key: String,
792 pub accepted: bool,
794 #[serde(skip_serializing_if = "Option::is_none")]
796 pub error_code: Option<ErrorCode>,
797}
798
799pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
814 use serde_json::json;
815
816 match cmd {
817 "ping" => json!({"type": "ping"}),
819 "metrics" => json!({"type": "metrics"}),
820 "get" => json!({"type": "get", "key": args["key"]}),
821 "hook_evaluate" => json!({
822 "type": "hook_evaluate",
823 "file_key": args["file_key"],
824 "include_recent": args.get("include_recent").and_then(|v| v.as_bool()).unwrap_or(false),
825 }),
826 "scan_prefix" => json!({"type": "scan_prefix", "prefix": args["prefix"]}),
827 "history" => {
828 json!({"type": "history", "key": args["key"], "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50)})
829 }
830 "history_since" => json!({
831 "type": "history_since",
832 "key": args["key"],
833 "since_ts": args.get("since_ts").and_then(|v| v.as_u64()).unwrap_or(0),
834 "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50),
835 }),
836 "session_check_consulted" => json!({"type": "session_check_consulted", "key": args["key"]}),
837 "session_check_consulted_recent" => json!({
838 "type": "session_check_consulted_recent",
839 "key": args["key"],
840 "ttl_secs": args.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(900),
841 }),
842 "mem_query" => json!({
843 "type": "mem_query",
844 "query": args["query"],
845 "mode": args.get("mode").and_then(|v| v.as_str()).unwrap_or("text"),
846 "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20),
847 }),
848 "scan_enforcement_events" => json!({
849 "type": "scan_enforcement_events",
850 "since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
851 "until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
852 }),
853 "mem_get" => json!({"type": "mem_get", "key": args["key"]}),
861 "mem_bootstrap" => json!({
862 "type": "mem_bootstrap",
863 "context_files": args.get("context_files").cloned().unwrap_or_else(|| serde_json::json!([])),
864 }),
865 other => {
866 panic!(
867 "v1_to_v2_command called with unsupported command '{other}' — \
868 only pure reads are supported; mutation/side-effecting callers \
869 must use daemon_v2() with typed Command"
870 );
871 }
872 }
873}
874
875#[cfg(test)]
878mod tests {
879 use super::*;
880
881 #[test]
888 fn query_mode_deserialize_rejects_unknown_variant() {
889 let result: Result<QueryMode, _> = serde_json::from_str("\"invalid_mode\"");
890 assert!(
891 result.is_err(),
892 "QueryMode deserialization must reject unknown variants, got: {result:?}"
893 );
894 }
895
896 #[test]
897 fn query_mode_deserialize_accepts_all_known_variants() {
898 for variant in &["text", "tag", "graph", "semantic"] {
900 let json = format!("\"{variant}\"");
901 let result: Result<QueryMode, _> = serde_json::from_str(&json);
902 assert!(
903 result.is_ok(),
904 "QueryMode must accept {variant:?}, got: {result:?}"
905 );
906 }
907 }
908
909 #[test]
910 fn valid_v2_ping_request_decodes() {
911 let json = serde_json::json!({
912 "v": 2,
913 "id": "550e8400-e29b-41d4-a716-446655440000",
914 "session": "660e8400-e29b-41d4-a716-446655440000",
915 "cmd": { "type": "ping" }
916 });
917 let req: Request = serde_json::from_value(json).unwrap();
918 assert_eq!(req.v, PROTOCOL_VERSION);
919 assert!(matches!(req.cmd, Command::Ping));
920 }
921
922 #[test]
923 fn valid_v2_get_request_decodes() {
924 let json = serde_json::json!({
925 "v": 2,
926 "id": "550e8400-e29b-41d4-a716-446655440000",
927 "session": "660e8400-e29b-41d4-a716-446655440000",
928 "cmd": { "type": "get", "key": "file:src/main.rs" }
929 });
930 let req: Request = serde_json::from_value(json).unwrap();
931 match req.cmd {
932 Command::Get(input) => assert_eq!(input.key, "file:src/main.rs"),
933 _ => panic!("expected Get"),
934 }
935 }
936
937 #[test]
938 fn valid_gotcha_upsert_decodes() {
939 let json = serde_json::json!({
940 "v": 2,
941 "id": "550e8400-e29b-41d4-a716-446655440000",
942 "session": "660e8400-e29b-41d4-a716-446655440000",
943 "cmd": {
944 "type": "gotcha_upsert",
945 "key": "gotcha:stripe-idempotency",
946 "rule": "Always include an idempotency key",
947 "reason": "Stripe retries without it cause double charges",
948 "severity": "high",
949 "affected_files": ["src/payments/stripe.rs"],
950 "tags": ["payments", "stripe"]
951 }
952 });
953 let req: Request = serde_json::from_value(json).unwrap();
954 match req.cmd {
955 Command::GotchaUpsert(input) => {
956 assert_eq!(input.key, "gotcha:stripe-idempotency");
957 assert_eq!(input.severity, Severity::High);
958 assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
959 assert_eq!(input.priority, Priority::Normal); }
961 _ => panic!("expected GotchaUpsert"),
962 }
963 }
964
965 #[test]
966 fn valid_decision_upsert_decodes() {
967 let json = serde_json::json!({
968 "v": 2,
969 "id": "550e8400-e29b-41d4-a716-446655440000",
970 "session": "660e8400-e29b-41d4-a716-446655440000",
971 "cmd": {
972 "type": "decision_upsert",
973 "slug": "unified-retry-strategy",
974 "value": "We use exponential backoff because linear retry overloads downstream",
975 "summary": "Exponential backoff for all retries",
976 "rationale": "Linear retry caused cascading failures in prod 2024-01"
977 }
978 });
979 let req: Request = serde_json::from_value(json).unwrap();
980 match req.cmd {
981 Command::DecisionUpsert(input) => {
982 assert_eq!(input.slug, "unified-retry-strategy");
983 assert!(!input.rationale.is_empty());
984 }
985 _ => panic!("expected DecisionUpsert"),
986 }
987 }
988
989 #[test]
990 fn valid_session_log_decodes() {
991 let json = serde_json::json!({
992 "v": 2,
993 "id": "550e8400-e29b-41d4-a716-446655440000",
994 "session": "660e8400-e29b-41d4-a716-446655440000",
995 "cmd": {
996 "type": "session_log",
997 "event": "compliance_miss",
998 "key": "file:src/main.rs"
999 }
1000 });
1001 let req: Request = serde_json::from_value(json).unwrap();
1002 match req.cmd {
1003 Command::SessionLog(input) => {
1004 assert_eq!(input.event, SessionEvent::ComplianceMiss);
1005 assert_eq!(input.key, "file:src/main.rs");
1006 }
1007 _ => panic!("expected SessionLog"),
1008 }
1009 }
1010
1011 #[test]
1012 fn valid_file_enrich_decodes() {
1013 let json = serde_json::json!({
1014 "v": 2,
1015 "id": "550e8400-e29b-41d4-a716-446655440000",
1016 "session": "660e8400-e29b-41d4-a716-446655440000",
1017 "cmd": {
1018 "type": "file_enrich",
1019 "path": "src/store/db.rs",
1020 "purpose": "Own the storage boundary for all SurrealKV operations",
1021 "entry_points": ["open", "put", "get"],
1022 "decision_keys": ["decision:storage-engine"]
1023 }
1024 });
1025 let req: Request = serde_json::from_value(json).unwrap();
1026 match req.cmd {
1027 Command::FileEnrich(input) => {
1028 assert_eq!(input.path, "src/store/db.rs");
1029 assert_eq!(input.entry_points.len(), 3);
1030 assert!(input.todos.is_empty()); }
1032 _ => panic!("expected FileEnrich"),
1033 }
1034 }
1035
1036 #[test]
1039 fn bad_version_still_decodes_for_error_handling() {
1040 let json = serde_json::json!({
1042 "v": 99,
1043 "id": "550e8400-e29b-41d4-a716-446655440000",
1044 "session": "660e8400-e29b-41d4-a716-446655440000",
1045 "cmd": { "type": "ping" }
1046 });
1047 let req: Request = serde_json::from_value(json).unwrap();
1048 assert_ne!(req.v, PROTOCOL_VERSION);
1049 }
1050
1051 #[test]
1052 fn unknown_field_in_request_rejected() {
1053 let json = serde_json::json!({
1054 "v": 2,
1055 "id": "550e8400-e29b-41d4-a716-446655440000",
1056 "session": "660e8400-e29b-41d4-a716-446655440000",
1057 "cmd": { "type": "ping" },
1058 "extra_field": true
1059 });
1060 let result = serde_json::from_value::<Request>(json);
1061 assert!(result.is_err(), "unknown top-level field must be rejected");
1062 }
1063
1064 #[test]
1065 fn unknown_field_in_command_args_rejected() {
1066 let json = serde_json::json!({
1067 "v": 2,
1068 "id": "550e8400-e29b-41d4-a716-446655440000",
1069 "session": "660e8400-e29b-41d4-a716-446655440000",
1070 "cmd": { "type": "get", "key": "file:foo", "smuggled": true }
1071 });
1072 let result = serde_json::from_value::<Request>(json);
1073 assert!(
1074 result.is_err(),
1075 "unknown field in command args must be rejected"
1076 );
1077 }
1078
1079 #[test]
1080 fn unknown_command_type_rejected() {
1081 let json = serde_json::json!({
1082 "v": 2,
1083 "id": "550e8400-e29b-41d4-a716-446655440000",
1084 "session": "660e8400-e29b-41d4-a716-446655440000",
1085 "cmd": { "type": "raw_put", "key": "gotcha:x", "value": "hacked" }
1086 });
1087 let result = serde_json::from_value::<Request>(json);
1088 assert!(result.is_err(), "unknown command type must be rejected");
1089 }
1090
1091 #[test]
1092 fn malformed_uuid_rejected() {
1093 let json = serde_json::json!({
1094 "v": 2,
1095 "id": "not-a-uuid",
1096 "session": "660e8400-e29b-41d4-a716-446655440000",
1097 "cmd": { "type": "ping" }
1098 });
1099 let result = serde_json::from_value::<Request>(json);
1100 assert!(result.is_err(), "malformed UUID must be rejected");
1101 }
1102
1103 #[test]
1104 fn missing_session_rejected() {
1105 let json = serde_json::json!({
1106 "v": 2,
1107 "id": "550e8400-e29b-41d4-a716-446655440000",
1108 "cmd": { "type": "ping" }
1109 });
1110 let result = serde_json::from_value::<Request>(json);
1111 assert!(result.is_err(), "missing session UUID must be rejected");
1112 }
1113
1114 #[test]
1115 fn gotcha_upsert_rejects_server_owned_fields() {
1116 let json = serde_json::json!({
1118 "v": 2,
1119 "id": "550e8400-e29b-41d4-a716-446655440000",
1120 "session": "660e8400-e29b-41d4-a716-446655440000",
1121 "cmd": {
1122 "type": "gotcha_upsert",
1123 "key": "gotcha:test",
1124 "rule": "test rule",
1125 "reason": "test reason",
1126 "severity": "normal",
1127 "confirmed": true
1128 }
1129 });
1130 let result = serde_json::from_value::<Request>(json);
1131 assert!(
1132 result.is_err(),
1133 "server-owned field `confirmed` must be rejected"
1134 );
1135 }
1136
1137 #[test]
1138 fn file_enrich_rejects_gotcha_keys() {
1139 let json = serde_json::json!({
1141 "v": 2,
1142 "id": "550e8400-e29b-41d4-a716-446655440000",
1143 "session": "660e8400-e29b-41d4-a716-446655440000",
1144 "cmd": {
1145 "type": "file_enrich",
1146 "path": "src/main.rs",
1147 "purpose": "entry point",
1148 "gotcha_keys": ["gotcha:smuggled"]
1149 }
1150 });
1151 let result = serde_json::from_value::<Request>(json);
1152 assert!(
1153 result.is_err(),
1154 "daemon-managed field `gotcha_keys` must be rejected"
1155 );
1156 }
1157
1158 #[test]
1159 fn file_enrich_rejects_imports() {
1160 let json = serde_json::json!({
1162 "v": 2,
1163 "id": "550e8400-e29b-41d4-a716-446655440000",
1164 "session": "660e8400-e29b-41d4-a716-446655440000",
1165 "cmd": {
1166 "type": "file_enrich",
1167 "path": "src/main.rs",
1168 "purpose": "entry point",
1169 "imports": ["std::io"]
1170 }
1171 });
1172 let result = serde_json::from_value::<Request>(json);
1173 assert!(
1174 result.is_err(),
1175 "daemon-derived field `imports` must be rejected"
1176 );
1177 }
1178
1179 #[test]
1180 fn invalid_severity_rejected() {
1181 let json = serde_json::json!({
1182 "v": 2,
1183 "id": "550e8400-e29b-41d4-a716-446655440000",
1184 "session": "660e8400-e29b-41d4-a716-446655440000",
1185 "cmd": {
1186 "type": "gotcha_upsert",
1187 "key": "gotcha:test",
1188 "rule": "test",
1189 "reason": "test",
1190 "severity": "EXTREME"
1191 }
1192 });
1193 let result = serde_json::from_value::<Request>(json);
1194 assert!(
1195 result.is_err(),
1196 "invalid severity enum value must be rejected"
1197 );
1198 }
1199
1200 #[test]
1201 fn invalid_session_event_rejected() {
1202 let json = serde_json::json!({
1203 "v": 2,
1204 "id": "550e8400-e29b-41d4-a716-446655440000",
1205 "session": "660e8400-e29b-41d4-a716-446655440000",
1206 "cmd": {
1207 "type": "session_log",
1208 "event": "hit",
1209 "key": "file:foo"
1210 }
1211 });
1212 let result = serde_json::from_value::<Request>(json);
1213 assert!(
1214 result.is_err(),
1215 "hit is not a SessionEvent variant — must use consultation_hit command"
1216 );
1217 }
1218
1219 #[test]
1222 fn ok_response_serializes() {
1223 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1224 let resp = Response::ok(id, serde_json::json!({"pong": true}));
1225 let json = serde_json::to_value(&resp).unwrap();
1226 assert_eq!(json["status"], "ok");
1227 assert_eq!(json["data"]["pong"], true);
1228 }
1229
1230 #[test]
1231 fn err_response_serializes_with_code() {
1232 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1233 let resp = Response::err(id, ErrorCode::ValidationFailed, "key must not be empty");
1234 let json = serde_json::to_value(&resp).unwrap();
1235 assert_eq!(json["status"], "err");
1236 assert_eq!(json["code"], "validation_failed");
1237 assert_eq!(json["message"], "key must not be empty");
1238 }
1239
1240 #[test]
1241 fn error_code_roundtrips() {
1242 let codes = vec![
1243 ErrorCode::VersionMismatch,
1244 ErrorCode::FrameTooLarge,
1245 ErrorCode::MalformedRequest,
1246 ErrorCode::SessionMismatch,
1247 ErrorCode::ValidationFailed,
1248 ErrorCode::NotFound,
1249 ErrorCode::Conflict,
1250 ErrorCode::InvalidStateTransition,
1251 ErrorCode::StoreError,
1252 ErrorCode::Internal,
1253 ];
1254 for code in codes {
1255 let json = serde_json::to_value(&code).unwrap();
1256 let back: ErrorCode = serde_json::from_value(json).unwrap();
1257 assert_eq!(back, code);
1258 }
1259 }
1260
1261 #[test]
1264 fn session_flush_decodes() {
1265 let json = serde_json::json!({
1266 "v": 2,
1267 "id": "550e8400-e29b-41d4-a716-446655440000",
1268 "session": "660e8400-e29b-41d4-a716-446655440000",
1269 "cmd": { "type": "session_flush" }
1270 });
1271 let req: Request = serde_json::from_value(json).unwrap();
1272 assert!(matches!(req.cmd, Command::SessionFlush));
1273 }
1274
1275 #[test]
1276 fn session_harvest_decodes() {
1277 let json = serde_json::json!({
1278 "v": 2,
1279 "id": "550e8400-e29b-41d4-a716-446655440000",
1280 "session": "660e8400-e29b-41d4-a716-446655440000",
1281 "cmd": { "type": "session_harvest" }
1282 });
1283 let req: Request = serde_json::from_value(json).unwrap();
1284 assert!(matches!(req.cmd, Command::SessionHarvest));
1285 }
1286
1287 #[test]
1288 fn dev_note_upsert_create_mode() {
1289 let json = serde_json::json!({
1290 "v": 2,
1291 "id": "550e8400-e29b-41d4-a716-446655440000",
1292 "session": "660e8400-e29b-41d4-a716-446655440000",
1293 "cmd": {
1294 "type": "dev_note_upsert",
1295 "text": "Remember to update the changelog"
1296 }
1297 });
1298 let req: Request = serde_json::from_value(json).unwrap();
1299 match req.cmd {
1300 Command::DevNoteUpsert(input) => {
1301 assert!(input.key.is_none()); assert_eq!(input.text, "Remember to update the changelog");
1303 }
1304 _ => panic!("expected DevNoteUpsert"),
1305 }
1306 }
1307
1308 #[test]
1309 fn dev_note_upsert_update_mode() {
1310 let json = serde_json::json!({
1311 "v": 2,
1312 "id": "550e8400-e29b-41d4-a716-446655440000",
1313 "session": "660e8400-e29b-41d4-a716-446655440000",
1314 "cmd": {
1315 "type": "dev_note_upsert",
1316 "key": "dev_note:changelog-reminder-1712345678",
1317 "text": "Updated: remember to update changelog AND version"
1318 }
1319 });
1320 let req: Request = serde_json::from_value(json).unwrap();
1321 match req.cmd {
1322 Command::DevNoteUpsert(input) => {
1323 assert_eq!(
1324 input.key.as_deref(),
1325 Some("dev_note:changelog-reminder-1712345678")
1326 );
1327 }
1328 _ => panic!("expected DevNoteUpsert"),
1329 }
1330 }
1331
1332 #[test]
1335 fn command_kind_covers_all_variants() {
1336 let cases: Vec<(&str, Command)> = vec![
1338 ("ping", Command::Ping),
1339 ("metrics", Command::Metrics),
1340 ("get", Command::Get(GetInput { key: "k".into() })),
1341 (
1342 "hook_evaluate",
1343 Command::HookEvaluate(HookEvaluateInput {
1344 file_key: "f".into(),
1345 include_recent: false,
1346 }),
1347 ),
1348 (
1349 "scan_prefix",
1350 Command::ScanPrefix(ScanPrefixInput { prefix: "p".into() }),
1351 ),
1352 (
1353 "history",
1354 Command::History(HistoryInput {
1355 key: "k".into(),
1356 limit: 10,
1357 }),
1358 ),
1359 (
1360 "history_since",
1361 Command::HistorySince(HistorySinceInput {
1362 key: "k".into(),
1363 since_ts: 0,
1364 limit: 10,
1365 }),
1366 ),
1367 (
1368 "session_check_consulted",
1369 Command::SessionCheckConsulted(SessionCheckConsultedInput { key: "k".into() }),
1370 ),
1371 (
1372 "session_check_consulted_recent",
1373 Command::SessionCheckConsultedRecent(SessionCheckConsultedRecentInput {
1374 key: "k".into(),
1375 ttl_secs: 900,
1376 }),
1377 ),
1378 (
1379 "mem_query",
1380 Command::MemQuery(MemQueryInput {
1381 query: "q".into(),
1382 mode: QueryMode::Text,
1383 limit: 20,
1384 }),
1385 ),
1386 ("mem_get", Command::MemGet(MemGetInput { key: "k".into() })),
1387 (
1388 "mem_bootstrap",
1389 Command::MemBootstrap(MemBootstrapInput {
1390 context_files: vec![],
1391 }),
1392 ),
1393 (
1394 "gotcha_upsert",
1395 Command::GotchaUpsert(GotchaDraftInput {
1396 key: "gotcha:t".into(),
1397 rule: "r".into(),
1398 reason: "r".into(),
1399 severity: Severity::Normal,
1400 affected_files: vec![],
1401 ref_url: None,
1402 tags: vec![],
1403 priority: Priority::Normal,
1404 source: None,
1405 }),
1406 ),
1407 (
1408 "gotcha_confirm",
1409 Command::GotchaConfirm(GotchaConfirmInput {
1410 key: "gotcha:t".into(),
1411 }),
1412 ),
1413 (
1414 "gotcha_tombstone",
1415 Command::GotchaTombstone(GotchaTombstoneInput {
1416 key: "gotcha:t".into(),
1417 }),
1418 ),
1419 (
1420 "file_enrich",
1421 Command::FileEnrich(FileEnrichInput {
1422 path: "p".into(),
1423 purpose: "p".into(),
1424 entry_points: vec![],
1425 decision_keys: vec![],
1426 todos: vec![],
1427 tags: vec![],
1428 priority: Priority::Normal,
1429 }),
1430 ),
1431 (
1432 "file_reparse",
1433 Command::FileReparse(FileReparseInput { path: "p".into() }),
1434 ),
1435 (
1436 "file_edit_hook",
1437 Command::FileEditHook(FileEditHookInput { path: "p".into() }),
1438 ),
1439 (
1440 "doc_capture",
1441 Command::DocCapture(DocCaptureInput { path: "p".into() }),
1442 ),
1443 (
1444 "decision_upsert",
1445 Command::DecisionUpsert(DecisionUpsertInput {
1446 slug: "s".into(),
1447 value: "v".into(),
1448 summary: "s".into(),
1449 rationale: "r".into(),
1450 tags: vec![],
1451 priority: Priority::Normal,
1452 }),
1453 ),
1454 (
1455 "dev_note_upsert",
1456 Command::DevNoteUpsert(DevNoteUpsertInput {
1457 key: None,
1458 text: "t".into(),
1459 tags: vec![],
1460 priority: Priority::Normal,
1461 }),
1462 ),
1463 (
1464 "session_log",
1465 Command::SessionLog(SessionLogInput {
1466 event: SessionEvent::Miss,
1467 key: "k".into(),
1468 }),
1469 ),
1470 (
1471 "consultation_hit",
1472 Command::ConsultationHit(ConsultationHitInput { key: "k".into() }),
1473 ),
1474 ("session_flush", Command::SessionFlush),
1475 ("session_harvest", Command::SessionHarvest),
1476 ];
1477
1478 assert_eq!(cases.len(), 25, "must cover all 25 command variants");
1479 for (expected_kind, cmd) in &cases {
1480 assert_eq!(
1481 cmd.kind(),
1482 *expected_kind,
1483 "kind() mismatch for {:?}",
1484 expected_kind
1485 );
1486 }
1487 }
1488
1489 #[test]
1490 fn command_is_mutation_classification() {
1491 assert!(!Command::Ping.is_mutation());
1493 assert!(!Command::Metrics.is_mutation());
1494 assert!(!Command::Get(GetInput { key: "k".into() }).is_mutation());
1495 assert!(!Command::MemQuery(MemQueryInput {
1496 query: "q".into(),
1497 mode: QueryMode::Text,
1498 limit: 20,
1499 })
1500 .is_mutation());
1501
1502 assert!(Command::MemGet(MemGetInput { key: "k".into() }).is_mutation());
1504 assert!(Command::MemBootstrap(MemBootstrapInput {
1505 context_files: vec![]
1506 })
1507 .is_mutation());
1508
1509 assert!(Command::GotchaConfirm(GotchaConfirmInput {
1511 key: "gotcha:t".into()
1512 })
1513 .is_mutation());
1514 assert!(Command::SessionLog(SessionLogInput {
1515 event: SessionEvent::Miss,
1516 key: "k".into(),
1517 })
1518 .is_mutation());
1519 assert!(Command::SessionFlush.is_mutation());
1520 assert!(Command::SessionHarvest.is_mutation());
1521 }
1522
1523 #[test]
1524 fn command_target_key_returns_expected_values() {
1525 assert_eq!(Command::Ping.target_key(), "");
1526 assert_eq!(
1527 Command::Get(GetInput {
1528 key: "file:src/main.rs".into()
1529 })
1530 .target_key(),
1531 "file:src/main.rs"
1532 );
1533 assert_eq!(
1534 Command::GotchaUpsert(GotchaDraftInput {
1535 key: "gotcha:test".into(),
1536 rule: "r".into(),
1537 reason: "r".into(),
1538 severity: Severity::Normal,
1539 affected_files: vec![],
1540 ref_url: None,
1541 tags: vec![],
1542 priority: Priority::Normal,
1543 source: None,
1544 })
1545 .target_key(),
1546 "gotcha:test"
1547 );
1548 assert_eq!(
1549 Command::DecisionUpsert(DecisionUpsertInput {
1550 slug: "my-decision".into(),
1551 value: "v".into(),
1552 summary: "s".into(),
1553 rationale: "r".into(),
1554 tags: vec![],
1555 priority: Priority::Normal,
1556 })
1557 .target_key(),
1558 "my-decision"
1559 );
1560 assert_eq!(
1562 Command::DevNoteUpsert(DevNoteUpsertInput {
1563 key: None,
1564 text: "t".into(),
1565 tags: vec![],
1566 priority: Priority::Normal,
1567 })
1568 .target_key(),
1569 ""
1570 );
1571 assert_eq!(Command::SessionFlush.target_key(), "");
1572 }
1573
1574 #[test]
1575 fn audit_entry_serializes() {
1576 let entry = AuditEntry {
1577 ts: 1700000000,
1578 peer_uid: 501,
1579 peer_pid: Some(1234),
1580 daemon_session: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1581 request_id: Uuid::parse_str("660e8400-e29b-41d4-a716-446655440000").unwrap(),
1582 command_kind: "gotcha_upsert".into(),
1583 target_key: "gotcha:test".into(),
1584 accepted: true,
1585 error_code: None,
1586 };
1587 let json = serde_json::to_value(&entry).unwrap();
1588 assert_eq!(json["peer_uid"], 501);
1589 assert_eq!(json["command_kind"], "gotcha_upsert");
1590 assert_eq!(json["accepted"], true);
1591 assert!(json.get("error_code").is_none());
1593 }
1594
1595 #[test]
1596 fn audit_entry_rejected_includes_error_code() {
1597 let entry = AuditEntry {
1598 ts: 1700000000,
1599 peer_uid: 501,
1600 peer_pid: None,
1601 daemon_session: Uuid::nil(),
1602 request_id: Uuid::nil(),
1603 command_kind: "gotcha_confirm".into(),
1604 target_key: "gotcha:missing".into(),
1605 accepted: false,
1606 error_code: Some(ErrorCode::NotFound),
1607 };
1608 let json = serde_json::to_value(&entry).unwrap();
1609 assert_eq!(json["accepted"], false);
1610 assert_eq!(json["error_code"], "not_found");
1611 assert!(json["peer_pid"].is_null());
1612 }
1613
1614 #[test]
1617 fn store_priority_to_protocol_severity_preserves_all_variants() {
1618 use crate::store::Priority as SP;
1619 assert_eq!(Severity::from(SP::Low), Severity::Low);
1620 assert_eq!(Severity::from(SP::Normal), Severity::Normal);
1621 assert_eq!(Severity::from(SP::High), Severity::High);
1622 assert_eq!(Severity::from(SP::Critical), Severity::Critical);
1623 }
1624
1625 #[test]
1626 fn store_priority_to_protocol_priority_preserves_all_variants() {
1627 use crate::store::Priority as SP;
1628 assert_eq!(Priority::from(SP::Low), Priority::Low);
1629 assert_eq!(Priority::from(SP::Normal), Priority::Normal);
1630 assert_eq!(Priority::from(SP::High), Priority::High);
1631 assert_eq!(Priority::from(SP::Critical), Priority::Critical);
1632 }
1633
1634 #[test]
1644 fn v1_to_v2_command_handles_mem_get() {
1645 let mapped = v1_to_v2_command("mem_get", &serde_json::json!({ "key": "file:src/main.rs" }));
1646 assert_eq!(
1647 mapped,
1648 serde_json::json!({ "type": "mem_get", "key": "file:src/main.rs" })
1649 );
1650
1651 let cmd: Command = serde_json::from_value(mapped).expect("mem_get must decode as Command");
1654 match cmd {
1655 Command::MemGet(input) => assert_eq!(input.key, "file:src/main.rs"),
1656 other => panic!("expected Command::MemGet, got {:?}", other.kind()),
1657 }
1658 }
1659
1660 #[test]
1661 fn v1_to_v2_command_handles_mem_bootstrap() {
1662 let mapped = v1_to_v2_command(
1664 "mem_bootstrap",
1665 &serde_json::json!({ "context_files": ["src/lib.rs", "src/main.rs"] }),
1666 );
1667 let cmd: Command =
1668 serde_json::from_value(mapped).expect("mem_bootstrap must decode as Command");
1669 match cmd {
1670 Command::MemBootstrap(input) => {
1671 assert_eq!(input.context_files, vec!["src/lib.rs", "src/main.rs"]);
1672 }
1673 other => panic!("expected Command::MemBootstrap, got {:?}", other.kind()),
1674 }
1675
1676 let mapped_empty = v1_to_v2_command("mem_bootstrap", &serde_json::json!({}));
1678 let cmd_empty: Command = serde_json::from_value(mapped_empty).unwrap();
1679 match cmd_empty {
1680 Command::MemBootstrap(input) => assert!(input.context_files.is_empty()),
1681 other => panic!("expected MemBootstrap, got {:?}", other.kind()),
1682 }
1683 }
1684
1685 #[test]
1686 #[should_panic(expected = "v1_to_v2_command called with unsupported command")]
1687 fn v1_to_v2_command_panic_message_lists_only_unsupported() {
1688 let _ = v1_to_v2_command("totally_bogus_cmd_xyz", &serde_json::json!({}));
1692 }
1693
1694 #[test]
1695 fn v1_to_v2_command_no_mutations_silently_accepted() {
1696 let mutation_names = [
1700 "mem_set",
1701 "gotcha_upsert",
1702 "gotcha_confirm",
1703 "gotcha_tombstone",
1704 "decision_upsert",
1705 "dev_note_upsert",
1706 "file_enrich",
1707 "file_reparse",
1708 "file_edit_hook",
1709 "doc_capture",
1710 "session_log",
1711 "consultation_hit",
1712 "session_flush",
1713 "session_harvest",
1714 ];
1715 for name in mutation_names {
1716 let result = std::panic::catch_unwind(|| {
1717 v1_to_v2_command(name, &serde_json::json!({}));
1718 });
1719 assert!(
1720 result.is_err(),
1721 "mutation command '{name}' must panic in v1_to_v2_command — \
1722 mutating callers must use daemon_v2() with typed Command"
1723 );
1724 }
1725 }
1726
1727 #[test]
1733 fn request_without_agent_field_deserializes_as_none() {
1734 let json = serde_json::json!({
1735 "v": 2,
1736 "id": "550e8400-e29b-41d4-a716-446655440000",
1737 "session": "660e8400-e29b-41d4-a716-446655440000",
1738 "cmd": { "type": "ping" }
1739 });
1740 let req: Request = serde_json::from_value(json).unwrap();
1741 assert!(
1742 req.agent.is_none(),
1743 "missing `agent` must decode to None (ADR-018 additive contract)"
1744 );
1745 }
1746
1747 #[test]
1748 fn request_with_agent_field_deserializes_and_preserves_value() {
1749 for (wire, expected) in [
1750 ("claude", AgentKind::Claude),
1751 ("codex", AgentKind::Codex),
1752 ("cli", AgentKind::Cli),
1753 ("supervisor", AgentKind::Supervisor),
1754 ("unknown", AgentKind::Unknown),
1755 ] {
1756 let json = serde_json::json!({
1757 "v": 2,
1758 "id": "550e8400-e29b-41d4-a716-446655440000",
1759 "session": "660e8400-e29b-41d4-a716-446655440000",
1760 "agent": wire,
1761 "cmd": { "type": "ping" }
1762 });
1763 let req: Request = serde_json::from_value(json)
1764 .unwrap_or_else(|e| panic!("decode failed for agent={wire}: {e}"));
1765 assert_eq!(req.agent, Some(expected));
1766 }
1767 }
1768
1769 #[test]
1770 fn request_with_unknown_agent_variant_rejected() {
1771 let json = serde_json::json!({
1772 "v": 2,
1773 "id": "550e8400-e29b-41d4-a716-446655440000",
1774 "session": "660e8400-e29b-41d4-a716-446655440000",
1775 "agent": "gemini",
1776 "cmd": { "type": "ping" }
1777 });
1778 let res = serde_json::from_value::<Request>(json);
1779 assert!(
1780 res.is_err(),
1781 "unknown agent variant must reject at decode (closed enum)"
1782 );
1783 }
1784
1785 #[test]
1786 fn request_with_agent_round_trips_through_serialize_deserialize() {
1787 let original = Request {
1788 v: PROTOCOL_VERSION,
1789 id: Uuid::new_v4(),
1790 session: Uuid::new_v4(),
1791 agent: Some(AgentKind::Codex),
1792 cmd: Command::Ping,
1793 };
1794 let bytes = serde_json::to_vec(&original).unwrap();
1795 let round_tripped: Request = serde_json::from_slice(&bytes).unwrap();
1796 assert_eq!(round_tripped.agent, Some(AgentKind::Codex));
1797 assert_eq!(round_tripped.v, PROTOCOL_VERSION);
1798 }
1799}