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 = "sandbox_audit")]
267 SandboxAudit(SandboxAuditInput),
268
269 #[serde(rename = "session_log")]
271 SessionLog(SessionLogInput),
272
273 #[serde(rename = "consultation_hit")]
275 ConsultationHit(ConsultationHitInput),
276
277 #[serde(rename = "session_flush")]
279 SessionFlush,
280
281 #[serde(rename = "session_harvest")]
283 SessionHarvest,
284
285 #[serde(rename = "session_clear_consults")]
287 SessionClearConsults,
288
289 #[serde(rename = "record_import")]
301 RecordImport(RecordImportInput),
302}
303
304#[derive(Debug, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct GetInput {
314 pub key: String,
315}
316
317#[derive(Debug, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct HookEvaluateInput {
320 pub file_key: String,
321 #[serde(default)]
322 pub include_recent: bool,
323 #[serde(default)]
326 pub actor: Option<String>,
327}
328
329#[derive(Debug, Serialize, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct ScanPrefixInput {
332 pub prefix: String,
333}
334
335#[derive(Debug, Serialize, Deserialize)]
336#[serde(deny_unknown_fields)]
337pub struct ScanEnforcementEventsInput {
338 #[serde(default)]
339 pub since_seq: u64,
340 #[serde(default = "default_until_seq")]
341 pub until_seq: u64,
342}
343
344fn default_until_seq() -> u64 {
345 u64::MAX
346}
347
348#[derive(Debug, Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350pub struct HistoryInput {
351 pub key: String,
352 #[serde(default = "default_history_limit")]
353 pub limit: u64,
354}
355
356#[derive(Debug, Serialize, Deserialize)]
357#[serde(deny_unknown_fields)]
358pub struct HistorySinceInput {
359 pub key: String,
360 pub since_ts: u64,
361 #[serde(default = "default_history_limit")]
362 pub limit: u64,
363}
364
365fn default_history_limit() -> u64 {
366 50
367}
368
369#[derive(Debug, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct SessionCheckConsultedInput {
372 pub key: String,
373}
374
375#[derive(Debug, Serialize, Deserialize)]
376#[serde(deny_unknown_fields)]
377pub struct SessionCheckConsultedRecentInput {
378 pub key: String,
379 #[serde(default = "default_ttl_secs")]
380 pub ttl_secs: u64,
381}
382
383fn default_ttl_secs() -> u64 {
384 900
385}
386
387#[derive(Debug, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct MemQueryInput {
390 pub query: String,
391 #[serde(default = "default_query_mode")]
392 pub mode: QueryMode,
393 #[serde(default = "default_query_limit")]
394 pub limit: u32,
395}
396
397fn default_query_mode() -> QueryMode {
398 QueryMode::Text
399}
400
401fn default_query_limit() -> u32 {
402 20
403}
404
405#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
407#[serde(rename_all = "snake_case")]
408pub enum QueryMode {
409 Text,
411 Tag,
413 Graph,
415 Semantic,
417}
418
419#[derive(Debug, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct MemGetInput {
424 pub key: String,
425}
426
427#[derive(Debug, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct MemBootstrapInput {
430 #[serde(default)]
431 pub context_files: Vec<String>,
432}
433
434#[derive(Debug, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct GotchaDraftInput {
444 pub key: String,
446 pub rule: String,
448 pub reason: String,
450 pub severity: Severity,
452 #[serde(default)]
454 pub affected_files: Vec<String>,
455 #[serde(default)]
457 pub ref_url: Option<String>,
458 #[serde(default)]
460 pub tags: Vec<String>,
461 #[serde(default)]
463 pub priority: Priority,
464 #[serde(default)]
467 pub source: Option<String>,
468}
469
470#[derive(Debug, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct GotchaConfirmInput {
473 pub key: String,
474}
475
476#[derive(Debug, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct GotchaTombstoneInput {
479 pub key: String,
480}
481
482#[derive(Debug, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct FileEnrichInput {
492 pub path: String,
494 pub purpose: String,
496 #[serde(default)]
498 pub entry_points: Vec<String>,
499 #[serde(default)]
501 pub decision_keys: Vec<String>,
502 #[serde(default)]
504 pub todos: Vec<String>,
505 #[serde(default)]
507 pub tags: Vec<String>,
508 #[serde(default)]
510 pub priority: Priority,
511}
512
513#[derive(Debug, Serialize, Deserialize)]
514#[serde(deny_unknown_fields)]
515pub struct FileReparseInput {
516 pub path: String,
517}
518
519#[derive(Debug, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct FileEditHookInput {
522 pub path: String,
523}
524
525#[derive(Debug, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct DocCaptureInput {
530 pub path: String,
531}
532
533#[derive(Debug, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub struct DecisionUpsertInput {
536 pub slug: String,
538 pub value: String,
540 pub summary: String,
542 pub rationale: String,
544 #[serde(default)]
546 pub tags: Vec<String>,
547 #[serde(default)]
549 pub priority: Priority,
550}
551
552#[derive(Debug, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct DevNoteUpsertInput {
555 #[serde(default)]
558 pub key: Option<String>,
559 pub text: String,
561 #[serde(default)]
563 pub tags: Vec<String>,
564 #[serde(default)]
566 pub priority: Priority,
567}
568
569#[derive(Debug, Serialize, Deserialize)]
570#[serde(deny_unknown_fields)]
571pub struct SessionLogInput {
572 pub event: SessionEvent,
574 pub key: String,
576 #[serde(default)]
580 pub session_id: Option<String>,
581}
582
583#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
588#[serde(rename_all = "snake_case")]
589pub enum SessionEvent {
590 Miss,
591 ComplianceMiss,
592 ComplianceHit,
593 EditConsulted,
598 EditBlocked,
601 FloorConsultMiss,
604 CodexShellMiss,
605 Bootstrap,
606 PromptNudge,
607}
608
609#[derive(Debug, Serialize, Deserialize)]
610#[serde(deny_unknown_fields)]
611pub struct ConsultationHitInput {
612 pub key: String,
613 #[serde(default)]
614 pub actor: Option<String>,
615 #[serde(default)]
617 pub session_id: Option<String>,
618 #[serde(default)]
620 pub agent_id: Option<String>,
621}
622
623#[derive(Debug, Serialize, Deserialize)]
627#[serde(deny_unknown_fields)]
628pub struct RecordImportInput {
629 pub records: Vec<crate::store::Record>,
630}
631
632#[derive(Debug, Serialize, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct ConfigGetInput {
637 pub key: String,
638}
639
640#[derive(Debug, Serialize, Deserialize)]
643#[serde(deny_unknown_fields)]
644pub struct ConfigSetInput {
645 pub key: String,
646 pub value: String,
647}
648
649#[derive(Debug, Serialize, Deserialize)]
652#[serde(deny_unknown_fields)]
653pub struct SandboxAuditInput {
654 pub setting: String,
655 pub new_value: String,
656 pub reason: String,
657}
658
659#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
663#[serde(rename_all = "snake_case")]
664pub enum Severity {
665 Critical,
666 High,
667 #[default]
668 Normal,
669 Low,
670}
671
672#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
674#[serde(rename_all = "snake_case")]
675pub enum Priority {
676 Critical,
677 High,
678 #[default]
679 Normal,
680 Low,
681}
682
683impl From<crate::store::Priority> for Severity {
686 fn from(p: crate::store::Priority) -> Self {
687 match p {
688 crate::store::Priority::Low => Severity::Low,
689 crate::store::Priority::Normal => Severity::Normal,
690 crate::store::Priority::High => Severity::High,
691 crate::store::Priority::Critical => Severity::Critical,
692 }
693 }
694}
695
696impl From<crate::store::Priority> for Priority {
697 fn from(p: crate::store::Priority) -> Self {
698 match p {
699 crate::store::Priority::Low => Priority::Low,
700 crate::store::Priority::Normal => Priority::Normal,
701 crate::store::Priority::High => Priority::High,
702 crate::store::Priority::Critical => Priority::Critical,
703 }
704 }
705}
706
707impl Command {
710 pub fn kind(&self) -> &'static str {
713 match self {
714 Self::Ping => "ping",
715 Self::Metrics => "metrics",
716 Self::Get(_) => "get",
717 Self::HookEvaluate(_) => "hook_evaluate",
718 Self::ScanPrefix(_) => "scan_prefix",
719 Self::History(_) => "history",
720 Self::HistorySince(_) => "history_since",
721 Self::SessionCheckConsulted(_) => "session_check_consulted",
722 Self::SessionCheckConsultedRecent(_) => "session_check_consulted_recent",
723 Self::MemQuery(_) => "mem_query",
724 Self::ScanEnforcementEvents(_) => "scan_enforcement_events",
725 Self::ConfigGet(_) => "config_get",
726 Self::ConfigSet(_) => "config_set",
727 Self::SandboxAudit(_) => "sandbox_audit",
728 Self::MemGet(_) => "mem_get",
729 Self::MemBootstrap(_) => "mem_bootstrap",
730 Self::GotchaUpsert(_) => "gotcha_upsert",
731 Self::GotchaConfirm(_) => "gotcha_confirm",
732 Self::GotchaTombstone(_) => "gotcha_tombstone",
733 Self::FileEnrich(_) => "file_enrich",
734 Self::FileReparse(_) => "file_reparse",
735 Self::FileEditHook(_) => "file_edit_hook",
736 Self::DocCapture(_) => "doc_capture",
737 Self::DecisionUpsert(_) => "decision_upsert",
738 Self::DevNoteUpsert(_) => "dev_note_upsert",
739 Self::SessionLog(_) => "session_log",
740 Self::ConsultationHit(_) => "consultation_hit",
741 Self::SessionFlush => "session_flush",
742 Self::SessionHarvest => "session_harvest",
743 Self::SessionClearConsults => "session_clear_consults",
744 Self::RecordImport(_) => "record_import",
745 }
746 }
747
748 pub fn target_key(&self) -> &str {
751 match self {
752 Self::Get(i) => &i.key,
753 Self::HookEvaluate(i) => &i.file_key,
754 Self::ScanPrefix(i) => &i.prefix,
755 Self::History(i) => &i.key,
756 Self::HistorySince(i) => &i.key,
757 Self::SessionCheckConsulted(i) => &i.key,
758 Self::SessionCheckConsultedRecent(i) => &i.key,
759 Self::MemQuery(i) => &i.query,
760 Self::MemGet(i) => &i.key,
761 Self::GotchaUpsert(i) => &i.key,
762 Self::GotchaConfirm(i) => &i.key,
763 Self::GotchaTombstone(i) => &i.key,
764 Self::FileEnrich(i) => &i.path,
765 Self::FileReparse(i) => &i.path,
766 Self::FileEditHook(i) => &i.path,
767 Self::DocCapture(i) => &i.path,
768 Self::DecisionUpsert(i) => &i.slug,
769 Self::DevNoteUpsert(i) => i.key.as_deref().unwrap_or(""),
770 Self::SessionLog(i) => &i.key,
771 Self::ConsultationHit(i) => &i.key,
772 Self::ConfigGet(i) => &i.key,
773 Self::ConfigSet(i) => &i.key,
774 Self::SandboxAudit(i) => &i.setting,
775 Self::Ping
776 | Self::Metrics
777 | Self::MemBootstrap(_)
778 | Self::ScanEnforcementEvents(_)
779 | Self::SessionFlush
780 | Self::SessionHarvest
781 | Self::SessionClearConsults
782 | Self::RecordImport(_) => "",
783 }
784 }
785
786 pub fn is_mutation(&self) -> bool {
793 matches!(
794 self,
795 Self::MemGet(_)
797 | Self::MemBootstrap(_)
798 | Self::GotchaUpsert(_)
800 | Self::GotchaConfirm(_)
801 | Self::GotchaTombstone(_)
802 | Self::FileEnrich(_)
803 | Self::FileReparse(_)
804 | Self::FileEditHook(_)
805 | Self::DocCapture(_)
806 | Self::DecisionUpsert(_)
807 | Self::DevNoteUpsert(_)
808 | Self::SessionLog(_)
809 | Self::ConsultationHit(_)
810 | Self::ConfigSet(_)
811 | Self::SandboxAudit(_)
812 | Self::SessionFlush
813 | Self::SessionHarvest
814 | Self::SessionClearConsults
815 | Self::RecordImport(_)
816 )
817 }
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
831pub struct AuditEntry {
832 pub ts: u64,
834 pub peer_uid: u32,
836 pub peer_pid: Option<u32>,
838 pub daemon_session: Uuid,
840 pub request_id: Uuid,
842 pub command_kind: String,
844 pub target_key: String,
846 pub accepted: bool,
848 #[serde(skip_serializing_if = "Option::is_none")]
850 pub error_code: Option<ErrorCode>,
851}
852
853pub fn v1_to_v2_command(cmd: &str, args: &serde_json::Value) -> serde_json::Value {
868 use serde_json::json;
869
870 match cmd {
871 "ping" => json!({"type": "ping"}),
873 "metrics" => json!({"type": "metrics"}),
874 "get" => json!({"type": "get", "key": args["key"]}),
875 "hook_evaluate" => json!({
876 "type": "hook_evaluate",
877 "file_key": args["file_key"],
878 "include_recent": args.get("include_recent").and_then(|v| v.as_bool()).unwrap_or(false),
879 "actor": args["actor"],
880 }),
881 "scan_prefix" => json!({"type": "scan_prefix", "prefix": args["prefix"]}),
882 "history" => {
883 json!({"type": "history", "key": args["key"], "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50)})
884 }
885 "history_since" => json!({
886 "type": "history_since",
887 "key": args["key"],
888 "since_ts": args.get("since_ts").and_then(|v| v.as_u64()).unwrap_or(0),
889 "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50),
890 }),
891 "session_check_consulted" => json!({"type": "session_check_consulted", "key": args["key"]}),
892 "session_check_consulted_recent" => json!({
893 "type": "session_check_consulted_recent",
894 "key": args["key"],
895 "ttl_secs": args.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(900),
896 }),
897 "mem_query" => json!({
898 "type": "mem_query",
899 "query": args["query"],
900 "mode": args.get("mode").and_then(|v| v.as_str()).unwrap_or("text"),
901 "limit": args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20),
902 }),
903 "scan_enforcement_events" => json!({
904 "type": "scan_enforcement_events",
905 "since_seq": args.get("since_seq").and_then(|v| v.as_u64()).unwrap_or(0),
906 "until_seq": args.get("until_seq").and_then(|v| v.as_u64()).unwrap_or(u64::MAX),
907 }),
908 "mem_get" => json!({"type": "mem_get", "key": args["key"]}),
916 "mem_bootstrap" => json!({
917 "type": "mem_bootstrap",
918 "context_files": args.get("context_files").cloned().unwrap_or_else(|| serde_json::json!([])),
919 }),
920 other => {
921 panic!(
922 "v1_to_v2_command called with unsupported command '{other}' — \
923 only pure reads are supported; mutation/side-effecting callers \
924 must use daemon_v2() with typed Command"
925 );
926 }
927 }
928}
929
930#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
943 fn query_mode_deserialize_rejects_unknown_variant() {
944 let result: Result<QueryMode, _> = serde_json::from_str("\"invalid_mode\"");
945 assert!(
946 result.is_err(),
947 "QueryMode deserialization must reject unknown variants, got: {result:?}"
948 );
949 }
950
951 #[test]
952 fn query_mode_deserialize_accepts_all_known_variants() {
953 for variant in &["text", "tag", "graph", "semantic"] {
955 let json = format!("\"{variant}\"");
956 let result: Result<QueryMode, _> = serde_json::from_str(&json);
957 assert!(
958 result.is_ok(),
959 "QueryMode must accept {variant:?}, got: {result:?}"
960 );
961 }
962 }
963
964 #[test]
965 fn valid_v2_ping_request_decodes() {
966 let json = serde_json::json!({
967 "v": 2,
968 "id": "550e8400-e29b-41d4-a716-446655440000",
969 "session": "660e8400-e29b-41d4-a716-446655440000",
970 "cmd": { "type": "ping" }
971 });
972 let req: Request = serde_json::from_value(json).unwrap();
973 assert_eq!(req.v, PROTOCOL_VERSION);
974 assert!(matches!(req.cmd, Command::Ping));
975 }
976
977 #[test]
978 fn valid_v2_get_request_decodes() {
979 let json = serde_json::json!({
980 "v": 2,
981 "id": "550e8400-e29b-41d4-a716-446655440000",
982 "session": "660e8400-e29b-41d4-a716-446655440000",
983 "cmd": { "type": "get", "key": "file:src/main.rs" }
984 });
985 let req: Request = serde_json::from_value(json).unwrap();
986 match req.cmd {
987 Command::Get(input) => assert_eq!(input.key, "file:src/main.rs"),
988 _ => panic!("expected Get"),
989 }
990 }
991
992 #[test]
993 fn valid_gotcha_upsert_decodes() {
994 let json = serde_json::json!({
995 "v": 2,
996 "id": "550e8400-e29b-41d4-a716-446655440000",
997 "session": "660e8400-e29b-41d4-a716-446655440000",
998 "cmd": {
999 "type": "gotcha_upsert",
1000 "key": "gotcha:stripe-idempotency",
1001 "rule": "Always include an idempotency key",
1002 "reason": "Stripe retries without it cause double charges",
1003 "severity": "high",
1004 "affected_files": ["src/payments/stripe.rs"],
1005 "tags": ["payments", "stripe"]
1006 }
1007 });
1008 let req: Request = serde_json::from_value(json).unwrap();
1009 match req.cmd {
1010 Command::GotchaUpsert(input) => {
1011 assert_eq!(input.key, "gotcha:stripe-idempotency");
1012 assert_eq!(input.severity, Severity::High);
1013 assert_eq!(input.affected_files, vec!["src/payments/stripe.rs"]);
1014 assert_eq!(input.priority, Priority::Normal); }
1016 _ => panic!("expected GotchaUpsert"),
1017 }
1018 }
1019
1020 #[test]
1021 fn valid_decision_upsert_decodes() {
1022 let json = serde_json::json!({
1023 "v": 2,
1024 "id": "550e8400-e29b-41d4-a716-446655440000",
1025 "session": "660e8400-e29b-41d4-a716-446655440000",
1026 "cmd": {
1027 "type": "decision_upsert",
1028 "slug": "unified-retry-strategy",
1029 "value": "We use exponential backoff because linear retry overloads downstream",
1030 "summary": "Exponential backoff for all retries",
1031 "rationale": "Linear retry caused cascading failures in prod 2024-01"
1032 }
1033 });
1034 let req: Request = serde_json::from_value(json).unwrap();
1035 match req.cmd {
1036 Command::DecisionUpsert(input) => {
1037 assert_eq!(input.slug, "unified-retry-strategy");
1038 assert!(!input.rationale.is_empty());
1039 }
1040 _ => panic!("expected DecisionUpsert"),
1041 }
1042 }
1043
1044 #[test]
1045 fn valid_session_log_decodes() {
1046 let json = serde_json::json!({
1047 "v": 2,
1048 "id": "550e8400-e29b-41d4-a716-446655440000",
1049 "session": "660e8400-e29b-41d4-a716-446655440000",
1050 "cmd": {
1051 "type": "session_log",
1052 "event": "compliance_miss",
1053 "key": "file:src/main.rs"
1054 }
1055 });
1056 let req: Request = serde_json::from_value(json).unwrap();
1057 match req.cmd {
1058 Command::SessionLog(input) => {
1059 assert_eq!(input.event, SessionEvent::ComplianceMiss);
1060 assert_eq!(input.key, "file:src/main.rs");
1061 }
1062 _ => panic!("expected SessionLog"),
1063 }
1064 }
1065
1066 #[test]
1067 fn valid_file_enrich_decodes() {
1068 let json = serde_json::json!({
1069 "v": 2,
1070 "id": "550e8400-e29b-41d4-a716-446655440000",
1071 "session": "660e8400-e29b-41d4-a716-446655440000",
1072 "cmd": {
1073 "type": "file_enrich",
1074 "path": "src/store/db.rs",
1075 "purpose": "Own the storage boundary for all SurrealKV operations",
1076 "entry_points": ["open", "put", "get"],
1077 "decision_keys": ["decision:storage-engine"]
1078 }
1079 });
1080 let req: Request = serde_json::from_value(json).unwrap();
1081 match req.cmd {
1082 Command::FileEnrich(input) => {
1083 assert_eq!(input.path, "src/store/db.rs");
1084 assert_eq!(input.entry_points.len(), 3);
1085 assert!(input.todos.is_empty()); }
1087 _ => panic!("expected FileEnrich"),
1088 }
1089 }
1090
1091 #[test]
1094 fn bad_version_still_decodes_for_error_handling() {
1095 let json = serde_json::json!({
1097 "v": 99,
1098 "id": "550e8400-e29b-41d4-a716-446655440000",
1099 "session": "660e8400-e29b-41d4-a716-446655440000",
1100 "cmd": { "type": "ping" }
1101 });
1102 let req: Request = serde_json::from_value(json).unwrap();
1103 assert_ne!(req.v, PROTOCOL_VERSION);
1104 }
1105
1106 #[test]
1107 fn unknown_field_in_request_rejected() {
1108 let json = serde_json::json!({
1109 "v": 2,
1110 "id": "550e8400-e29b-41d4-a716-446655440000",
1111 "session": "660e8400-e29b-41d4-a716-446655440000",
1112 "cmd": { "type": "ping" },
1113 "extra_field": true
1114 });
1115 let result = serde_json::from_value::<Request>(json);
1116 assert!(result.is_err(), "unknown top-level field must be rejected");
1117 }
1118
1119 #[test]
1120 fn unknown_field_in_command_args_rejected() {
1121 let json = serde_json::json!({
1122 "v": 2,
1123 "id": "550e8400-e29b-41d4-a716-446655440000",
1124 "session": "660e8400-e29b-41d4-a716-446655440000",
1125 "cmd": { "type": "get", "key": "file:foo", "smuggled": true }
1126 });
1127 let result = serde_json::from_value::<Request>(json);
1128 assert!(
1129 result.is_err(),
1130 "unknown field in command args must be rejected"
1131 );
1132 }
1133
1134 #[test]
1135 fn unknown_command_type_rejected() {
1136 let json = serde_json::json!({
1137 "v": 2,
1138 "id": "550e8400-e29b-41d4-a716-446655440000",
1139 "session": "660e8400-e29b-41d4-a716-446655440000",
1140 "cmd": { "type": "raw_put", "key": "gotcha:x", "value": "hacked" }
1141 });
1142 let result = serde_json::from_value::<Request>(json);
1143 assert!(result.is_err(), "unknown command type must be rejected");
1144 }
1145
1146 #[test]
1147 fn malformed_uuid_rejected() {
1148 let json = serde_json::json!({
1149 "v": 2,
1150 "id": "not-a-uuid",
1151 "session": "660e8400-e29b-41d4-a716-446655440000",
1152 "cmd": { "type": "ping" }
1153 });
1154 let result = serde_json::from_value::<Request>(json);
1155 assert!(result.is_err(), "malformed UUID must be rejected");
1156 }
1157
1158 #[test]
1159 fn missing_session_rejected() {
1160 let json = serde_json::json!({
1161 "v": 2,
1162 "id": "550e8400-e29b-41d4-a716-446655440000",
1163 "cmd": { "type": "ping" }
1164 });
1165 let result = serde_json::from_value::<Request>(json);
1166 assert!(result.is_err(), "missing session UUID must be rejected");
1167 }
1168
1169 #[test]
1170 fn gotcha_upsert_rejects_server_owned_fields() {
1171 let json = serde_json::json!({
1173 "v": 2,
1174 "id": "550e8400-e29b-41d4-a716-446655440000",
1175 "session": "660e8400-e29b-41d4-a716-446655440000",
1176 "cmd": {
1177 "type": "gotcha_upsert",
1178 "key": "gotcha:test",
1179 "rule": "test rule",
1180 "reason": "test reason",
1181 "severity": "normal",
1182 "confirmed": true
1183 }
1184 });
1185 let result = serde_json::from_value::<Request>(json);
1186 assert!(
1187 result.is_err(),
1188 "server-owned field `confirmed` must be rejected"
1189 );
1190 }
1191
1192 #[test]
1193 fn file_enrich_rejects_gotcha_keys() {
1194 let json = serde_json::json!({
1196 "v": 2,
1197 "id": "550e8400-e29b-41d4-a716-446655440000",
1198 "session": "660e8400-e29b-41d4-a716-446655440000",
1199 "cmd": {
1200 "type": "file_enrich",
1201 "path": "src/main.rs",
1202 "purpose": "entry point",
1203 "gotcha_keys": ["gotcha:smuggled"]
1204 }
1205 });
1206 let result = serde_json::from_value::<Request>(json);
1207 assert!(
1208 result.is_err(),
1209 "daemon-managed field `gotcha_keys` must be rejected"
1210 );
1211 }
1212
1213 #[test]
1214 fn file_enrich_rejects_imports() {
1215 let json = serde_json::json!({
1217 "v": 2,
1218 "id": "550e8400-e29b-41d4-a716-446655440000",
1219 "session": "660e8400-e29b-41d4-a716-446655440000",
1220 "cmd": {
1221 "type": "file_enrich",
1222 "path": "src/main.rs",
1223 "purpose": "entry point",
1224 "imports": ["std::io"]
1225 }
1226 });
1227 let result = serde_json::from_value::<Request>(json);
1228 assert!(
1229 result.is_err(),
1230 "daemon-derived field `imports` must be rejected"
1231 );
1232 }
1233
1234 #[test]
1235 fn invalid_severity_rejected() {
1236 let json = serde_json::json!({
1237 "v": 2,
1238 "id": "550e8400-e29b-41d4-a716-446655440000",
1239 "session": "660e8400-e29b-41d4-a716-446655440000",
1240 "cmd": {
1241 "type": "gotcha_upsert",
1242 "key": "gotcha:test",
1243 "rule": "test",
1244 "reason": "test",
1245 "severity": "EXTREME"
1246 }
1247 });
1248 let result = serde_json::from_value::<Request>(json);
1249 assert!(
1250 result.is_err(),
1251 "invalid severity enum value must be rejected"
1252 );
1253 }
1254
1255 #[test]
1256 fn invalid_session_event_rejected() {
1257 let json = serde_json::json!({
1258 "v": 2,
1259 "id": "550e8400-e29b-41d4-a716-446655440000",
1260 "session": "660e8400-e29b-41d4-a716-446655440000",
1261 "cmd": {
1262 "type": "session_log",
1263 "event": "hit",
1264 "key": "file:foo"
1265 }
1266 });
1267 let result = serde_json::from_value::<Request>(json);
1268 assert!(
1269 result.is_err(),
1270 "hit is not a SessionEvent variant — must use consultation_hit command"
1271 );
1272 }
1273
1274 #[test]
1277 fn ok_response_serializes() {
1278 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1279 let resp = Response::ok(id, serde_json::json!({"pong": true}));
1280 let json = serde_json::to_value(&resp).unwrap();
1281 assert_eq!(json["status"], "ok");
1282 assert_eq!(json["data"]["pong"], true);
1283 }
1284
1285 #[test]
1286 fn err_response_serializes_with_code() {
1287 let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
1288 let resp = Response::err(id, ErrorCode::ValidationFailed, "key must not be empty");
1289 let json = serde_json::to_value(&resp).unwrap();
1290 assert_eq!(json["status"], "err");
1291 assert_eq!(json["code"], "validation_failed");
1292 assert_eq!(json["message"], "key must not be empty");
1293 }
1294
1295 #[test]
1296 fn error_code_roundtrips() {
1297 let codes = vec![
1298 ErrorCode::VersionMismatch,
1299 ErrorCode::FrameTooLarge,
1300 ErrorCode::MalformedRequest,
1301 ErrorCode::SessionMismatch,
1302 ErrorCode::ValidationFailed,
1303 ErrorCode::NotFound,
1304 ErrorCode::Conflict,
1305 ErrorCode::InvalidStateTransition,
1306 ErrorCode::StoreError,
1307 ErrorCode::Internal,
1308 ];
1309 for code in codes {
1310 let json = serde_json::to_value(&code).unwrap();
1311 let back: ErrorCode = serde_json::from_value(json).unwrap();
1312 assert_eq!(back, code);
1313 }
1314 }
1315
1316 #[test]
1319 fn session_flush_decodes() {
1320 let json = serde_json::json!({
1321 "v": 2,
1322 "id": "550e8400-e29b-41d4-a716-446655440000",
1323 "session": "660e8400-e29b-41d4-a716-446655440000",
1324 "cmd": { "type": "session_flush" }
1325 });
1326 let req: Request = serde_json::from_value(json).unwrap();
1327 assert!(matches!(req.cmd, Command::SessionFlush));
1328 }
1329
1330 #[test]
1331 fn hook_evaluate_v1_to_v2_preserves_actor() {
1332 let v1_args = serde_json::json!({
1337 "file_key": "file:x", "include_recent": false, "actor": "agentZ"
1338 });
1339 let v2 = v1_to_v2_command("hook_evaluate", &v1_args);
1340 let cmd: Command =
1341 serde_json::from_value(v2).expect("v1->v2 hook_evaluate must deserialize WITH actor");
1342 match cmd {
1343 Command::HookEvaluate(i) => assert_eq!(i.actor.as_deref(), Some("agentZ")),
1344 other => panic!("expected HookEvaluate, got {other:?}"),
1345 }
1346 }
1347
1348 #[test]
1349 fn session_harvest_decodes() {
1350 let json = serde_json::json!({
1351 "v": 2,
1352 "id": "550e8400-e29b-41d4-a716-446655440000",
1353 "session": "660e8400-e29b-41d4-a716-446655440000",
1354 "cmd": { "type": "session_harvest" }
1355 });
1356 let req: Request = serde_json::from_value(json).unwrap();
1357 assert!(matches!(req.cmd, Command::SessionHarvest));
1358 }
1359
1360 #[test]
1361 fn session_clear_consults_decodes() {
1362 let json = serde_json::json!({
1363 "v": 2,
1364 "id": "550e8400-e29b-41d4-a716-446655440000",
1365 "session": "660e8400-e29b-41d4-a716-446655440000",
1366 "cmd": { "type": "session_clear_consults" }
1367 });
1368 let req: Request = serde_json::from_value(json).unwrap();
1369 assert!(matches!(req.cmd, Command::SessionClearConsults));
1370 }
1371
1372 #[test]
1373 fn dev_note_upsert_create_mode() {
1374 let json = serde_json::json!({
1375 "v": 2,
1376 "id": "550e8400-e29b-41d4-a716-446655440000",
1377 "session": "660e8400-e29b-41d4-a716-446655440000",
1378 "cmd": {
1379 "type": "dev_note_upsert",
1380 "text": "Remember to update the changelog"
1381 }
1382 });
1383 let req: Request = serde_json::from_value(json).unwrap();
1384 match req.cmd {
1385 Command::DevNoteUpsert(input) => {
1386 assert!(input.key.is_none()); assert_eq!(input.text, "Remember to update the changelog");
1388 }
1389 _ => panic!("expected DevNoteUpsert"),
1390 }
1391 }
1392
1393 #[test]
1394 fn dev_note_upsert_update_mode() {
1395 let json = serde_json::json!({
1396 "v": 2,
1397 "id": "550e8400-e29b-41d4-a716-446655440000",
1398 "session": "660e8400-e29b-41d4-a716-446655440000",
1399 "cmd": {
1400 "type": "dev_note_upsert",
1401 "key": "dev_note:changelog-reminder-1712345678",
1402 "text": "Updated: remember to update changelog AND version"
1403 }
1404 });
1405 let req: Request = serde_json::from_value(json).unwrap();
1406 match req.cmd {
1407 Command::DevNoteUpsert(input) => {
1408 assert_eq!(
1409 input.key.as_deref(),
1410 Some("dev_note:changelog-reminder-1712345678")
1411 );
1412 }
1413 _ => panic!("expected DevNoteUpsert"),
1414 }
1415 }
1416
1417 #[test]
1420 fn command_kind_covers_all_variants() {
1421 let cases: Vec<(&str, Command)> = vec![
1423 ("ping", Command::Ping),
1424 ("metrics", Command::Metrics),
1425 ("get", Command::Get(GetInput { key: "k".into() })),
1426 (
1427 "hook_evaluate",
1428 Command::HookEvaluate(HookEvaluateInput {
1429 file_key: "f".into(),
1430 include_recent: false,
1431 actor: None,
1432 }),
1433 ),
1434 (
1435 "scan_prefix",
1436 Command::ScanPrefix(ScanPrefixInput { prefix: "p".into() }),
1437 ),
1438 (
1439 "history",
1440 Command::History(HistoryInput {
1441 key: "k".into(),
1442 limit: 10,
1443 }),
1444 ),
1445 (
1446 "history_since",
1447 Command::HistorySince(HistorySinceInput {
1448 key: "k".into(),
1449 since_ts: 0,
1450 limit: 10,
1451 }),
1452 ),
1453 (
1454 "session_check_consulted",
1455 Command::SessionCheckConsulted(SessionCheckConsultedInput { key: "k".into() }),
1456 ),
1457 (
1458 "session_check_consulted_recent",
1459 Command::SessionCheckConsultedRecent(SessionCheckConsultedRecentInput {
1460 key: "k".into(),
1461 ttl_secs: 900,
1462 }),
1463 ),
1464 (
1465 "mem_query",
1466 Command::MemQuery(MemQueryInput {
1467 query: "q".into(),
1468 mode: QueryMode::Text,
1469 limit: 20,
1470 }),
1471 ),
1472 ("mem_get", Command::MemGet(MemGetInput { key: "k".into() })),
1473 (
1474 "mem_bootstrap",
1475 Command::MemBootstrap(MemBootstrapInput {
1476 context_files: vec![],
1477 }),
1478 ),
1479 (
1480 "gotcha_upsert",
1481 Command::GotchaUpsert(GotchaDraftInput {
1482 key: "gotcha:t".into(),
1483 rule: "r".into(),
1484 reason: "r".into(),
1485 severity: Severity::Normal,
1486 affected_files: vec![],
1487 ref_url: None,
1488 tags: vec![],
1489 priority: Priority::Normal,
1490 source: None,
1491 }),
1492 ),
1493 (
1494 "gotcha_confirm",
1495 Command::GotchaConfirm(GotchaConfirmInput {
1496 key: "gotcha:t".into(),
1497 }),
1498 ),
1499 (
1500 "gotcha_tombstone",
1501 Command::GotchaTombstone(GotchaTombstoneInput {
1502 key: "gotcha:t".into(),
1503 }),
1504 ),
1505 (
1506 "file_enrich",
1507 Command::FileEnrich(FileEnrichInput {
1508 path: "p".into(),
1509 purpose: "p".into(),
1510 entry_points: vec![],
1511 decision_keys: vec![],
1512 todos: vec![],
1513 tags: vec![],
1514 priority: Priority::Normal,
1515 }),
1516 ),
1517 (
1518 "file_reparse",
1519 Command::FileReparse(FileReparseInput { path: "p".into() }),
1520 ),
1521 (
1522 "file_edit_hook",
1523 Command::FileEditHook(FileEditHookInput { path: "p".into() }),
1524 ),
1525 (
1526 "doc_capture",
1527 Command::DocCapture(DocCaptureInput { path: "p".into() }),
1528 ),
1529 (
1530 "decision_upsert",
1531 Command::DecisionUpsert(DecisionUpsertInput {
1532 slug: "s".into(),
1533 value: "v".into(),
1534 summary: "s".into(),
1535 rationale: "r".into(),
1536 tags: vec![],
1537 priority: Priority::Normal,
1538 }),
1539 ),
1540 (
1541 "dev_note_upsert",
1542 Command::DevNoteUpsert(DevNoteUpsertInput {
1543 key: None,
1544 text: "t".into(),
1545 tags: vec![],
1546 priority: Priority::Normal,
1547 }),
1548 ),
1549 (
1550 "session_log",
1551 Command::SessionLog(SessionLogInput {
1552 event: SessionEvent::Miss,
1553 key: "k".into(),
1554 session_id: None,
1555 }),
1556 ),
1557 (
1558 "consultation_hit",
1559 Command::ConsultationHit(ConsultationHitInput {
1560 key: "k".into(),
1561 actor: None,
1562 session_id: None,
1563 agent_id: None,
1564 }),
1565 ),
1566 ("session_flush", Command::SessionFlush),
1567 ("session_harvest", Command::SessionHarvest),
1568 ("session_clear_consults", Command::SessionClearConsults),
1569 ];
1570
1571 assert_eq!(cases.len(), 26, "must cover all 26 command variants");
1572 for (expected_kind, cmd) in &cases {
1573 assert_eq!(
1574 cmd.kind(),
1575 *expected_kind,
1576 "kind() mismatch for {:?}",
1577 expected_kind
1578 );
1579 }
1580 }
1581
1582 #[test]
1583 fn command_is_mutation_classification() {
1584 assert!(!Command::Ping.is_mutation());
1586 assert!(!Command::Metrics.is_mutation());
1587 assert!(!Command::Get(GetInput { key: "k".into() }).is_mutation());
1588 assert!(!Command::MemQuery(MemQueryInput {
1589 query: "q".into(),
1590 mode: QueryMode::Text,
1591 limit: 20,
1592 })
1593 .is_mutation());
1594
1595 assert!(Command::MemGet(MemGetInput { key: "k".into() }).is_mutation());
1597 assert!(Command::MemBootstrap(MemBootstrapInput {
1598 context_files: vec![]
1599 })
1600 .is_mutation());
1601
1602 assert!(Command::GotchaConfirm(GotchaConfirmInput {
1604 key: "gotcha:t".into()
1605 })
1606 .is_mutation());
1607 assert!(Command::SessionLog(SessionLogInput {
1608 event: SessionEvent::Miss,
1609 key: "k".into(),
1610 session_id: None,
1611 })
1612 .is_mutation());
1613 assert!(Command::SessionFlush.is_mutation());
1614 assert!(Command::SessionHarvest.is_mutation());
1615 assert!(Command::SessionClearConsults.is_mutation());
1616 }
1617
1618 #[test]
1619 fn command_target_key_returns_expected_values() {
1620 assert_eq!(Command::Ping.target_key(), "");
1621 assert_eq!(
1622 Command::Get(GetInput {
1623 key: "file:src/main.rs".into()
1624 })
1625 .target_key(),
1626 "file:src/main.rs"
1627 );
1628 assert_eq!(
1629 Command::GotchaUpsert(GotchaDraftInput {
1630 key: "gotcha:test".into(),
1631 rule: "r".into(),
1632 reason: "r".into(),
1633 severity: Severity::Normal,
1634 affected_files: vec![],
1635 ref_url: None,
1636 tags: vec![],
1637 priority: Priority::Normal,
1638 source: None,
1639 })
1640 .target_key(),
1641 "gotcha:test"
1642 );
1643 assert_eq!(
1644 Command::DecisionUpsert(DecisionUpsertInput {
1645 slug: "my-decision".into(),
1646 value: "v".into(),
1647 summary: "s".into(),
1648 rationale: "r".into(),
1649 tags: vec![],
1650 priority: Priority::Normal,
1651 })
1652 .target_key(),
1653 "my-decision"
1654 );
1655 assert_eq!(
1657 Command::DevNoteUpsert(DevNoteUpsertInput {
1658 key: None,
1659 text: "t".into(),
1660 tags: vec![],
1661 priority: Priority::Normal,
1662 })
1663 .target_key(),
1664 ""
1665 );
1666 assert_eq!(Command::SessionFlush.target_key(), "");
1667 assert_eq!(Command::SessionClearConsults.target_key(), "");
1668 }
1669
1670 #[test]
1671 fn audit_entry_serializes() {
1672 let entry = AuditEntry {
1673 ts: 1700000000,
1674 peer_uid: 501,
1675 peer_pid: Some(1234),
1676 daemon_session: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1677 request_id: Uuid::parse_str("660e8400-e29b-41d4-a716-446655440000").unwrap(),
1678 command_kind: "gotcha_upsert".into(),
1679 target_key: "gotcha:test".into(),
1680 accepted: true,
1681 error_code: None,
1682 };
1683 let json = serde_json::to_value(&entry).unwrap();
1684 assert_eq!(json["peer_uid"], 501);
1685 assert_eq!(json["command_kind"], "gotcha_upsert");
1686 assert_eq!(json["accepted"], true);
1687 assert!(json.get("error_code").is_none());
1689 }
1690
1691 #[test]
1692 fn audit_entry_rejected_includes_error_code() {
1693 let entry = AuditEntry {
1694 ts: 1700000000,
1695 peer_uid: 501,
1696 peer_pid: None,
1697 daemon_session: Uuid::nil(),
1698 request_id: Uuid::nil(),
1699 command_kind: "gotcha_confirm".into(),
1700 target_key: "gotcha:missing".into(),
1701 accepted: false,
1702 error_code: Some(ErrorCode::NotFound),
1703 };
1704 let json = serde_json::to_value(&entry).unwrap();
1705 assert_eq!(json["accepted"], false);
1706 assert_eq!(json["error_code"], "not_found");
1707 assert!(json["peer_pid"].is_null());
1708 }
1709
1710 #[test]
1713 fn store_priority_to_protocol_severity_preserves_all_variants() {
1714 use crate::store::Priority as SP;
1715 assert_eq!(Severity::from(SP::Low), Severity::Low);
1716 assert_eq!(Severity::from(SP::Normal), Severity::Normal);
1717 assert_eq!(Severity::from(SP::High), Severity::High);
1718 assert_eq!(Severity::from(SP::Critical), Severity::Critical);
1719 }
1720
1721 #[test]
1722 fn store_priority_to_protocol_priority_preserves_all_variants() {
1723 use crate::store::Priority as SP;
1724 assert_eq!(Priority::from(SP::Low), Priority::Low);
1725 assert_eq!(Priority::from(SP::Normal), Priority::Normal);
1726 assert_eq!(Priority::from(SP::High), Priority::High);
1727 assert_eq!(Priority::from(SP::Critical), Priority::Critical);
1728 }
1729
1730 #[test]
1740 fn v1_to_v2_command_handles_mem_get() {
1741 let mapped = v1_to_v2_command("mem_get", &serde_json::json!({ "key": "file:src/main.rs" }));
1742 assert_eq!(
1743 mapped,
1744 serde_json::json!({ "type": "mem_get", "key": "file:src/main.rs" })
1745 );
1746
1747 let cmd: Command = serde_json::from_value(mapped).expect("mem_get must decode as Command");
1750 match cmd {
1751 Command::MemGet(input) => assert_eq!(input.key, "file:src/main.rs"),
1752 other => panic!("expected Command::MemGet, got {:?}", other.kind()),
1753 }
1754 }
1755
1756 #[test]
1757 fn v1_to_v2_command_handles_mem_bootstrap() {
1758 let mapped = v1_to_v2_command(
1760 "mem_bootstrap",
1761 &serde_json::json!({ "context_files": ["src/lib.rs", "src/main.rs"] }),
1762 );
1763 let cmd: Command =
1764 serde_json::from_value(mapped).expect("mem_bootstrap must decode as Command");
1765 match cmd {
1766 Command::MemBootstrap(input) => {
1767 assert_eq!(input.context_files, vec!["src/lib.rs", "src/main.rs"]);
1768 }
1769 other => panic!("expected Command::MemBootstrap, got {:?}", other.kind()),
1770 }
1771
1772 let mapped_empty = v1_to_v2_command("mem_bootstrap", &serde_json::json!({}));
1774 let cmd_empty: Command = serde_json::from_value(mapped_empty).unwrap();
1775 match cmd_empty {
1776 Command::MemBootstrap(input) => assert!(input.context_files.is_empty()),
1777 other => panic!("expected MemBootstrap, got {:?}", other.kind()),
1778 }
1779 }
1780
1781 #[test]
1782 #[should_panic(expected = "v1_to_v2_command called with unsupported command")]
1783 fn v1_to_v2_command_panic_message_lists_only_unsupported() {
1784 let _ = v1_to_v2_command("totally_bogus_cmd_xyz", &serde_json::json!({}));
1788 }
1789
1790 #[test]
1791 fn v1_to_v2_command_no_mutations_silently_accepted() {
1792 let mutation_names = [
1796 "mem_set",
1797 "gotcha_upsert",
1798 "gotcha_confirm",
1799 "gotcha_tombstone",
1800 "decision_upsert",
1801 "dev_note_upsert",
1802 "file_enrich",
1803 "file_reparse",
1804 "file_edit_hook",
1805 "doc_capture",
1806 "session_log",
1807 "consultation_hit",
1808 "session_flush",
1809 "session_harvest",
1810 "session_clear_consults",
1811 ];
1812 for name in mutation_names {
1813 let result = std::panic::catch_unwind(|| {
1814 v1_to_v2_command(name, &serde_json::json!({}));
1815 });
1816 assert!(
1817 result.is_err(),
1818 "mutation command '{name}' must panic in v1_to_v2_command — \
1819 mutating callers must use daemon_v2() with typed Command"
1820 );
1821 }
1822 }
1823
1824 #[test]
1830 fn request_without_agent_field_deserializes_as_none() {
1831 let json = serde_json::json!({
1832 "v": 2,
1833 "id": "550e8400-e29b-41d4-a716-446655440000",
1834 "session": "660e8400-e29b-41d4-a716-446655440000",
1835 "cmd": { "type": "ping" }
1836 });
1837 let req: Request = serde_json::from_value(json).unwrap();
1838 assert!(
1839 req.agent.is_none(),
1840 "missing `agent` must decode to None (ADR-018 additive contract)"
1841 );
1842 }
1843
1844 #[test]
1845 fn request_with_agent_field_deserializes_and_preserves_value() {
1846 for (wire, expected) in [
1847 ("claude", AgentKind::Claude),
1848 ("codex", AgentKind::Codex),
1849 ("cli", AgentKind::Cli),
1850 ("supervisor", AgentKind::Supervisor),
1851 ("unknown", AgentKind::Unknown),
1852 ] {
1853 let json = serde_json::json!({
1854 "v": 2,
1855 "id": "550e8400-e29b-41d4-a716-446655440000",
1856 "session": "660e8400-e29b-41d4-a716-446655440000",
1857 "agent": wire,
1858 "cmd": { "type": "ping" }
1859 });
1860 let req: Request = serde_json::from_value(json)
1861 .unwrap_or_else(|e| panic!("decode failed for agent={wire}: {e}"));
1862 assert_eq!(req.agent, Some(expected));
1863 }
1864 }
1865
1866 #[test]
1867 fn request_with_unknown_agent_variant_rejected() {
1868 let json = serde_json::json!({
1869 "v": 2,
1870 "id": "550e8400-e29b-41d4-a716-446655440000",
1871 "session": "660e8400-e29b-41d4-a716-446655440000",
1872 "agent": "gemini",
1873 "cmd": { "type": "ping" }
1874 });
1875 let res = serde_json::from_value::<Request>(json);
1876 assert!(
1877 res.is_err(),
1878 "unknown agent variant must reject at decode (closed enum)"
1879 );
1880 }
1881
1882 #[test]
1883 fn request_with_agent_round_trips_through_serialize_deserialize() {
1884 let original = Request {
1885 v: PROTOCOL_VERSION,
1886 id: Uuid::new_v4(),
1887 session: Uuid::new_v4(),
1888 agent: Some(AgentKind::Codex),
1889 cmd: Command::Ping,
1890 };
1891 let bytes = serde_json::to_vec(&original).unwrap();
1892 let round_tripped: Request = serde_json::from_slice(&bytes).unwrap();
1893 assert_eq!(round_tripped.agent, Some(AgentKind::Codex));
1894 assert_eq!(round_tripped.v, PROTOCOL_VERSION);
1895 }
1896
1897 #[test]
1898 fn consultation_hit_input_actor_is_optional() {
1899 let without_actor: ConsultationHitInput =
1901 serde_json::from_value(serde_json::json!({"key": "file:x"})).unwrap();
1902 assert_eq!(without_actor.key, "file:x");
1903 assert_eq!(without_actor.actor, None);
1904 assert_eq!(without_actor.session_id, None);
1905 assert_eq!(without_actor.agent_id, None);
1906
1907 let with_actor: ConsultationHitInput =
1909 serde_json::from_value(serde_json::json!({"key": "file:x", "actor": "a"})).unwrap();
1910 assert_eq!(with_actor.key, "file:x");
1911 assert_eq!(with_actor.actor, Some("a".to_string()));
1912 assert_eq!(with_actor.session_id, None);
1913 assert_eq!(with_actor.agent_id, None);
1914
1915 let with_session: ConsultationHitInput = serde_json::from_value(serde_json::json!({
1917 "key": "file:x",
1918 "session_id": "sess-abc",
1919 "agent_id": "agent-xyz"
1920 }))
1921 .unwrap();
1922 assert_eq!(with_session.key, "file:x");
1923 assert_eq!(with_session.actor, None);
1924 assert_eq!(with_session.session_id, Some("sess-abc".to_string()));
1925 assert_eq!(with_session.agent_id, Some("agent-xyz".to_string()));
1926
1927 let round_tripped: ConsultationHitInput =
1929 serde_json::from_str(&serde_json::to_string(&with_session).unwrap()).unwrap();
1930 assert_eq!(round_tripped.session_id, Some("sess-abc".to_string()));
1931 assert_eq!(round_tripped.agent_id, Some("agent-xyz".to_string()));
1932 }
1933}