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,
497 Answer { prompt_id: u64, value: Option<String> },
500 Set { id: u64, setting: SessionSetting },
502 Compact { instructions: Option<String> },
503 NewSession,
505 Save,
506 Query { id: u64, query: SessionQuery },
508 Attach { client: ClientMeta, mode: AttachMode },
510 Detach { client: ClientId },
511 End { reason: EndReason },
513 Resync { client: ClientId, since_seq: u64 },
515 EngineCommand { id: u64, name: String, arg: String },
519 SubmitPrepared {
523 messages: Vec<crate::SharedMessage>,
524 #[serde(default)]
525 user_text: Option<String>,
526 },
527 PluginCommand { id: u64, plugin: String, name: String, arg: String },
530 Resume { id: u64, query: String },
533 Checkpoint { reason: CheckpointReason },
537 KeepWarm { on: bool },
539 #[serde(skip)]
542 Park,
543 #[serde(skip)]
545 HostEvent(HostEvent),
546 DriverStart {
551 plugin: String,
552 command: String,
553 arg: String,
554 },
555}
556
557#[derive(Clone, Debug, Serialize, Deserialize)]
562pub struct SessionReloadRecord {
563 pub config: SessionConfig,
564 pub keep_warm: bool,
565 pub lifecycle: SessionLifecycle,
566 #[serde(default)]
567 pub settings_replay: Vec<SessionSetting>,
568 #[serde(default)]
570 pub cache_keepwarm: Option<String>,
571 pub model: String,
572 pub thinking_level: String,
573}
574
575impl std::fmt::Debug for SessionCommand {
576 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 match self {
578 Self::Submit { attachments, .. } => {
579 let kinds: Vec<&str> = attachments
580 .iter()
581 .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
582 .collect();
583 f.debug_struct("Submit")
584 .field("text", &format_args!("<redacted>"))
585 .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
586 .finish()
587 }
588 Self::Steer { .. } => f
589 .debug_struct("Steer")
590 .field("text", &format_args!("<redacted>"))
591 .finish(),
592 Self::SubmitPrepared { .. } => f
593 .debug_struct("SubmitPrepared")
594 .field("messages", &format_args!("<redacted>"))
595 .field("user_text", &format_args!("<redacted>"))
596 .finish(),
597 Self::Answer { prompt_id, .. } => f
598 .debug_struct("Answer")
599 .field("prompt_id", prompt_id)
600 .field("value", &format_args!("<redacted>"))
601 .finish(),
602 Self::Resume { id, .. } => f
603 .debug_struct("Resume")
604 .field("id", id)
605 .field("query", &format_args!("<redacted>"))
606 .finish(),
607 Self::Cancel => f.write_str("Cancel"),
608 Self::Set { id, setting } => {
609 f.debug_struct("Set").field("id", id).field("setting", setting).finish()
610 }
611 Self::Compact { instructions } => {
612 f.debug_struct("Compact").field("instructions", instructions).finish()
613 }
614 Self::NewSession => f.write_str("NewSession"),
615 Self::Save => f.write_str("Save"),
616 Self::Park => f.write_str("Park"),
617 Self::Query { id, query } => {
618 f.debug_struct("Query").field("id", id).field("query", query).finish()
619 }
620 Self::Attach { client, mode } => {
621 f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
622 }
623 Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
624 Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
625 Self::Resync { client, since_seq } => f
626 .debug_struct("Resync")
627 .field("client", client)
628 .field("since_seq", since_seq)
629 .finish(),
630 Self::EngineCommand { id, name, arg } => f
631 .debug_struct("EngineCommand")
632 .field("id", id)
633 .field("name", name)
634 .field("arg", arg)
635 .finish(),
636 Self::PluginCommand { id, plugin, name, arg } => f
637 .debug_struct("PluginCommand")
638 .field("id", id)
639 .field("plugin", plugin)
640 .field("name", name)
641 .field("arg", arg)
642 .finish(),
643 Self::Checkpoint { reason } => {
644 f.debug_struct("Checkpoint").field("reason", reason).finish()
645 }
646 Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
647 Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
648 Self::DriverStart { plugin, command, .. } => f
649 .debug_struct("DriverStart")
650 .field("plugin", plugin)
651 .field("command", command)
652 .field("arg", &format_args!("<redacted>"))
653 .finish(),
654 }
655 }
656}
657
658#[derive(Debug, Clone)]
660pub enum HostEvent {
661 ExtensionNotification {
662 extension_id: String,
663 method: String,
664 params: serde_json::Value,
665 },
666 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(tag = "setting", rename_all = "snake_case")]
672pub enum SessionSetting {
673 Model { model: String },
675 ReasoningLevel {
677 #[serde(with = "reasoning_level_serde")]
678 level: agent_core::reasoning::ReasoningLevel,
679 },
680 ContextWindow { tokens: Option<u64> },
682 CompactionModel { model: Option<String> },
683 ApiRetries { n: u32 },
684 SubagentTimeout { secs: u64 },
685 MaxToolOutput { bytes: usize },
686 BashTimeout { secs: u64 },
687 BashMaxTimeout { secs: u64 },
688 SystemPrompt { text: String },
690 ReloadPrompt,
692 GrantWorkerModel { model: String },
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct SettingApplied {
698 #[serde(default)]
700 pub id: u64,
701 pub setting: String,
702 pub ok: bool,
703 pub message: Option<String>,
704 pub view: RuntimeView,
705 #[serde(default)]
708 pub clamp: Option<ReasoningClampWire>,
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct ReasoningClampWire {
713 pub from: String,
714 pub to: String,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
718#[serde(tag = "query", rename_all = "snake_case")]
719pub enum SessionQuery {
720 Status,
722 Messages,
724 SubagentRows,
726 PromptInspection,
728 ToolsSchema,
730 View,
732 ContextAssessment,
734 ContextReport,
736 DisplayTail { items: usize },
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum EndReason {
743 ClientQuit,
744 HostShutdown,
745 Evicted,
746 Error,
747 Idle,
751}
752
753#[derive(Debug, Clone)]
759#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
761 Stream(crate::StreamEvent),
762 TurnStarted {
769 turn_baseline: usize,
770 trigger: TurnTrigger,
771 user_text: Option<String>,
772 },
773 Conversation(ConversationSnapshot),
776 Prompt(PromptRequest),
777 PromptResolved { prompt_id: u64 },
779 External(crate::events::types::Event),
781 AutoTurnCapReached { cap: u32 },
782 Idle,
786 Steered { text: String, delivered: bool },
788 Dequeued { text: String },
789 SystemNotice(String),
791 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
792 ExtensionNotification {
793 extension_id: String,
794 method: String,
795 params: serde_json::Value,
796 },
797 SettingChanged(SettingApplied),
798 QueryResult { id: u64, value: serde_json::Value },
799 Attached {
801 client: ClientId,
802 snapshot: AttachSnapshot,
803 },
804 ClientJoined { client: ClientId, kind: ClientKind },
805 ClientLeft { client: ClientId },
806 Ended { reason: EndReason },
807 Aborted { context_saved: bool },
811 Cleared { session_id: String },
813 CompactionStarted { source: String, disclosure: String },
815 CompactionApplied {
816 previous_session_id: String,
817 session_id: String,
818 chains_advanced: Vec<String>,
819 queued_restored: Option<String>,
820 msg_count: usize,
821 },
822 CompactionFailed { message: String, panicked: bool },
824 CompactionCancelled,
825 SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
828 Resumed {
830 id: u64,
831 old_id: String,
832 new_id: String,
833 via: Option<String>,
834 clamp_notice: Option<String>,
835 },
836 InputOwnerChanged {
839 from: Option<ClientId>,
840 to: Option<ClientId>,
841 reason: OwnerChangeReason,
842 },
843 Refused { client: ClientId, command: String, reason: String },
845 AttachRefused { message: String },
847 Lifecycle(SessionLifecycle),
849 Reloading { generation: u64, retry_after_ms: u64 },
851 DriverArmed {
853 plugin_id: String,
854 run_id: String,
855 models: Vec<crate::extensions::session_driver::Selection>,
856 selection: crate::extensions::session_driver::Selection,
857 deadline_ms: Option<u64>,
859 notice: String,
860 },
861 DriverRevoked {
863 reason: String,
864 undelivered_steering: Vec<String>,
865 },
866 DriverTurnOutcome {
868 outcome: crate::extensions::session_driver::Outcome,
869 selection: crate::extensions::session_driver::Selection,
870 feedback: Option<String>,
871 },
872 CostCapReached {
877 scope: String,
878 cost: f64,
879 cap: f64,
880 },
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885pub enum OwnerChangeReason {
886 Attach,
887 Takeover,
888 OwnerDetached,
889 Reload,
890}
891
892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub enum TurnTrigger {
895 User,
896 QueuedAuto,
897 EventAuto,
898 PluginCommand,
899 Compaction,
900 DriverAuto,
902}
903
904#[derive(Debug, Clone)]
907pub struct Envelope {
908 pub session_id: SessionId,
909 pub seq: u64,
910 pub ts: chrono::DateTime<chrono::Utc>,
911 pub event: SessionEventWire,
912}
913
914#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
915pub struct ConversationTokens {
916 pub input: u64,
917 pub output: u64,
918 pub cache_read: u64,
919 pub cache_creation: u64,
920}
921
922#[derive(Clone, Default)]
929pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
930
931type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
933
934impl MessagesHashMemo {
935 pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
936 let mut memo = self
937 .0
938 .lock()
939 .unwrap_or_else(std::sync::PoisonError::into_inner);
940 if let Some((len, last, hash)) = memo.as_ref() {
941 let same_last = match (last, messages.last()) {
942 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
943 (None, None) => true,
944 _ => false,
945 };
946 if *len == messages.len() && same_last {
947 return *hash;
948 }
949 }
950 let hash = super::wire::messages_hash(messages);
951 *memo = Some((messages.len(), messages.last().cloned(), hash));
952 hash
953 }
954}
955
956impl std::fmt::Debug for MessagesHashMemo {
957 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958 f.write_str("MessagesHashMemo")
959 }
960}
961
962#[derive(Debug, Clone, Default, Serialize, Deserialize)]
965pub struct ConversationSnapshot {
966 #[serde(default)]
968 pub header: SessionHeader,
969 pub api_messages: Vec<crate::SharedMessage>,
970 #[serde(default)]
973 pub messages_len: usize,
974 pub tokens: ConversationTokens,
975 pub cost: f64,
976 #[serde(default)]
978 pub keepwarm_cost: f64,
979 #[serde(default)]
981 pub keepwarm_tokens: ConversationTokens,
982 pub abort_context: Option<String>,
985 pub queued_message: Option<String>,
986 pub pending_events_len: usize,
987 pub consecutive_auto_turns: u32,
988 #[serde(skip)]
990 pub messages_hash_memo: MessagesHashMemo,
991}
992
993#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
995pub struct SessionHeader {
996 pub id: String,
997 pub title: String,
998 pub name: Option<String>,
999 pub model: String,
1000 pub thinking_level: String,
1001 pub system_prompt: Option<String>,
1002 pub created_at: chrono::DateTime<chrono::Utc>,
1003 pub updated_at: chrono::DateTime<chrono::Utc>,
1004 pub parent_session: Option<String>,
1005}
1006
1007impl From<&crate::Session> for SessionHeader {
1008 fn from(s: &crate::Session) -> Self {
1009 Self {
1010 id: s.id.clone(),
1011 title: s.title.clone(),
1012 name: s.name.clone(),
1013 model: s.model.clone(),
1014 thinking_level: s.thinking_level.clone(),
1015 system_prompt: s.system_prompt.clone(),
1016 created_at: s.created_at,
1017 updated_at: s.updated_at,
1018 parent_session: s.parent_session.clone(),
1019 }
1020 }
1021}
1022
1023#[derive(Debug, Clone)]
1024pub struct AttachSnapshot {
1025 pub meta: SessionMeta,
1026 pub view: RuntimeView,
1027 pub conversation: ConversationSnapshot,
1028 pub streaming: bool,
1029 pub replay: Vec<Envelope>,
1032 pub pending_prompts: Vec<PromptRequest>,
1033 pub clients: Vec<(ClientId, ClientKind)>,
1034 pub input_owner: Option<ClientId>,
1037 pub display_tail: Option<crate::session::display::DisplayTail>,
1040}
1041
1042impl AttachSnapshot {
1043 pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1050 let owner = self.input_owner.filter(|o| *o != me)?;
1051 let kind = self
1052 .clients
1053 .iter()
1054 .find(|(c, _)| *c == owner)
1055 .map(|(_, k)| format!("{k:?}").to_lowercase())
1056 .unwrap_or_else(|| "?".into());
1057 Some(format!(
1058 "input is owned by client #{} ({kind}); attach with --takeover to steal it",
1059 owner.0
1060 ))
1061 }
1062}
1063
1064pub mod reasoning_level_serde {
1067 use agent_core::reasoning::ReasoningLevel;
1068 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1069
1070 pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1071 level.as_str().serialize(s)
1072 }
1073
1074 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1075 let s = String::deserialize(d)?;
1076 ReasoningLevel::parse(&s)
1077 .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 #[test]
1086 fn prompt_kind_from_title() {
1087 assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1088 assert_eq!(
1089 PromptKind::from_title("Confirm tool activation"),
1090 PromptKind::Confirm
1091 );
1092 assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1093 }
1094
1095 #[test]
1096 fn session_config_default_persists() {
1097 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1098 assert!(cfg.persist);
1099 assert!(cfg.cwd.is_none());
1100 assert_eq!(
1101 serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1102 serde_json::Value::Bool(true)
1103 );
1104 }
1105
1106 #[test]
1107 fn session_config_env_defaults_to_none() {
1108 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1109 assert!(cfg.env.is_none());
1110 }
1111
1112 #[test]
1113 fn session_config_env_round_trips() {
1114 let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1115 let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1116 let json = serde_json::to_string(&cfg).unwrap();
1117 let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1118 assert_eq!(cfg2.env, Some(env));
1119 }
1120
1121 #[test]
1122 fn should_strip_env_client_prefixes() {
1123 assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1124 assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1125 assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1126 assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1127 assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1128 assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1130 assert!(!should_strip_env("HOME"));
1131 assert!(!should_strip_env("PATH"));
1132 assert!(!should_strip_env("VIRTUAL_ENV"));
1133 }
1134
1135 #[test]
1136 fn should_strip_env_secrets() {
1137 assert!(should_strip_env("ANTHROPIC_API_KEY"));
1138 assert!(should_strip_env("OPENAI_API_KEY"));
1139 assert!(should_strip_env("my_secret_token"));
1140 assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1141 assert!(should_strip_env("DB_PASSWORD"));
1142 assert!(should_strip_env("GCP_CREDENTIALS"));
1143 assert!(should_strip_env("My_Api_Key"));
1145 assert!(!should_strip_env("MY_VALUE"));
1147 assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1148 assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1150 assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1151 assert!(!should_strip_env("SSH_KEY_PATH"));
1152 assert!(should_strip_env("GH_TOKEN"));
1153 assert!(should_strip_env("SSH_KEY"));
1154 assert!(should_strip_env("SECRET_KEY_BASE"));
1155 assert!(should_strip_env("MY_PASSWD"));
1156 }
1157
1158 #[test]
1159 fn strip_client_env_drops_credential_values_whatever_the_key() {
1160 let vars = vec![
1161 ("HOME".to_string(), "/home/u".to_string()),
1162 (
1163 "DATABASE_URL".to_string(),
1164 "postgres://app:hunter2@db/prod".to_string(),
1165 ),
1166 (
1167 "HTTPS_PROXY".to_string(),
1168 "http://user:pass@proxy:3128".to_string(),
1169 ),
1170 (
1171 "SENTRY_DSN".to_string(),
1172 "https://abc123@o1.ingest.sentry.io/42".to_string(),
1173 ),
1174 ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1175 (
1176 "API_BASE_URL".to_string(),
1177 "https://api.example.com/v1".to_string(),
1178 ),
1179 ];
1180 let (env, stripped) = strip_client_env(vars);
1181 let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1182 assert_eq!(
1183 kept,
1184 vec!["API_BASE_URL", "HOME"],
1185 "only non-secrets survive"
1186 );
1187 assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1188 && !v.contains("user:pass")
1189 && !v.contains("abc123")));
1190 assert_eq!(
1191 stripped,
1192 vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1193 "stripped names are recorded"
1194 );
1195 }
1196
1197 #[test]
1198 fn capture_client_env_is_sorted_and_stripped() {
1199 let (env, stripped) = capture_client_env();
1202 for name in &stripped {
1205 let by_value = std::env::var(name)
1206 .map(|v| agent_core::core::config::is_secret_env_value(&v))
1207 .unwrap_or(false);
1208 assert!(
1209 is_secret_key(name) || by_value,
1210 "stripped non-secret: {name}"
1211 );
1212 assert!(
1213 env.iter().all(|(k, _)| k != name),
1214 "stripped name leaked: {name}"
1215 );
1216 }
1217 for w in env.windows(2) {
1219 assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1220 }
1221 for (k, v) in &env {
1223 assert!(!should_strip_env(k), "should have been stripped: {k}");
1224 assert!(
1225 !agent_core::core::config::is_secret_env_value(v),
1226 "credential value kept: {k}"
1227 );
1228 }
1229 }
1230
1231 #[test]
1232 fn command_json_round_trip() {
1233 let cmd = SessionCommand::Set {
1234 id: 7,
1235 setting: SessionSetting::ReasoningLevel {
1236 level: agent_core::reasoning::ReasoningLevel::Ultra,
1237 },
1238 };
1239 let json = serde_json::to_string(&cmd).unwrap();
1240 assert_eq!(
1241 json,
1242 r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1243 );
1244 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1245 assert!(matches!(
1246 back,
1247 SessionCommand::Set {
1248 id: 7,
1249 setting: SessionSetting::ReasoningLevel {
1250 level: agent_core::reasoning::ReasoningLevel::Ultra
1251 }
1252 }
1253 ));
1254
1255 let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1256 match submit {
1257 SessionCommand::Submit { text, attachments } => {
1258 assert_eq!(text, "hi");
1259 assert!(attachments.is_empty());
1260 }
1261 other => panic!("unexpected {other:?}"),
1262 }
1263
1264 let blocks = vec![
1266 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
1267 serde_json::json!({"type":"text","text":"hello"}),
1268 ];
1269 let submit_with = SessionCommand::Submit {
1270 text: "describe".into(),
1271 attachments: blocks.clone(),
1272 };
1273 let json = serde_json::to_string(&submit_with).unwrap();
1274 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1275 match back {
1276 SessionCommand::Submit { text, attachments } => {
1277 assert_eq!(text, "describe");
1278 assert_eq!(attachments.len(), 2);
1279 assert_eq!(attachments[0]["type"], "image");
1280 assert_eq!(attachments[1]["type"], "text");
1281 }
1282 other => panic!("unexpected {other:?}"),
1283 }
1284 }
1285
1286 #[test]
1287 fn new_commands_round_trip() {
1288 let cmds = vec![
1289 SessionCommand::SubmitPrepared {
1290 messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
1291 user_text: Some("t".into()),
1292 },
1293 SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
1294 SessionCommand::Resume { id: 2, query: "q".into() },
1295 SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
1296 SessionCommand::KeepWarm { on: true },
1297 SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
1298 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
1299 ];
1300 for cmd in cmds {
1301 let json = serde_json::to_string(&cmd).unwrap();
1302 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1303 assert_eq!(serde_json::to_string(&back).unwrap(), json);
1304 }
1305 let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
1306 assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
1307 }
1308
1309 #[test]
1310 fn command_debug_redacts_bodies_without_lengths() {
1311 let secret = "hunter2-very-secret";
1312 let cmds = vec![
1313 SessionCommand::Submit { text: secret.into(), attachments: vec![] },
1314 SessionCommand::Steer { text: secret.into() },
1315 SessionCommand::SubmitPrepared {
1316 messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
1317 user_text: Some(secret.into()),
1318 },
1319 SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1320 SessionCommand::Resume { id: 1, query: secret.into() },
1321 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
1322 ];
1323 for cmd in cmds {
1324 let d = format!("{cmd:?}");
1325 assert!(!d.contains(secret), "{d}");
1326 assert!(d.contains("<redacted>"), "{d}");
1327 assert!(!d.contains("chars"), "{d}");
1328 assert!(!d.contains(&secret.len().to_string()), "{d}");
1329 }
1330 let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
1331 assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
1332
1333 let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
1335 let cmd = SessionCommand::Submit {
1336 text: "x".into(),
1337 attachments: vec![
1338 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
1339 serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
1340 ],
1341 };
1342 let d = format!("{cmd:?}");
1343 assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
1344 assert!(!d.contains(b64_secret), "base64 leaked: {d}");
1345 }
1346
1347 #[test]
1348 fn session_config_new_fields_default() {
1349 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1350 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
1351 assert!(cfg.await_extensions);
1352 assert!(!cfg.keep_warm);
1353 let cfg: SessionConfig =
1354 serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
1355 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
1356 assert!(!cfg.await_extensions);
1357 }
1358
1359 #[test]
1360 fn session_lifecycle_u8_round_trip() {
1361 for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
1362 assert_eq!(SessionLifecycle::from_u8(l as u8), l);
1363 }
1364 assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
1365 }
1366
1367 #[test]
1368 fn host_event_is_never_serialised() {
1369 let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
1370 crate::extensions::loader::ExtensionLoaderEvent::Started,
1371 ));
1372 assert!(serde_json::to_string(&cmd).is_err());
1373 }
1374}
1375
1376mod double_option {
1380 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
1381
1382 #[derive(Serialize, Deserialize)]
1383 #[serde(untagged)]
1384 enum Wire {
1385 Latest { latest: bool },
1386 Id(String),
1387 }
1388
1389 pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
1390 match v {
1391 None => s.serialize_none(),
1392 Some(None) => Wire::Latest { latest: true }.serialize(s),
1393 Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
1394 }
1395 }
1396
1397 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
1398 match Option::<Wire>::deserialize(d)? {
1399 None => Ok(None),
1400 Some(Wire::Latest { latest: true }) => Ok(Some(None)),
1401 Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
1402 Some(Wire::Id(id)) => Ok(Some(Some(id))),
1403 }
1404 }
1405
1406 #[cfg(test)]
1407 mod tests {
1408 use super::super::SessionConfig;
1409
1410 fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
1411 let cfg = SessionConfig { continue_session: v, ..Default::default() };
1412 let j = serde_json::to_string(&cfg).unwrap();
1413 serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
1414 }
1415
1416 #[test]
1417 fn continue_session_round_trips_all_three_states() {
1418 assert_eq!(rt(None), None);
1419 assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
1420 assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
1421 }
1422
1423 #[test]
1424 fn continue_session_accepts_legacy_encodings() {
1425 let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
1426 assert_eq!(old_null.continue_session, None);
1427 let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
1428 assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
1429 let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
1430 assert_eq!(absent.continue_session, None);
1431 }
1432 }
1433}