1use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::view::RuntimeView;
11
12#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct SessionId(pub String);
20
21impl SessionId {
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25}
26
27impl std::fmt::Display for SessionId {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str(&self.0)
30 }
31}
32
33impl From<String> for SessionId {
34 fn from(s: String) -> Self {
35 Self(s)
36 }
37}
38
39impl From<&str> for SessionId {
40 fn from(s: &str) -> Self {
41 Self(s.to_string())
42 }
43}
44
45pub type SessionEnv = Vec<(String, String)>;
47
48const ENV_STRIP_PREFIXES: &[&str] = &[
51 "SYNAPS_CLIENT_",
52 "SYNAPS_TUI_",
53 "SYNAPS_DAEMON_",
54 "SYNAPS_MEM_TRACE",
55];
56
57pub fn capture_client_env() -> (SessionEnv, Vec<String>) {
64 strip_client_env(std::env::vars())
65}
66
67pub fn strip_client_env(
70 vars: impl IntoIterator<Item = (String, String)>,
71) -> (SessionEnv, Vec<String>) {
72 let mut env: SessionEnv = Vec::new();
73 let mut stripped: Vec<String> = Vec::new();
74 for (k, v) in vars {
75 let secret_value = agent_core::core::config::is_secret_env_value(&v);
79 if should_strip_env(&k) || secret_value {
80 if is_secret_key(&k) || secret_value {
82 stripped.push(k);
83 }
84 } else {
85 env.push((k, v));
86 }
87 }
88 env.sort_by(|(a, _), (b, _)| a.cmp(b));
89 stripped.sort();
90 (env, stripped)
91}
92
93pub fn should_strip_env(key: &str) -> bool {
95 let upper = key.to_ascii_uppercase();
96 for prefix in ENV_STRIP_PREFIXES {
97 if upper.starts_with(prefix) {
98 return true;
99 }
100 }
101 is_secret_key(&upper)
102}
103
104pub fn is_secret_key(key: &str) -> bool {
107 agent_core::core::config::is_secret_key(key)
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize)]
115pub struct SessionConfig {
116 #[serde(default, with = "double_option", skip_serializing_if = "Option::is_none")]
121 pub continue_session: Option<Option<String>>,
122 #[serde(default)]
124 pub system: Option<String>,
125 #[serde(default)]
127 pub prompt_manifest: Option<PathBuf>,
128 #[serde(default)]
132 pub cwd: Option<PathBuf>,
133 #[serde(default)]
138 pub env: Option<SessionEnv>,
139 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub env_stripped: Vec<String>,
146 #[serde(default)]
149 pub auto_approve_confirms: bool,
150 #[serde(default)]
152 pub model_override: Option<String>,
153 #[serde(default = "default_true")]
156 pub persist: bool,
157 #[serde(default)]
161 pub auto_compact: bool,
162 #[serde(default)]
165 pub compaction_policy: CompactionPolicyWire,
166 #[serde(default = "default_true")]
170 pub await_extensions: bool,
171 #[serde(default)]
173 pub keep_warm: bool,
174 #[serde(default)]
178 pub name: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub max_session_cost: Option<f64>,
185}
186
187fn default_true() -> bool {
188 true
189}
190
191#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum CompactionPolicyWire {
195 #[default]
196 InPlace,
197 LinkedSuccessor,
198}
199
200impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
201 fn from(p: CompactionPolicyWire) -> Self {
202 match p {
203 CompactionPolicyWire::InPlace => Self::InPlace,
204 CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
205 }
206 }
207}
208
209impl Default for SessionConfig {
210 fn default() -> Self {
211 Self {
212 continue_session: None,
213 system: None,
214 prompt_manifest: None,
215 cwd: None,
216 env: None,
217 env_stripped: Vec::new(),
218 auto_approve_confirms: false,
219 model_override: None,
220 persist: true,
221 auto_compact: false,
222 compaction_policy: CompactionPolicyWire::InPlace,
223 await_extensions: true,
224 keep_warm: false,
225 name: None,
226 max_session_cost: None,
227 }
228 }
229}
230
231#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ContinueInfoWire {
234 pub session_id: String,
235 pub resolved_via: Option<String>,
237 pub query: String,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub compaction_notice: Option<String>,
241}
242
243impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
244 fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
245 Self {
246 session_id: c.session_id.clone(),
247 resolved_via: c.resolved_via.clone(),
248 query: c.query.clone(),
249 compaction_notice: c.compaction_notice.clone(),
250 }
251 }
252}
253
254#[derive(Clone, Debug, Serialize, Deserialize)]
255pub struct SessionMeta {
256 pub id: SessionId,
257 pub name: Option<String>,
258 pub model: String,
259 pub cwd: Option<PathBuf>,
260 pub created_at: chrono::DateTime<chrono::Utc>,
261 pub continued: bool,
262 pub continue_info: Option<ContinueInfoWire>,
263 pub host_pid: u32,
265 #[serde(default)]
267 pub lifecycle: SessionLifecycle,
268 #[serde(default)]
269 pub clients: usize,
270 #[serde(default)]
271 pub input_owner: Option<ClientId>,
272 #[serde(default)]
273 pub awaiting_input: usize,
274 #[serde(default)]
276 pub journal_id: String,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub locked_by: Option<String>,
281}
282
283#[repr(u8)]
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum SessionLifecycle {
288 #[default]
289 Live = 0,
290 Parking = 1,
291 Parked = 2,
292 Ending = 3,
293}
294
295impl SessionLifecycle {
296 pub fn from_u8(v: u8) -> Self {
297 match v {
298 1 => Self::Parking,
299 2 => Self::Parked,
300 3 => Self::Ending,
301 _ => Self::Live,
302 }
303 }
304}
305
306#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
308#[serde(transparent)]
309pub struct ClientId(pub u64);
310
311#[derive(Clone, Debug, Serialize, Deserialize)]
312pub struct ClientMeta {
313 pub kind: ClientKind,
314 pub terminal: Option<String>,
316 pub instance: String,
318 #[serde(default)]
320 pub history: HistoryMode,
321 #[serde(default = "default_tail_items")]
323 pub tail_items: usize,
324}
325
326pub const DEFAULT_TAIL_ITEMS: usize = 120;
328
329fn default_tail_items() -> usize {
330 DEFAULT_TAIL_ITEMS
331}
332
333pub fn tail_items_from_env() -> usize {
336 std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
337 .ok()
338 .and_then(|v| v.trim().parse::<usize>().ok())
339 .unwrap_or(DEFAULT_TAIL_ITEMS)
340}
341
342impl ClientMeta {
343 pub fn new(kind: ClientKind) -> Self {
345 Self {
346 kind,
347 terminal: None,
348 instance: uuid::Uuid::new_v4().to_string(),
349 history: HistoryMode::default(),
350 tail_items: tail_items_from_env(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
359#[serde(rename_all = "snake_case")]
360pub enum HistoryMode {
361 #[default]
362 Full,
363 Digest,
364}
365
366impl HistoryMode {
367 pub fn from_env_or(default: Self) -> Self {
370 match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
371 Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
372 Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
373 _ => default,
374 }
375 }
376
377 pub fn attach_client_default() -> Self {
380 Self::Digest
381 }
382}
383
384#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum ClientKind {
387 Tui,
388 Chat,
389 Attach,
390 Rpc,
391 Server,
392 Test,
393}
394
395#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum AttachMode {
400 Mirror,
401 Takeover,
402 Observe,
403}
404
405#[derive(Clone, Copy, Debug, PartialEq, Eq)]
407pub enum AttachState {
408 Attached(usize),
409 Detached { running: bool },
410 Parking,
412 Parked,
414}
415
416#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum PromptKind {
421 Secret,
422 Confirm,
423}
424
425pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
428pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
432
433impl PromptKind {
434 pub fn from_title(title: &str) -> Self {
437 if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
438 Self::Confirm
439 } else {
440 Self::Secret
441 }
442 }
443}
444
445#[derive(Clone, Debug, Serialize, Deserialize)]
446pub struct PromptRequest {
447 pub id: u64,
449 pub kind: PromptKind,
450 pub title: String,
451 pub prompt: String,
452 pub raised_at: chrono::DateTime<chrono::Utc>,
453}
454
455#[derive(Debug)]
460pub struct Addressed {
461 pub from: Option<ClientId>,
462 pub cmd: SessionCommand,
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "snake_case")]
468pub enum CheckpointReason {
469 Reload,
470 HostRequest,
471}
472
473#[derive(Serialize, Deserialize)]
478#[serde(tag = "cmd", rename_all = "snake_case")]
479pub enum SessionCommand {
480 Submit {
483 text: String,
484 #[serde(default)]
488 attachments: Vec<serde_json::Value>,
489 },
490 Steer { text: String },
493 Cancel,
496 Answer { prompt_id: u64, value: Option<String> },
499 Set { id: u64, setting: SessionSetting },
501 Compact { instructions: Option<String> },
502 NewSession,
504 Save,
505 Query { id: u64, query: SessionQuery },
507 Attach { client: ClientMeta, mode: AttachMode },
509 Detach { client: ClientId },
510 End { reason: EndReason },
512 Resync { client: ClientId, since_seq: u64 },
514 EngineCommand { id: u64, name: String, arg: String },
518 SubmitPrepared {
522 messages: Vec<crate::SharedMessage>,
523 #[serde(default)]
524 user_text: Option<String>,
525 },
526 PluginCommand { id: u64, plugin: String, name: String, arg: String },
529 Resume { id: u64, query: String },
532 Checkpoint { reason: CheckpointReason },
536 KeepWarm { on: bool },
538 #[serde(skip)]
541 Park,
542 #[serde(skip)]
544 HostEvent(HostEvent),
545 DriverStart {
550 plugin: String,
551 command: String,
552 arg: String,
553 },
554}
555
556#[derive(Clone, Debug, Serialize, Deserialize)]
561pub struct SessionReloadRecord {
562 pub config: SessionConfig,
563 pub keep_warm: bool,
564 pub lifecycle: SessionLifecycle,
565 #[serde(default)]
566 pub settings_replay: Vec<SessionSetting>,
567 pub model: String,
568 pub thinking_level: String,
569}
570
571impl std::fmt::Debug for SessionCommand {
572 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573 match self {
574 Self::Submit { attachments, .. } => {
575 let kinds: Vec<&str> = attachments
576 .iter()
577 .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
578 .collect();
579 f.debug_struct("Submit")
580 .field("text", &format_args!("<redacted>"))
581 .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
582 .finish()
583 }
584 Self::Steer { .. } => f
585 .debug_struct("Steer")
586 .field("text", &format_args!("<redacted>"))
587 .finish(),
588 Self::SubmitPrepared { .. } => f
589 .debug_struct("SubmitPrepared")
590 .field("messages", &format_args!("<redacted>"))
591 .field("user_text", &format_args!("<redacted>"))
592 .finish(),
593 Self::Answer { prompt_id, .. } => f
594 .debug_struct("Answer")
595 .field("prompt_id", prompt_id)
596 .field("value", &format_args!("<redacted>"))
597 .finish(),
598 Self::Resume { id, .. } => f
599 .debug_struct("Resume")
600 .field("id", id)
601 .field("query", &format_args!("<redacted>"))
602 .finish(),
603 Self::Cancel => f.write_str("Cancel"),
604 Self::Set { id, setting } => {
605 f.debug_struct("Set").field("id", id).field("setting", setting).finish()
606 }
607 Self::Compact { instructions } => {
608 f.debug_struct("Compact").field("instructions", instructions).finish()
609 }
610 Self::NewSession => f.write_str("NewSession"),
611 Self::Save => f.write_str("Save"),
612 Self::Park => f.write_str("Park"),
613 Self::Query { id, query } => {
614 f.debug_struct("Query").field("id", id).field("query", query).finish()
615 }
616 Self::Attach { client, mode } => {
617 f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
618 }
619 Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
620 Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
621 Self::Resync { client, since_seq } => f
622 .debug_struct("Resync")
623 .field("client", client)
624 .field("since_seq", since_seq)
625 .finish(),
626 Self::EngineCommand { id, name, arg } => f
627 .debug_struct("EngineCommand")
628 .field("id", id)
629 .field("name", name)
630 .field("arg", arg)
631 .finish(),
632 Self::PluginCommand { id, plugin, name, arg } => f
633 .debug_struct("PluginCommand")
634 .field("id", id)
635 .field("plugin", plugin)
636 .field("name", name)
637 .field("arg", arg)
638 .finish(),
639 Self::Checkpoint { reason } => {
640 f.debug_struct("Checkpoint").field("reason", reason).finish()
641 }
642 Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
643 Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
644 Self::DriverStart { plugin, command, .. } => f
645 .debug_struct("DriverStart")
646 .field("plugin", plugin)
647 .field("command", command)
648 .field("arg", &format_args!("<redacted>"))
649 .finish(),
650 }
651 }
652}
653
654#[derive(Debug, Clone)]
656pub enum HostEvent {
657 ExtensionNotification {
658 extension_id: String,
659 method: String,
660 params: serde_json::Value,
661 },
662 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize)]
667#[serde(tag = "setting", rename_all = "snake_case")]
668pub enum SessionSetting {
669 Model { model: String },
671 ReasoningLevel {
673 #[serde(with = "reasoning_level_serde")]
674 level: agent_core::reasoning::ReasoningLevel,
675 },
676 ContextWindow { tokens: Option<u64> },
678 CompactionModel { model: Option<String> },
679 ApiRetries { n: u32 },
680 SubagentTimeout { secs: u64 },
681 MaxToolOutput { bytes: usize },
682 BashTimeout { secs: u64 },
683 BashMaxTimeout { secs: u64 },
684 SystemPrompt { text: String },
686 ReloadPrompt,
688 GrantWorkerModel { model: String },
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct SettingApplied {
694 #[serde(default)]
696 pub id: u64,
697 pub setting: String,
698 pub ok: bool,
699 pub message: Option<String>,
700 pub view: RuntimeView,
701 #[serde(default)]
704 pub clamp: Option<ReasoningClampWire>,
705}
706
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708pub struct ReasoningClampWire {
709 pub from: String,
710 pub to: String,
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
714#[serde(tag = "query", rename_all = "snake_case")]
715pub enum SessionQuery {
716 Status,
718 Messages,
720 SubagentRows,
722 PromptInspection,
724 ToolsSchema,
726 View,
728 ContextAssessment,
730 ContextReport,
732 DisplayTail { items: usize },
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
737#[serde(rename_all = "snake_case")]
738pub enum EndReason {
739 ClientQuit,
740 HostShutdown,
741 Evicted,
742 Error,
743 Idle,
747}
748
749#[derive(Debug, Clone)]
755#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
757 Stream(crate::StreamEvent),
758 TurnStarted {
762 turn_baseline: usize,
763 trigger: TurnTrigger,
764 user_text: Option<String>,
765 },
766 Conversation(ConversationSnapshot),
769 Prompt(PromptRequest),
770 PromptResolved { prompt_id: u64 },
772 External(crate::events::types::Event),
774 AutoTurnCapReached { cap: u32 },
775 Idle,
779 Steered { text: String, delivered: bool },
781 Dequeued { text: String },
782 SystemNotice(String),
784 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
785 ExtensionNotification {
786 extension_id: String,
787 method: String,
788 params: serde_json::Value,
789 },
790 SettingChanged(SettingApplied),
791 QueryResult { id: u64, value: serde_json::Value },
792 Attached {
794 client: ClientId,
795 snapshot: AttachSnapshot,
796 },
797 ClientJoined { client: ClientId, kind: ClientKind },
798 ClientLeft { client: ClientId },
799 Ended { reason: EndReason },
800 Aborted { context_saved: bool },
803 Cleared { session_id: String },
805 CompactionStarted { source: String, disclosure: String },
807 CompactionApplied {
808 previous_session_id: String,
809 session_id: String,
810 chains_advanced: Vec<String>,
811 queued_restored: Option<String>,
812 msg_count: usize,
813 },
814 CompactionFailed { message: String, panicked: bool },
816 CompactionCancelled,
817 SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
820 Resumed {
822 id: u64,
823 old_id: String,
824 new_id: String,
825 via: Option<String>,
826 clamp_notice: Option<String>,
827 },
828 InputOwnerChanged {
831 from: Option<ClientId>,
832 to: Option<ClientId>,
833 reason: OwnerChangeReason,
834 },
835 Refused { client: ClientId, command: String, reason: String },
837 AttachRefused { message: String },
839 Lifecycle(SessionLifecycle),
841 Reloading { generation: u64, retry_after_ms: u64 },
843 DriverArmed {
845 plugin_id: String,
846 run_id: String,
847 models: Vec<crate::extensions::session_driver::Selection>,
848 selection: crate::extensions::session_driver::Selection,
849 deadline_ms: Option<u64>,
851 notice: String,
852 },
853 DriverRevoked {
855 reason: String,
856 undelivered_steering: Vec<String>,
857 },
858 DriverTurnOutcome {
860 outcome: crate::extensions::session_driver::Outcome,
861 selection: crate::extensions::session_driver::Selection,
862 feedback: Option<String>,
863 },
864 CostCapReached {
869 scope: String,
870 cost: f64,
871 cap: f64,
872 },
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
876#[serde(rename_all = "snake_case")]
877pub enum OwnerChangeReason {
878 Attach,
879 Takeover,
880 OwnerDetached,
881 Reload,
882}
883
884#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
885#[serde(rename_all = "snake_case")]
886pub enum TurnTrigger {
887 User,
888 QueuedAuto,
889 EventAuto,
890 PluginCommand,
891 Compaction,
892 DriverAuto,
894}
895
896#[derive(Debug, Clone)]
899pub struct Envelope {
900 pub session_id: SessionId,
901 pub seq: u64,
902 pub ts: chrono::DateTime<chrono::Utc>,
903 pub event: SessionEventWire,
904}
905
906#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
907pub struct ConversationTokens {
908 pub input: u64,
909 pub output: u64,
910 pub cache_read: u64,
911 pub cache_creation: u64,
912}
913
914#[derive(Debug, Clone, Default, Serialize, Deserialize)]
917pub struct ConversationSnapshot {
918 #[serde(default)]
920 pub header: SessionHeader,
921 pub api_messages: Vec<crate::SharedMessage>,
922 #[serde(default)]
925 pub messages_len: usize,
926 pub tokens: ConversationTokens,
927 pub cost: f64,
928 pub abort_context: Option<String>,
929 pub queued_message: Option<String>,
930 pub pending_events_len: usize,
931 pub consecutive_auto_turns: u32,
932}
933
934#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
936pub struct SessionHeader {
937 pub id: String,
938 pub title: String,
939 pub name: Option<String>,
940 pub model: String,
941 pub thinking_level: String,
942 pub system_prompt: Option<String>,
943 pub created_at: chrono::DateTime<chrono::Utc>,
944 pub updated_at: chrono::DateTime<chrono::Utc>,
945 pub parent_session: Option<String>,
946}
947
948impl From<&crate::Session> for SessionHeader {
949 fn from(s: &crate::Session) -> Self {
950 Self {
951 id: s.id.clone(),
952 title: s.title.clone(),
953 name: s.name.clone(),
954 model: s.model.clone(),
955 thinking_level: s.thinking_level.clone(),
956 system_prompt: s.system_prompt.clone(),
957 created_at: s.created_at,
958 updated_at: s.updated_at,
959 parent_session: s.parent_session.clone(),
960 }
961 }
962}
963
964#[derive(Debug, Clone)]
965pub struct AttachSnapshot {
966 pub meta: SessionMeta,
967 pub view: RuntimeView,
968 pub conversation: ConversationSnapshot,
969 pub streaming: bool,
970 pub replay: Vec<Envelope>,
973 pub pending_prompts: Vec<PromptRequest>,
974 pub clients: Vec<(ClientId, ClientKind)>,
975 pub input_owner: Option<ClientId>,
978 pub display_tail: Option<crate::session::display::DisplayTail>,
981}
982
983pub mod reasoning_level_serde {
986 use agent_core::reasoning::ReasoningLevel;
987 use serde::{Deserialize, Deserializer, Serialize, Serializer};
988
989 pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
990 level.as_str().serialize(s)
991 }
992
993 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
994 let s = String::deserialize(d)?;
995 ReasoningLevel::parse(&s)
996 .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 #[test]
1005 fn prompt_kind_from_title() {
1006 assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1007 assert_eq!(
1008 PromptKind::from_title("Confirm tool activation"),
1009 PromptKind::Confirm
1010 );
1011 assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1012 }
1013
1014 #[test]
1015 fn session_config_default_persists() {
1016 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1017 assert!(cfg.persist);
1018 assert!(cfg.cwd.is_none());
1019 assert_eq!(
1020 serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1021 serde_json::Value::Bool(true)
1022 );
1023 }
1024
1025 #[test]
1026 fn session_config_env_defaults_to_none() {
1027 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1028 assert!(cfg.env.is_none());
1029 }
1030
1031 #[test]
1032 fn session_config_env_round_trips() {
1033 let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1034 let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1035 let json = serde_json::to_string(&cfg).unwrap();
1036 let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1037 assert_eq!(cfg2.env, Some(env));
1038 }
1039
1040 #[test]
1041 fn should_strip_env_client_prefixes() {
1042 assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1043 assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1044 assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1045 assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1046 assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1047 assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1049 assert!(!should_strip_env("HOME"));
1050 assert!(!should_strip_env("PATH"));
1051 assert!(!should_strip_env("VIRTUAL_ENV"));
1052 }
1053
1054 #[test]
1055 fn should_strip_env_secrets() {
1056 assert!(should_strip_env("ANTHROPIC_API_KEY"));
1057 assert!(should_strip_env("OPENAI_API_KEY"));
1058 assert!(should_strip_env("my_secret_token"));
1059 assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1060 assert!(should_strip_env("DB_PASSWORD"));
1061 assert!(should_strip_env("GCP_CREDENTIALS"));
1062 assert!(should_strip_env("My_Api_Key"));
1064 assert!(!should_strip_env("MY_VALUE"));
1066 assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1067 assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1069 assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1070 assert!(!should_strip_env("SSH_KEY_PATH"));
1071 assert!(should_strip_env("GH_TOKEN"));
1072 assert!(should_strip_env("SSH_KEY"));
1073 assert!(should_strip_env("SECRET_KEY_BASE"));
1074 assert!(should_strip_env("MY_PASSWD"));
1075 }
1076
1077 #[test]
1078 fn strip_client_env_drops_credential_values_whatever_the_key() {
1079 let vars = vec![
1080 ("HOME".to_string(), "/home/u".to_string()),
1081 (
1082 "DATABASE_URL".to_string(),
1083 "postgres://app:hunter2@db/prod".to_string(),
1084 ),
1085 (
1086 "HTTPS_PROXY".to_string(),
1087 "http://user:pass@proxy:3128".to_string(),
1088 ),
1089 (
1090 "SENTRY_DSN".to_string(),
1091 "https://abc123@o1.ingest.sentry.io/42".to_string(),
1092 ),
1093 ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1094 (
1095 "API_BASE_URL".to_string(),
1096 "https://api.example.com/v1".to_string(),
1097 ),
1098 ];
1099 let (env, stripped) = strip_client_env(vars);
1100 let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1101 assert_eq!(
1102 kept,
1103 vec!["API_BASE_URL", "HOME"],
1104 "only non-secrets survive"
1105 );
1106 assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1107 && !v.contains("user:pass")
1108 && !v.contains("abc123")));
1109 assert_eq!(
1110 stripped,
1111 vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1112 "stripped names are recorded"
1113 );
1114 }
1115
1116 #[test]
1117 fn capture_client_env_is_sorted_and_stripped() {
1118 let (env, stripped) = capture_client_env();
1121 for name in &stripped {
1124 let by_value = std::env::var(name)
1125 .map(|v| agent_core::core::config::is_secret_env_value(&v))
1126 .unwrap_or(false);
1127 assert!(
1128 is_secret_key(name) || by_value,
1129 "stripped non-secret: {name}"
1130 );
1131 assert!(
1132 env.iter().all(|(k, _)| k != name),
1133 "stripped name leaked: {name}"
1134 );
1135 }
1136 for w in env.windows(2) {
1138 assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1139 }
1140 for (k, v) in &env {
1142 assert!(!should_strip_env(k), "should have been stripped: {k}");
1143 assert!(
1144 !agent_core::core::config::is_secret_env_value(v),
1145 "credential value kept: {k}"
1146 );
1147 }
1148 }
1149
1150 #[test]
1151 fn command_json_round_trip() {
1152 let cmd = SessionCommand::Set {
1153 id: 7,
1154 setting: SessionSetting::ReasoningLevel {
1155 level: agent_core::reasoning::ReasoningLevel::Ultra,
1156 },
1157 };
1158 let json = serde_json::to_string(&cmd).unwrap();
1159 assert_eq!(
1160 json,
1161 r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1162 );
1163 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1164 assert!(matches!(
1165 back,
1166 SessionCommand::Set {
1167 id: 7,
1168 setting: SessionSetting::ReasoningLevel {
1169 level: agent_core::reasoning::ReasoningLevel::Ultra
1170 }
1171 }
1172 ));
1173
1174 let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1175 match submit {
1176 SessionCommand::Submit { text, attachments } => {
1177 assert_eq!(text, "hi");
1178 assert!(attachments.is_empty());
1179 }
1180 other => panic!("unexpected {other:?}"),
1181 }
1182
1183 let blocks = vec![
1185 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
1186 serde_json::json!({"type":"text","text":"hello"}),
1187 ];
1188 let submit_with = SessionCommand::Submit {
1189 text: "describe".into(),
1190 attachments: blocks.clone(),
1191 };
1192 let json = serde_json::to_string(&submit_with).unwrap();
1193 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1194 match back {
1195 SessionCommand::Submit { text, attachments } => {
1196 assert_eq!(text, "describe");
1197 assert_eq!(attachments.len(), 2);
1198 assert_eq!(attachments[0]["type"], "image");
1199 assert_eq!(attachments[1]["type"], "text");
1200 }
1201 other => panic!("unexpected {other:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn new_commands_round_trip() {
1207 let cmds = vec![
1208 SessionCommand::SubmitPrepared {
1209 messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
1210 user_text: Some("t".into()),
1211 },
1212 SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
1213 SessionCommand::Resume { id: 2, query: "q".into() },
1214 SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
1215 SessionCommand::KeepWarm { on: true },
1216 SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
1217 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
1218 ];
1219 for cmd in cmds {
1220 let json = serde_json::to_string(&cmd).unwrap();
1221 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1222 assert_eq!(serde_json::to_string(&back).unwrap(), json);
1223 }
1224 let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
1225 assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
1226 }
1227
1228 #[test]
1229 fn command_debug_redacts_bodies_without_lengths() {
1230 let secret = "hunter2-very-secret";
1231 let cmds = vec![
1232 SessionCommand::Submit { text: secret.into(), attachments: vec![] },
1233 SessionCommand::Steer { text: secret.into() },
1234 SessionCommand::SubmitPrepared {
1235 messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
1236 user_text: Some(secret.into()),
1237 },
1238 SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1239 SessionCommand::Resume { id: 1, query: secret.into() },
1240 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
1241 ];
1242 for cmd in cmds {
1243 let d = format!("{cmd:?}");
1244 assert!(!d.contains(secret), "{d}");
1245 assert!(d.contains("<redacted>"), "{d}");
1246 assert!(!d.contains("chars"), "{d}");
1247 assert!(!d.contains(&secret.len().to_string()), "{d}");
1248 }
1249 let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
1250 assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
1251
1252 let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
1254 let cmd = SessionCommand::Submit {
1255 text: "x".into(),
1256 attachments: vec![
1257 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
1258 serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
1259 ],
1260 };
1261 let d = format!("{cmd:?}");
1262 assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
1263 assert!(!d.contains(b64_secret), "base64 leaked: {d}");
1264 }
1265
1266 #[test]
1267 fn session_config_new_fields_default() {
1268 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1269 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
1270 assert!(cfg.await_extensions);
1271 assert!(!cfg.keep_warm);
1272 let cfg: SessionConfig =
1273 serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
1274 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
1275 assert!(!cfg.await_extensions);
1276 }
1277
1278 #[test]
1279 fn session_lifecycle_u8_round_trip() {
1280 for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
1281 assert_eq!(SessionLifecycle::from_u8(l as u8), l);
1282 }
1283 assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
1284 }
1285
1286 #[test]
1287 fn host_event_is_never_serialised() {
1288 let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
1289 crate::extensions::loader::ExtensionLoaderEvent::Started,
1290 ));
1291 assert!(serde_json::to_string(&cmd).is_err());
1292 }
1293}
1294
1295mod double_option {
1299 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
1300
1301 #[derive(Serialize, Deserialize)]
1302 #[serde(untagged)]
1303 enum Wire {
1304 Latest { latest: bool },
1305 Id(String),
1306 }
1307
1308 pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
1309 match v {
1310 None => s.serialize_none(),
1311 Some(None) => Wire::Latest { latest: true }.serialize(s),
1312 Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
1313 }
1314 }
1315
1316 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
1317 match Option::<Wire>::deserialize(d)? {
1318 None => Ok(None),
1319 Some(Wire::Latest { latest: true }) => Ok(Some(None)),
1320 Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
1321 Some(Wire::Id(id)) => Ok(Some(Some(id))),
1322 }
1323 }
1324
1325 #[cfg(test)]
1326 mod tests {
1327 use super::super::SessionConfig;
1328
1329 fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
1330 let cfg = SessionConfig { continue_session: v, ..Default::default() };
1331 let j = serde_json::to_string(&cfg).unwrap();
1332 serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
1333 }
1334
1335 #[test]
1336 fn continue_session_round_trips_all_three_states() {
1337 assert_eq!(rt(None), None);
1338 assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
1339 assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
1340 }
1341
1342 #[test]
1343 fn continue_session_accepts_legacy_encodings() {
1344 let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
1345 assert_eq!(old_null.continue_session, None);
1346 let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
1347 assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
1348 let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
1349 assert_eq!(absent.continue_session, None);
1350 }
1351 }
1352}