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