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
48#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct SecretEnv(pub SessionEnv);
56
57impl std::fmt::Debug for SecretEnv {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 let names: Vec<&str> = self.0.iter().map(|(k, _)| k.as_str()).collect();
60 write!(f, "SecretEnv({names:?})")
61 }
62}
63
64#[derive(Clone, Default, PartialEq, Eq)]
70pub struct ToolEnv {
71 pub vars: SessionEnv,
72 secrets: Option<std::sync::Arc<SecretEnv>>,
73}
74
75impl ToolEnv {
76 pub fn with_secrets(vars: SessionEnv, secrets: SecretEnv) -> Self {
77 Self { vars, secrets: (!secrets.0.is_empty()).then(|| std::sync::Arc::new(secrets)) }
78 }
79
80 fn secret_vars(&self) -> &[(String, String)] {
81 self.secrets.as_deref().map(|s| s.0.as_slice()).unwrap_or(&[])
82 }
83
84 pub fn get(&self, key: &str) -> Option<&str> {
86 self.secret_vars()
87 .iter()
88 .chain(self.vars.iter())
89 .find(|(k, _)| k == key)
90 .map(|(_, v)| v.as_str())
91 }
92
93 pub fn spawn_vars(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
96 let secrets = self.secret_vars();
97 self.vars
98 .iter()
99 .filter(move |(k, _)| !secrets.iter().any(|(s, _)| s == k))
100 .chain(secrets.iter())
101 .map(|(k, v)| (k.as_str(), v.as_str()))
102 }
103}
104
105impl From<SessionEnv> for ToolEnv {
106 fn from(vars: SessionEnv) -> Self {
107 Self { vars, secrets: None }
108 }
109}
110
111impl std::fmt::Debug for ToolEnv {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("ToolEnv").field("vars", &self.vars).field("secrets", &self.secrets).finish()
114 }
115}
116
117#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(transparent)]
122pub struct OwnerToken(pub String);
123
124impl OwnerToken {
125 pub fn mint() -> Self {
126 Self(uuid::Uuid::new_v4().simple().to_string())
127 }
128}
129
130impl std::fmt::Debug for OwnerToken {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.write_str("OwnerToken(<redacted>)")
133 }
134}
135
136impl OwnerToken {
137 pub fn matches(&self, other: &OwnerToken) -> bool {
140 let (a, b) = (self.0.as_bytes(), other.0.as_bytes());
141 a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
142 }
143}
144
145#[derive(Clone, Debug)]
148pub struct OwnedConfig {
149 pub token: OwnerToken,
150 pub restored: bool,
156}
157
158pub struct OwnerBind {
164 pub claim: OwnerClaim,
165 pub alive: tokio_util::sync::CancellationToken,
166}
167
168#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct OwnerClaim {
172 pub token: OwnerToken,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub forward_secrets: Option<SecretEnv>,
175}
176
177pub fn capture_client_secrets() -> SecretEnv {
180 client_secret_env(std::env::vars())
181}
182
183pub fn client_secret_env(vars: impl IntoIterator<Item = (String, String)>) -> SecretEnv {
187 let mut out: SessionEnv = vars
188 .into_iter()
189 .filter(|(k, v)| {
190 let upper = k.to_ascii_uppercase();
191 let client_only = ENV_STRIP_PREFIXES.iter().any(|p| upper.starts_with(p));
192 !client_only && (is_secret_key(k) || agent_core::core::config::is_secret_env_value(v))
193 })
194 .collect();
195 out.sort_by(|(a, _), (b, _)| a.cmp(b));
196 SecretEnv(out)
197}
198
199const ENV_STRIP_PREFIXES: &[&str] = &[
202 "SYNAPS_CLIENT_",
203 "SYNAPS_TUI_",
204 "SYNAPS_DAEMON_",
205 "SYNAPS_MEM_TRACE",
206];
207
208pub fn capture_client_env() -> (SessionEnv, Vec<String>) {
215 strip_client_env(std::env::vars())
216}
217
218pub fn strip_client_env(
221 vars: impl IntoIterator<Item = (String, String)>,
222) -> (SessionEnv, Vec<String>) {
223 let mut env: SessionEnv = Vec::new();
224 let mut stripped: Vec<String> = Vec::new();
225 for (k, v) in vars {
226 let secret_value = agent_core::core::config::is_secret_env_value(&v);
230 if should_strip_env(&k) || secret_value {
231 if is_secret_key(&k) || secret_value {
233 stripped.push(k);
234 }
235 } else {
236 env.push((k, v));
237 }
238 }
239 env.sort_by(|(a, _), (b, _)| a.cmp(b));
240 stripped.sort();
241 (env, stripped)
242}
243
244pub fn should_strip_env(key: &str) -> bool {
246 let upper = key.to_ascii_uppercase();
247 for prefix in ENV_STRIP_PREFIXES {
248 if upper.starts_with(prefix) {
249 return true;
250 }
251 }
252 is_secret_key(&upper)
253}
254
255pub fn is_secret_key(key: &str) -> bool {
258 agent_core::core::config::is_secret_key(key)
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
266pub struct SessionConfig {
267 #[serde(default, with = "double_option", skip_serializing_if = "Option::is_none")]
272 pub continue_session: Option<Option<String>>,
273 #[serde(default)]
275 pub system: Option<String>,
276 #[serde(default)]
278 pub prompt_manifest: Option<PathBuf>,
279 #[serde(default)]
283 pub cwd: Option<PathBuf>,
284 #[serde(default)]
289 pub env: Option<SessionEnv>,
290 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub env_stripped: Vec<String>,
297 #[serde(default)]
300 pub auto_approve_confirms: bool,
301 #[serde(default)]
303 pub model_override: Option<String>,
304 #[serde(default = "default_true")]
307 pub persist: bool,
308 #[serde(default)]
312 pub auto_compact: bool,
313 #[serde(default)]
316 pub compaction_policy: CompactionPolicyWire,
317 #[serde(default = "default_true")]
321 pub await_extensions: bool,
322 #[serde(default)]
324 pub keep_warm: bool,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub client_input: Option<ClientInput>,
329 #[serde(default)]
333 pub name: Option<String>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub max_session_cost: Option<f64>,
340 #[serde(skip)]
344 pub restore_parked: bool,
345 #[serde(skip)]
348 pub message_clock: Option<crate::runtime::message_time::MessageClock>,
349 #[serde(skip)]
353 pub owned: Option<OwnedConfig>,
354}
355
356fn default_true() -> bool {
357 true
358}
359
360#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "snake_case")]
363pub enum CompactionPolicyWire {
364 #[default]
365 InPlace,
366 LinkedSuccessor,
367}
368
369impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
370 fn from(p: CompactionPolicyWire) -> Self {
371 match p {
372 CompactionPolicyWire::InPlace => Self::InPlace,
373 CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
374 }
375 }
376}
377
378impl Default for SessionConfig {
379 fn default() -> Self {
380 Self {
381 continue_session: None,
382 system: None,
383 prompt_manifest: None,
384 cwd: None,
385 env: None,
386 env_stripped: Vec::new(),
387 auto_approve_confirms: false,
388 model_override: None,
389 persist: true,
390 auto_compact: false,
391 compaction_policy: CompactionPolicyWire::InPlace,
392 await_extensions: true,
393 keep_warm: false,
394 client_input: None,
395 name: None,
396 max_session_cost: None,
397 restore_parked: false,
398 message_clock: None,
399 owned: None,
400 }
401 }
402}
403
404#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406pub struct ContinueInfoWire {
407 pub session_id: String,
408 pub resolved_via: Option<String>,
410 pub query: String,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub compaction_notice: Option<String>,
414}
415
416impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
417 fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
418 Self {
419 session_id: c.session_id.clone(),
420 resolved_via: c.resolved_via.clone(),
421 query: c.query.clone(),
422 compaction_notice: c.compaction_notice.clone(),
423 }
424 }
425}
426
427#[derive(Clone, Debug, Serialize, Deserialize)]
428pub struct SessionMeta {
429 pub id: SessionId,
430 pub name: Option<String>,
431 pub model: String,
432 pub cwd: Option<PathBuf>,
433 pub created_at: chrono::DateTime<chrono::Utc>,
434 pub continued: bool,
435 pub continue_info: Option<ContinueInfoWire>,
436 pub host_pid: u32,
438 #[serde(default)]
440 pub lifecycle: SessionLifecycle,
441 #[serde(default)]
442 pub clients: usize,
443 #[serde(default)]
444 pub input_owner: Option<ClientId>,
445 #[serde(default)]
446 pub awaiting_input: usize,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub roster: Vec<ClientInfo>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub client_input: Option<ClientInput>,
455 #[serde(default)]
457 pub journal_id: String,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub locked_by: Option<String>,
462 #[serde(default)]
466 pub pinned: bool,
467 #[serde(default)]
471 pub busy: bool,
472 #[serde(default)]
475 pub state_since: Option<chrono::DateTime<chrono::Utc>>,
476 #[serde(default)]
479 pub park_due: Option<chrono::DateTime<chrono::Utc>>,
480 #[serde(default)]
482 pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
483 #[serde(default)]
485 pub title: Option<String>,
486 #[serde(default)]
488 pub cost_usd: f64,
489 #[serde(default)]
491 pub messages: usize,
492}
493
494#[repr(u8)]
496#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498pub enum SessionLifecycle {
499 #[default]
500 Live = 0,
501 Parking = 1,
502 Parked = 2,
503 Ending = 3,
504}
505
506impl SessionLifecycle {
507 pub fn from_u8(v: u8) -> Self {
508 match v {
509 1 => Self::Parking,
510 2 => Self::Parked,
511 3 => Self::Ending,
512 _ => Self::Live,
513 }
514 }
515}
516
517pub use crate::core::config::ClientInput;
518
519#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
521#[serde(transparent)]
522pub struct ClientId(pub u64);
523
524#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct ClientMeta {
526 pub kind: ClientKind,
527 pub terminal: Option<String>,
529 pub instance: String,
531 #[serde(default)]
533 pub history: HistoryMode,
534 #[serde(default = "default_tail_items")]
536 pub tail_items: usize,
537 #[serde(default, skip_serializing_if = "ClientCapabilities::is_none")]
541 pub capabilities: ClientCapabilities,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub name: Option<String>,
549}
550
551pub const CLIENT_NAME_MAX: usize = 32;
553
554pub fn validate_client_name(name: &str) -> Result<(), String> {
558 let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
559 let ok_rest = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
560 if name.is_empty() || name.len() > CLIENT_NAME_MAX || !ok_first || !ok_rest {
561 return Err(format!(
562 "invalid client name {name:?}: use 1-{CLIENT_NAME_MAX} letters, digits, '-' or '_', starting with a letter or digit"
563 ));
564 }
565 Ok(())
566}
567
568pub fn client_name_from_env() -> Option<String> {
571 std::env::var("SYNAPS_CLIENT_NAME")
572 .ok()
573 .map(|v| v.trim().to_string())
574 .filter(|v| !v.is_empty())
575}
576
577pub fn resolve_client_name<'a>(
582 requested: Option<&str>,
583 kind: ClientKind,
584 taken: impl IntoIterator<Item = &'a str>,
585) -> String {
586 let base = requested
587 .filter(|n| validate_client_name(n).is_ok())
588 .unwrap_or(kind.default_name());
589 let taken: std::collections::HashSet<String> = taken.into_iter().map(str::to_ascii_lowercase).collect();
590 if !taken.contains(&base.to_ascii_lowercase()) {
591 return base.to_string();
592 }
593 (2..)
594 .map(|n| format!("{base}-{n}"))
595 .find(|c| !taken.contains(&c.to_ascii_lowercase()))
596 .expect("an unbounded range always has a free suffix")
597}
598
599#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
602pub struct ClientInfo {
603 pub id: ClientId,
604 pub name: String,
606 pub kind: ClientKind,
607 pub mode: AttachMode,
608 pub attached_at_ms: u64,
610}
611
612impl ClientInfo {
613 pub fn label(&self) -> String {
615 format!("{} (#{})", self.name, self.id.0)
616 }
617}
618
619#[derive(Clone, Debug, Default, PartialEq, Eq)]
623pub struct Roster(Vec<ClientInfo>);
624
625impl Roster {
626 pub fn from_snapshot(snapshot: &AttachSnapshot) -> Self {
627 let mut r = Self(snapshot.roster.clone());
628 if r.0.is_empty() {
630 r.0 = snapshot
631 .clients
632 .iter()
633 .map(|(id, kind)| ClientInfo {
634 id: *id,
635 name: kind.default_name().to_string(),
636 kind: *kind,
637 mode: AttachMode::Mirror,
638 attached_at_ms: 0,
639 })
640 .collect();
641 }
642 r
643 }
644
645 pub fn apply(&mut self, event: &SessionEventWire) {
646 match event {
647 SessionEventWire::ClientJoined { client, kind, name, mode } => {
648 self.0.retain(|c| c.id != *client);
649 self.0.push(ClientInfo {
650 id: *client,
651 name: if name.is_empty() { kind.default_name().to_string() } else { name.clone() },
652 kind: *kind,
653 mode: *mode,
654 attached_at_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
656 });
657 self.0.sort_by_key(|c| c.id.0);
658 }
659 SessionEventWire::ClientLeft { client } => self.0.retain(|c| c.id != *client),
660 _ => {}
661 }
662 }
663
664 pub fn clients(&self) -> &[ClientInfo] {
665 &self.0
666 }
667
668 pub fn get(&self, id: ClientId) -> Option<&ClientInfo> {
669 self.0.iter().find(|c| c.id == id)
670 }
671
672 pub fn label(&self, id: ClientId) -> String {
674 self.get(id).map(ClientInfo::label).unwrap_or_else(|| format!("client #{}", id.0))
675 }
676}
677
678#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
685pub struct ClientCapabilities {
686 #[serde(default)]
688 pub tool_queue: bool,
689 #[serde(default)]
693 pub owner_reason_input: bool,
694 #[serde(default)]
697 pub owner_gone: bool,
698}
699
700impl ClientCapabilities {
701 pub fn current() -> Self {
703 Self { tool_queue: true, owner_reason_input: true, owner_gone: true }
704 }
705
706 fn is_none(&self) -> bool {
707 *self == Self::default()
708 }
709}
710
711pub const DEFAULT_TAIL_ITEMS: usize = 120;
713
714fn default_tail_items() -> usize {
715 DEFAULT_TAIL_ITEMS
716}
717
718pub fn tail_items_from_env() -> usize {
721 std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
722 .ok()
723 .and_then(|v| v.trim().parse::<usize>().ok())
724 .unwrap_or(DEFAULT_TAIL_ITEMS)
725}
726
727impl ClientMeta {
728 pub fn new(kind: ClientKind) -> Self {
730 Self {
731 kind,
732 terminal: None,
733 instance: uuid::Uuid::new_v4().to_string(),
734 history: HistoryMode::default(),
735 tail_items: tail_items_from_env(),
736 capabilities: ClientCapabilities::current(),
737 name: None,
738 }
739 }
740
741 pub fn named(mut self, name: Option<String>) -> Self {
743 self.name = name;
744 self
745 }
746}
747
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
752#[serde(rename_all = "snake_case")]
753pub enum HistoryMode {
754 #[default]
755 Full,
756 Digest,
757}
758
759impl HistoryMode {
760 pub fn from_env_or(default: Self) -> Self {
763 match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
764 Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
765 Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
766 _ => default,
767 }
768 }
769
770 pub fn attach_client_default() -> Self {
773 Self::Digest
774 }
775}
776
777#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(rename_all = "snake_case")]
779pub enum ClientKind {
780 Tui,
781 Chat,
782 Attach,
783 Rpc,
784 Server,
785 Test,
786}
787
788impl ClientKind {
789 pub fn default_name(self) -> &'static str {
791 match self {
792 Self::Tui => "tui",
793 Self::Chat => "chat",
794 Self::Attach => "attach",
795 Self::Rpc => "rpc",
796 Self::Server => "server",
797 Self::Test => "test",
798 }
799 }
800}
801
802#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
805#[serde(rename_all = "snake_case")]
806pub enum AttachMode {
807 Mirror,
808 Takeover,
809 Observe,
810}
811
812#[derive(Clone, Copy, Debug, PartialEq, Eq)]
814pub enum AttachState {
815 Attached(usize),
816 Detached { running: bool },
817 Parking,
819 Parked,
821}
822
823#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(rename_all = "snake_case")]
827pub enum PromptKind {
828 Secret,
829 Confirm,
830}
831
832pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
835pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
839
840impl PromptKind {
841 pub fn from_title(title: &str) -> Self {
844 if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
845 Self::Confirm
846 } else {
847 Self::Secret
848 }
849 }
850}
851
852#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
857pub struct ConfirmScope {
858 pub extensions: Vec<String>,
861 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub tool: Option<String>,
864 #[serde(default, skip_serializing_if = "Vec::is_empty")]
868 pub rewritten_by: Vec<String>,
869}
870
871impl ConfirmScope {
872 pub fn extensions_label(&self) -> String {
874 self.extensions.join(", ")
875 }
876
877 pub fn allow_all_label(&self) -> String {
879 format!("Allow all from {} this session", self.extensions_label())
880 }
881
882 pub fn allow_all_scope(&self) -> String {
884 format!(
885 "Allow all: later confirms from {} (any tool) are approved without asking until \
886 /new or /confirms reset. Other extensions still ask.",
887 self.extensions_label()
888 )
889 }
890}
891
892pub fn sanitize_prompt_text(text: &str) -> String {
903 text.chars()
904 .filter_map(|c| match c {
905 '\n' => Some('\n'),
906 '\t' => Some(' '),
907 c if c.is_control() => None,
908 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{2028}' | '\u{2029}' => None,
909 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => None,
910 c => Some(c),
911 })
912 .collect()
913}
914
915#[derive(Clone, Debug, Serialize, Deserialize)]
916pub struct PromptRequest {
917 pub id: u64,
919 pub kind: PromptKind,
920 pub title: String,
921 pub prompt: String,
922 pub raised_at: chrono::DateTime<chrono::Utc>,
923 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub confirm_scope: Option<ConfirmScope>,
927}
928
929#[derive(Debug)]
934pub struct Addressed {
935 pub from: Option<ClientId>,
936 pub cmd: SessionCommand,
937}
938
939#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(rename_all = "snake_case")]
942pub enum CheckpointReason {
943 Reload,
944 HostRequest,
945}
946
947#[derive(Serialize, Deserialize)]
952#[serde(tag = "cmd", rename_all = "snake_case")]
953pub enum SessionCommand {
954 Submit {
957 text: String,
958 #[serde(default)]
962 attachments: Vec<serde_json::Value>,
963 },
964 Steer { text: String },
967 SubagentSteer { handle_id: String, text: String },
972 Cancel,
976 Answer { prompt_id: u64, value: Option<String> },
979 Set { id: u64, setting: SessionSetting },
981 Compact { instructions: Option<String> },
982 NewSession,
984 Save,
985 Query { id: u64, query: SessionQuery },
987 Attach {
989 client: ClientMeta,
990 mode: AttachMode,
991 #[serde(skip)]
994 owner: Option<OwnerBind>,
995 },
996 Detach { client: ClientId },
997 End { reason: EndReason },
999 Resync { client: ClientId, since_seq: u64 },
1001 EngineCommand { id: u64, name: String, arg: String },
1005 SubmitPrepared {
1009 messages: Vec<crate::SharedMessage>,
1010 #[serde(default)]
1011 user_text: Option<String>,
1012 },
1013 PluginCommand { id: u64, plugin: String, name: String, arg: String },
1016 Resume { id: u64, query: String },
1019 Checkpoint { reason: CheckpointReason },
1023 KeepWarm { on: bool },
1026 TakeInput,
1032 #[serde(skip)]
1035 Park,
1036 #[serde(skip)]
1041 AdoptWorkers { workers: Vec<crate::runtime::subagent::CarriedWorker> },
1042 #[serde(skip)]
1046 ArmOwnerRebind,
1047 #[serde(skip)]
1053 RestoreToolActivations {
1054 activations: Vec<crate::tools::activation::CarriedActivation>,
1055 },
1056 #[serde(skip)]
1063 ArmPark,
1064 #[serde(skip)]
1069 ReloadAborted,
1070 #[serde(skip)]
1072 HostEvent(HostEvent),
1073 DriverStart {
1078 plugin: String,
1079 command: String,
1080 arg: String,
1081 },
1082}
1083
1084#[derive(Clone, Debug, Serialize, Deserialize)]
1089pub struct SessionReloadRecord {
1090 pub config: SessionConfig,
1091 pub keep_warm: bool,
1092 pub lifecycle: SessionLifecycle,
1093 #[serde(default)]
1094 pub settings_replay: Vec<SessionSetting>,
1095 #[serde(default)]
1097 pub cache_keepwarm: Option<String>,
1098 pub model: String,
1099 pub thinking_level: String,
1100 #[serde(default)]
1103 pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
1104 #[serde(default)]
1108 pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
1109}
1110
1111pub const RELOADING_REFUSAL: &str = "daemon reloading; retry in a moment";
1113
1114impl SessionCommand {
1115 pub fn refused_while_reloading(&self) -> bool {
1123 matches!(
1124 self,
1125 Self::Submit { .. }
1126 | Self::SubmitPrepared { .. }
1127 | Self::Steer { .. }
1128 | Self::SubagentSteer { .. }
1129 | Self::Compact { .. }
1130 | Self::NewSession
1131 | Self::Resume { .. }
1132 | Self::EngineCommand { .. }
1133 | Self::PluginCommand { .. }
1134 | Self::DriverStart { .. }
1135 | Self::Set { .. }
1136 | Self::KeepWarm { .. }
1137 )
1138 }
1139}
1140
1141impl std::fmt::Debug for SessionCommand {
1142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143 match self {
1144 Self::Submit { attachments, .. } => {
1145 let kinds: Vec<&str> = attachments
1146 .iter()
1147 .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
1148 .collect();
1149 f.debug_struct("Submit")
1150 .field("text", &format_args!("<redacted>"))
1151 .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
1152 .finish()
1153 }
1154 Self::Steer { .. } => f
1155 .debug_struct("Steer")
1156 .field("text", &format_args!("<redacted>"))
1157 .finish(),
1158 Self::SubagentSteer { handle_id, .. } => f
1159 .debug_struct("SubagentSteer")
1160 .field("handle_id", handle_id)
1161 .field("text", &format_args!("<redacted>"))
1162 .finish(),
1163 Self::SubmitPrepared { .. } => f
1164 .debug_struct("SubmitPrepared")
1165 .field("messages", &format_args!("<redacted>"))
1166 .field("user_text", &format_args!("<redacted>"))
1167 .finish(),
1168 Self::Answer { prompt_id, .. } => f
1169 .debug_struct("Answer")
1170 .field("prompt_id", prompt_id)
1171 .field("value", &format_args!("<redacted>"))
1172 .finish(),
1173 Self::Resume { id, .. } => f
1174 .debug_struct("Resume")
1175 .field("id", id)
1176 .field("query", &format_args!("<redacted>"))
1177 .finish(),
1178 Self::Cancel => f.write_str("Cancel"),
1179 Self::Set { id, setting } => {
1180 f.debug_struct("Set").field("id", id).field("setting", setting).finish()
1181 }
1182 Self::Compact { instructions } => {
1183 f.debug_struct("Compact").field("instructions", instructions).finish()
1184 }
1185 Self::NewSession => f.write_str("NewSession"),
1186 Self::Save => f.write_str("Save"),
1187 Self::TakeInput => f.write_str("TakeInput"),
1188 Self::Park => f.write_str("Park"),
1189 Self::ArmOwnerRebind => f.write_str("ArmOwnerRebind"),
1190 Self::AdoptWorkers { workers } => f
1191 .debug_struct("AdoptWorkers")
1192 .field("workers", &workers.iter().map(|w| w.id.as_str()).collect::<Vec<_>>())
1193 .finish(),
1194 Self::RestoreToolActivations { activations } => f
1195 .debug_struct("RestoreToolActivations")
1196 .field(
1197 "tools",
1198 &activations.iter().map(|a| a.tool_id.as_str()).collect::<Vec<_>>(),
1199 )
1200 .finish(),
1201 Self::ReloadAborted => f.write_str("ReloadAborted"),
1202 Self::ArmPark => f.write_str("ArmPark"),
1203 Self::Query { id, query } => {
1204 f.debug_struct("Query").field("id", id).field("query", query).finish()
1205 }
1206 Self::Attach { client, mode, owner } => {
1207 f.debug_struct("Attach")
1208 .field("client", client)
1209 .field("mode", mode)
1210 .field("owner_claim", &owner.is_some())
1211 .finish()
1212 }
1213 Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
1214 Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
1215 Self::Resync { client, since_seq } => f
1216 .debug_struct("Resync")
1217 .field("client", client)
1218 .field("since_seq", since_seq)
1219 .finish(),
1220 Self::EngineCommand { id, name, arg } => f
1221 .debug_struct("EngineCommand")
1222 .field("id", id)
1223 .field("name", name)
1224 .field("arg", arg)
1225 .finish(),
1226 Self::PluginCommand { id, plugin, name, arg } => f
1227 .debug_struct("PluginCommand")
1228 .field("id", id)
1229 .field("plugin", plugin)
1230 .field("name", name)
1231 .field("arg", arg)
1232 .finish(),
1233 Self::Checkpoint { reason } => {
1234 f.debug_struct("Checkpoint").field("reason", reason).finish()
1235 }
1236 Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
1237 Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
1238 Self::DriverStart { plugin, command, .. } => f
1239 .debug_struct("DriverStart")
1240 .field("plugin", plugin)
1241 .field("command", command)
1242 .field("arg", &format_args!("<redacted>"))
1243 .finish(),
1244 }
1245 }
1246}
1247
1248#[derive(Debug, Clone)]
1250pub enum HostEvent {
1251 ExtensionNotification {
1252 extension_id: String,
1253 method: String,
1254 params: serde_json::Value,
1255 },
1256 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1257}
1258
1259#[derive(Debug, Clone, Serialize, Deserialize)]
1261#[serde(tag = "setting", rename_all = "snake_case")]
1262pub enum SessionSetting {
1263 Model { model: String },
1265 ReasoningLevel {
1267 #[serde(with = "reasoning_level_serde")]
1268 level: agent_core::reasoning::ReasoningLevel,
1269 },
1270 ContextWindow { tokens: Option<u64> },
1272 CompactionModel { model: Option<String> },
1273 ApiRetries { n: u32 },
1274 SubagentTimeout { secs: u64 },
1275 MaxToolOutput { bytes: usize },
1276 BashTimeout { secs: u64 },
1277 BashMaxTimeout { secs: u64 },
1278 SystemPrompt { text: String },
1280 ReloadPrompt,
1282 GrantWorkerModel { model: String },
1284}
1285
1286#[derive(Debug, Clone, Serialize, Deserialize)]
1287pub struct SettingApplied {
1288 #[serde(default)]
1290 pub id: u64,
1291 pub setting: String,
1292 pub ok: bool,
1293 pub message: Option<String>,
1294 pub view: RuntimeView,
1295 #[serde(default)]
1298 pub clamp: Option<ReasoningClampWire>,
1299}
1300
1301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1302pub struct ReasoningClampWire {
1303 pub from: String,
1304 pub to: String,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1308#[serde(tag = "query", rename_all = "snake_case")]
1309pub enum SessionQuery {
1310 Status,
1312 Messages,
1314 SubagentRows,
1316 PromptInspection,
1318 ToolsSchema,
1320 View,
1322 ContextAssessment,
1324 ContextReport,
1326 DisplayTail { items: usize },
1328 SubagentWatch {
1335 handle_id: String,
1336 since: u64,
1337 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1338 tool_queue: bool,
1339 },
1340}
1341
1342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1343#[serde(rename_all = "snake_case")]
1344pub enum EndReason {
1345 ClientQuit,
1346 HostShutdown,
1347 Evicted,
1348 Error,
1349 Idle,
1353 OwnerGone,
1357}
1358
1359#[derive(Debug, Clone)]
1365#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
1367 Stream(crate::StreamEvent),
1368 TurnStarted {
1375 turn_baseline: usize,
1376 trigger: TurnTrigger,
1377 user_text: Option<String>,
1378 },
1379 Conversation(ConversationSnapshot),
1382 Prompt(PromptRequest),
1383 PromptResolved { prompt_id: u64 },
1385 External(crate::events::types::Event),
1387 AutoTurnCapReached { cap: u32 },
1388 Idle,
1392 Steered { text: String, delivered: bool },
1394 Dequeued { text: String },
1395 SystemNotice(String),
1397 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1398 ExtensionNotification {
1399 extension_id: String,
1400 method: String,
1401 params: serde_json::Value,
1402 },
1403 SettingChanged(SettingApplied),
1404 QueryResult { id: u64, value: serde_json::Value },
1405 Attached {
1407 client: ClientId,
1408 snapshot: AttachSnapshot,
1409 },
1410 ClientJoined { client: ClientId, kind: ClientKind, name: String, mode: AttachMode },
1412 ClientLeft { client: ClientId },
1413 Ended { reason: EndReason },
1414 Aborted { context_saved: bool },
1418 Cleared { session_id: String },
1420 CompactionStarted { source: String, disclosure: String },
1422 CompactionApplied {
1423 previous_session_id: String,
1424 session_id: String,
1425 chains_advanced: Vec<String>,
1426 queued_restored: Option<String>,
1427 msg_count: usize,
1428 },
1429 CompactionFailed { message: String, panicked: bool },
1431 CompactionCancelled,
1432 SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
1435 Resumed {
1437 id: u64,
1438 old_id: String,
1439 new_id: String,
1440 via: Option<String>,
1441 clamp_notice: Option<String>,
1442 },
1443 InputOwnerChanged {
1446 from: Option<ClientId>,
1447 to: Option<ClientId>,
1448 reason: OwnerChangeReason,
1449 },
1450 Refused { client: ClientId, command: String, reason: String },
1452 AttachRefused { message: String },
1454 Lifecycle(SessionLifecycle),
1456 Reloading { generation: u64, retry_after_ms: u64 },
1458 DriverArmed {
1460 plugin_id: String,
1461 run_id: String,
1462 models: Vec<crate::extensions::session_driver::Selection>,
1463 selection: crate::extensions::session_driver::Selection,
1464 deadline_ms: Option<u64>,
1466 notice: String,
1467 },
1468 DriverRevoked {
1470 reason: String,
1471 undelivered_steering: Vec<String>,
1472 },
1473 DriverTurnOutcome {
1475 outcome: crate::extensions::session_driver::Outcome,
1476 selection: crate::extensions::session_driver::Selection,
1477 feedback: Option<String>,
1478 },
1479 CostCapReached {
1484 scope: String,
1485 cost: f64,
1486 cap: f64,
1487 },
1488}
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1491#[serde(rename_all = "snake_case")]
1492pub enum OwnerChangeReason {
1493 Attach,
1494 Takeover,
1495 OwnerDetached,
1496 Reload,
1497 Input,
1502}
1503
1504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1505#[serde(rename_all = "snake_case")]
1506pub enum TurnTrigger {
1507 User,
1508 QueuedAuto,
1509 EventAuto,
1510 PluginCommand,
1511 Compaction,
1512 DriverAuto,
1514}
1515
1516#[derive(Debug, Clone)]
1519pub struct Envelope {
1520 pub session_id: SessionId,
1521 pub seq: u64,
1522 pub ts: chrono::DateTime<chrono::Utc>,
1523 pub event: SessionEventWire,
1524}
1525
1526#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1527pub struct ConversationTokens {
1528 pub input: u64,
1529 pub output: u64,
1530 pub cache_read: u64,
1531 pub cache_creation: u64,
1532}
1533
1534#[derive(Clone, Default)]
1541pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
1542
1543type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
1545
1546impl MessagesHashMemo {
1547 pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
1548 let mut memo = self
1549 .0
1550 .lock()
1551 .unwrap_or_else(std::sync::PoisonError::into_inner);
1552 if let Some((len, last, hash)) = memo.as_ref() {
1553 let same_last = match (last, messages.last()) {
1554 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
1555 (None, None) => true,
1556 _ => false,
1557 };
1558 if *len == messages.len() && same_last {
1559 return *hash;
1560 }
1561 }
1562 let hash = super::wire::messages_hash(messages);
1563 *memo = Some((messages.len(), messages.last().cloned(), hash));
1564 hash
1565 }
1566}
1567
1568impl std::fmt::Debug for MessagesHashMemo {
1569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1570 f.write_str("MessagesHashMemo")
1571 }
1572}
1573
1574#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1577pub struct ConversationSnapshot {
1578 #[serde(default)]
1580 pub header: SessionHeader,
1581 pub api_messages: Vec<crate::SharedMessage>,
1582 #[serde(default)]
1585 pub messages_len: usize,
1586 pub tokens: ConversationTokens,
1587 pub cost: f64,
1588 #[serde(default)]
1590 pub keepwarm_cost: f64,
1591 #[serde(default)]
1593 pub keepwarm_tokens: ConversationTokens,
1594 pub abort_context: Option<String>,
1597 pub queued_message: Option<String>,
1598 pub pending_events_len: usize,
1599 pub consecutive_auto_turns: u32,
1600 #[serde(skip)]
1602 pub messages_hash_memo: MessagesHashMemo,
1603}
1604
1605#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1607pub struct SessionHeader {
1608 pub id: String,
1609 pub title: String,
1610 pub name: Option<String>,
1611 pub model: String,
1612 pub thinking_level: String,
1613 pub system_prompt: Option<String>,
1614 pub created_at: chrono::DateTime<chrono::Utc>,
1615 pub updated_at: chrono::DateTime<chrono::Utc>,
1616 pub parent_session: Option<String>,
1617}
1618
1619impl From<&crate::Session> for SessionHeader {
1620 fn from(s: &crate::Session) -> Self {
1621 Self {
1622 id: s.id.clone(),
1623 title: s.title.clone(),
1624 name: s.name.clone(),
1625 model: s.model.clone(),
1626 thinking_level: s.thinking_level.clone(),
1627 system_prompt: s.system_prompt.clone(),
1628 created_at: s.created_at,
1629 updated_at: s.updated_at,
1630 parent_session: s.parent_session.clone(),
1631 }
1632 }
1633}
1634
1635#[derive(Debug, Clone)]
1636pub struct AttachSnapshot {
1637 pub meta: SessionMeta,
1638 pub view: RuntimeView,
1639 pub conversation: ConversationSnapshot,
1640 pub streaming: bool,
1641 pub replay: Vec<Envelope>,
1644 pub pending_prompts: Vec<PromptRequest>,
1645 pub clients: Vec<(ClientId, ClientKind)>,
1646 pub roster: Vec<ClientInfo>,
1649 pub client_input: ClientInput,
1651 pub input_owner: Option<ClientId>,
1654 pub display_tail: Option<crate::session::display::DisplayTail>,
1657}
1658
1659impl AttachSnapshot {
1660 pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1668 if self.client_input == ClientInput::Shared {
1669 return None;
1670 }
1671 let owner = self.input_owner.filter(|o| *o != me)?;
1672 let who = Roster::from_snapshot(self).label(owner);
1673 Some(format!("input is owned by {who}; attach with --takeover to steal it"))
1674 }
1675}
1676
1677pub mod reasoning_level_serde {
1680 use agent_core::reasoning::ReasoningLevel;
1681 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1682
1683 pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1684 level.as_str().serialize(s)
1685 }
1686
1687 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1688 let s = String::deserialize(d)?;
1689 ReasoningLevel::parse(&s)
1690 .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1691 }
1692}
1693
1694#[cfg(test)]
1695mod tests {
1696 use super::*;
1697
1698 #[test]
1699 fn client_names_are_short_plain_labels() {
1700 for ok in ["tui", "discord", "Dash_2", "a", "9lives", &"x".repeat(CLIENT_NAME_MAX)] {
1701 assert_eq!(validate_client_name(ok), Ok(()), "{ok}");
1702 }
1703 for bad in ["", "-lead", "_lead", "has space", "dots.no", "émoji", &"x".repeat(CLIENT_NAME_MAX + 1)] {
1704 let err = validate_client_name(bad).unwrap_err();
1705 assert!(err.starts_with("invalid client name"), "{bad}: {err}");
1706 }
1707 }
1708
1709 #[test]
1710 fn a_session_resolves_names_to_unique_ones() {
1711 assert_eq!(resolve_client_name(Some("discord"), ClientKind::Rpc, []), "discord");
1713 assert_eq!(resolve_client_name(None, ClientKind::Tui, []), "tui");
1714 assert_eq!(resolve_client_name(Some("no spaces"), ClientKind::Attach, []), "attach");
1715 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui"]), "tui-2");
1717 assert_eq!(resolve_client_name(Some("TUI"), ClientKind::Tui, ["tui", "tui-2"]), "TUI-3");
1718 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui", "tui-3"]), "tui-2");
1719 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui-2"]), "tui");
1721 }
1722
1723 #[test]
1724 fn a_roster_follows_joins_and_leaves_and_labels_by_name() {
1725 let joined = |id: u64, name: &str, mode: AttachMode| SessionEventWire::ClientJoined {
1726 client: ClientId(id),
1727 kind: ClientKind::Attach,
1728 name: name.to_string(),
1729 mode,
1730 };
1731 let mut r = Roster::default();
1732 r.apply(&joined(2, "dash", AttachMode::Observe));
1733 r.apply(&joined(1, "terminal", AttachMode::Mirror));
1734 r.apply(&joined(3, "", AttachMode::Mirror));
1736 let ids: Vec<u64> = r.clients().iter().map(|c| c.id.0).collect();
1737 assert_eq!(ids, [1, 2, 3], "kept in id order");
1738 assert_eq!(r.get(ClientId(2)).unwrap().mode, AttachMode::Observe);
1739 assert_eq!(r.label(ClientId(1)), "terminal (#1)");
1740 assert_eq!(r.label(ClientId(3)), "attach (#3)");
1741 r.apply(&SessionEventWire::ClientLeft { client: ClientId(1) });
1742 assert_eq!(r.label(ClientId(1)), "client #1", "gone: id only");
1743 assert_eq!(r.clients().len(), 2);
1744 }
1745
1746 #[test]
1750 fn sanitize_prompt_text_strips_separators_keeps_zwj_zwnj() {
1751 let input = "a\u{2028}b\u{2029}c\u{061C}d\u{200B}e\u{200E}f\u{200F}g\u{202E}h\u{2067}i\x1bj\tk\nl";
1752 assert_eq!(sanitize_prompt_text(input), "abcdefghij k\nl");
1753 let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1754 assert_eq!(sanitize_prompt_text(family), family, "ZWJ emoji intact");
1755 let persian = "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}";
1756 assert_eq!(sanitize_prompt_text(persian), persian, "ZWNJ intact");
1757 }
1758
1759 #[test]
1760 fn prompt_kind_from_title() {
1761 assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1762 assert_eq!(
1763 PromptKind::from_title("Confirm tool activation"),
1764 PromptKind::Confirm
1765 );
1766 assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1767 }
1768
1769 #[test]
1770 fn session_config_default_persists() {
1771 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1772 assert!(cfg.persist);
1773 assert!(cfg.cwd.is_none());
1774 assert_eq!(
1775 serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1776 serde_json::Value::Bool(true)
1777 );
1778 }
1779
1780 #[test]
1781 fn session_config_env_defaults_to_none() {
1782 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1783 assert!(cfg.env.is_none());
1784 }
1785
1786 #[test]
1787 fn session_config_env_round_trips() {
1788 let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1789 let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1790 let json = serde_json::to_string(&cfg).unwrap();
1791 let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1792 assert_eq!(cfg2.env, Some(env));
1793 }
1794
1795 #[test]
1796 fn should_strip_env_client_prefixes() {
1797 assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1798 assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1799 assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1800 assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1801 assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1802 assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1804 assert!(!should_strip_env("HOME"));
1805 assert!(!should_strip_env("PATH"));
1806 assert!(!should_strip_env("VIRTUAL_ENV"));
1807 }
1808
1809 #[test]
1810 fn should_strip_env_secrets() {
1811 assert!(should_strip_env("ANTHROPIC_API_KEY"));
1812 assert!(should_strip_env("OPENAI_API_KEY"));
1813 assert!(should_strip_env("my_secret_token"));
1814 assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1815 assert!(should_strip_env("DB_PASSWORD"));
1816 assert!(should_strip_env("GCP_CREDENTIALS"));
1817 assert!(should_strip_env("My_Api_Key"));
1819 assert!(!should_strip_env("MY_VALUE"));
1821 assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1822 assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1824 assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1825 assert!(!should_strip_env("SSH_KEY_PATH"));
1826 assert!(should_strip_env("GH_TOKEN"));
1827 assert!(should_strip_env("SSH_KEY"));
1828 assert!(should_strip_env("SECRET_KEY_BASE"));
1829 assert!(should_strip_env("MY_PASSWD"));
1830 }
1831
1832 #[test]
1833 fn strip_client_env_drops_credential_values_whatever_the_key() {
1834 let vars = vec![
1835 ("HOME".to_string(), "/home/u".to_string()),
1836 (
1837 "DATABASE_URL".to_string(),
1838 "postgres://app:hunter2@db/prod".to_string(),
1839 ),
1840 (
1841 "HTTPS_PROXY".to_string(),
1842 "http://user:pass@proxy:3128".to_string(),
1843 ),
1844 (
1845 "SENTRY_DSN".to_string(),
1846 "https://abc123@o1.ingest.sentry.io/42".to_string(),
1847 ),
1848 ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1849 (
1850 "API_BASE_URL".to_string(),
1851 "https://api.example.com/v1".to_string(),
1852 ),
1853 ];
1854 let (env, stripped) = strip_client_env(vars);
1855 let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1856 assert_eq!(
1857 kept,
1858 vec!["API_BASE_URL", "HOME"],
1859 "only non-secrets survive"
1860 );
1861 assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1862 && !v.contains("user:pass")
1863 && !v.contains("abc123")));
1864 assert_eq!(
1865 stripped,
1866 vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1867 "stripped names are recorded"
1868 );
1869 }
1870
1871 #[test]
1874 fn client_secret_env_is_the_stripped_secrets_with_values() {
1875 let vars = vec![
1876 ("HOME".to_string(), "/home/u".to_string()),
1877 ("GH_TOKEN".to_string(), "ghp_dummy".to_string()),
1878 ("AWS_SECRET_ACCESS_KEY".to_string(), "aws-dummy".to_string()),
1879 ("DATABASE_URL".to_string(), "postgres://app:hunter2@db/prod".to_string()),
1880 ("SYNAPS_CLIENT_TOKEN".to_string(), "client-only".to_string()),
1881 ];
1882 let (_, stripped) = strip_client_env(vars.clone());
1883 let secrets = client_secret_env(vars);
1884 let names: Vec<&str> = secrets.0.iter().map(|(k, _)| k.as_str()).collect();
1885 assert_eq!(names, vec!["AWS_SECRET_ACCESS_KEY", "DATABASE_URL", "GH_TOKEN"]);
1886 assert!(names.iter().all(|n| stripped.iter().any(|s| s == n)), "{stripped:?}");
1888 assert!(stripped.iter().any(|s| s == "SYNAPS_CLIENT_TOKEN"), "{stripped:?}");
1889 assert!(secrets.0.contains(&("GH_TOKEN".to_string(), "ghp_dummy".to_string())));
1890 let dbg = format!("{secrets:?} {:?}", OwnerClaim { token: OwnerToken("tok-123".into()), forward_secrets: Some(secrets.clone()) });
1892 assert!(!dbg.contains("ghp_dummy") && !dbg.contains("hunter2") && !dbg.contains("tok-123"), "{dbg}");
1893 assert!(dbg.contains("GH_TOKEN"), "{dbg}");
1894 }
1895
1896 #[test]
1899 fn tool_env_merges_secrets_at_spawn_and_never_debugs_them() {
1900 let vars = vec![("GH_TOKEN".to_string(), "session-old".to_string()), ("PATH".into(), "/bin".into())];
1901 let env = ToolEnv::with_secrets(vars, SecretEnv(vec![("GH_TOKEN".into(), "s3cr3t-value".into())]));
1902 let spawn: Vec<(&str, &str)> = env.spawn_vars().collect();
1903 assert_eq!(spawn, [("PATH", "/bin"), ("GH_TOKEN", "s3cr3t-value")]);
1904 assert_eq!(env.get("GH_TOKEN"), Some("s3cr3t-value"));
1905 let dbg = format!("{env:?}");
1906 assert!(!dbg.contains("s3cr3t-value") && dbg.contains("GH_TOKEN"), "{dbg}");
1907 let plain: ToolEnv = vec![("PATH".to_string(), "/bin".to_string())].into();
1908 assert_eq!(plain.spawn_vars().collect::<Vec<_>>(), [("PATH", "/bin")]);
1909 }
1910
1911 #[test]
1912 fn owner_tokens_compare_whole() {
1913 let t = OwnerToken("abcdef".into());
1914 assert!(t.matches(&OwnerToken("abcdef".into())));
1915 assert!(!t.matches(&OwnerToken("abcdeg".into())));
1916 assert!(!t.matches(&OwnerToken("abcde".into())));
1917 assert!(!t.matches(&OwnerToken(String::new())));
1918 }
1919
1920 #[test]
1923 fn session_config_owned_never_serializes() {
1924 let cfg = SessionConfig {
1925 owned: Some(OwnedConfig { token: OwnerToken::mint(), restored: false }),
1926 ..Default::default()
1927 };
1928 let v = serde_json::to_value(&cfg).unwrap();
1929 assert!(v.get("owned").is_none(), "{v}");
1930 let back: SessionConfig = serde_json::from_value(v).unwrap();
1931 assert!(back.owned.is_none());
1932 }
1933
1934 #[test]
1935 fn capture_client_env_is_sorted_and_stripped() {
1936 let (env, stripped) = capture_client_env();
1939 for name in &stripped {
1942 let by_value = std::env::var(name)
1943 .map(|v| agent_core::core::config::is_secret_env_value(&v))
1944 .unwrap_or(false);
1945 assert!(
1946 is_secret_key(name) || by_value,
1947 "stripped non-secret: {name}"
1948 );
1949 assert!(
1950 env.iter().all(|(k, _)| k != name),
1951 "stripped name leaked: {name}"
1952 );
1953 }
1954 for w in env.windows(2) {
1956 assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1957 }
1958 for (k, v) in &env {
1960 assert!(!should_strip_env(k), "should have been stripped: {k}");
1961 assert!(
1962 !agent_core::core::config::is_secret_env_value(v),
1963 "credential value kept: {k}"
1964 );
1965 }
1966 }
1967
1968 #[test]
1969 fn command_json_round_trip() {
1970 let cmd = SessionCommand::Set {
1971 id: 7,
1972 setting: SessionSetting::ReasoningLevel {
1973 level: agent_core::reasoning::ReasoningLevel::Ultra,
1974 },
1975 };
1976 let json = serde_json::to_string(&cmd).unwrap();
1977 assert_eq!(
1978 json,
1979 r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1980 );
1981 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1982 assert!(matches!(
1983 back,
1984 SessionCommand::Set {
1985 id: 7,
1986 setting: SessionSetting::ReasoningLevel {
1987 level: agent_core::reasoning::ReasoningLevel::Ultra
1988 }
1989 }
1990 ));
1991
1992 let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1993 match submit {
1994 SessionCommand::Submit { text, attachments } => {
1995 assert_eq!(text, "hi");
1996 assert!(attachments.is_empty());
1997 }
1998 other => panic!("unexpected {other:?}"),
1999 }
2000
2001 let blocks = vec![
2003 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
2004 serde_json::json!({"type":"text","text":"hello"}),
2005 ];
2006 let submit_with = SessionCommand::Submit {
2007 text: "describe".into(),
2008 attachments: blocks.clone(),
2009 };
2010 let json = serde_json::to_string(&submit_with).unwrap();
2011 let back: SessionCommand = serde_json::from_str(&json).unwrap();
2012 match back {
2013 SessionCommand::Submit { text, attachments } => {
2014 assert_eq!(text, "describe");
2015 assert_eq!(attachments.len(), 2);
2016 assert_eq!(attachments[0]["type"], "image");
2017 assert_eq!(attachments[1]["type"], "text");
2018 }
2019 other => panic!("unexpected {other:?}"),
2020 }
2021 }
2022
2023 #[test]
2024 fn new_commands_round_trip() {
2025 let cmds = vec![
2026 SessionCommand::SubmitPrepared {
2027 messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
2028 user_text: Some("t".into()),
2029 },
2030 SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
2031 SessionCommand::Resume { id: 2, query: "q".into() },
2032 SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
2033 SessionCommand::KeepWarm { on: true },
2034 SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
2035 SessionCommand::Query {
2036 id: 4,
2037 query: SessionQuery::SubagentWatch { handle_id: "sa_2".into(), since: 7, tool_queue: true },
2038 },
2039 SessionCommand::SubagentSteer { handle_id: "sa_2".into(), text: "focus on X".into() },
2040 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
2041 SessionCommand::TakeInput,
2042 ];
2043 for cmd in cmds {
2044 let json = serde_json::to_string(&cmd).unwrap();
2045 let back: SessionCommand = serde_json::from_str(&json).unwrap();
2046 assert_eq!(serde_json::to_string(&back).unwrap(), json);
2047 }
2048 let take: SessionCommand = serde_json::from_str(r#"{"cmd":"take_input"}"#).unwrap();
2049 assert!(matches!(take, SessionCommand::TakeInput));
2050 let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
2051 assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
2052 }
2053
2054 #[test]
2055 fn command_debug_redacts_bodies_without_lengths() {
2056 let secret = "hunter2-very-secret";
2057 let cmds = vec![
2058 SessionCommand::Submit { text: secret.into(), attachments: vec![] },
2059 SessionCommand::Steer { text: secret.into() },
2060 SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: secret.into() },
2061 SessionCommand::SubmitPrepared {
2062 messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
2063 user_text: Some(secret.into()),
2064 },
2065 SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
2066 SessionCommand::Resume { id: 1, query: secret.into() },
2067 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
2068 ];
2069 for cmd in cmds {
2070 let d = format!("{cmd:?}");
2071 assert!(!d.contains(secret), "{d}");
2072 assert!(d.contains("<redacted>"), "{d}");
2073 assert!(!d.contains("chars"), "{d}");
2074 assert!(!d.contains(&secret.len().to_string()), "{d}");
2075 }
2076 let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
2077 assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
2078
2079 let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
2081 let cmd = SessionCommand::Submit {
2082 text: "x".into(),
2083 attachments: vec![
2084 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
2085 serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
2086 ],
2087 };
2088 let d = format!("{cmd:?}");
2089 assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
2090 assert!(!d.contains(b64_secret), "base64 leaked: {d}");
2091 }
2092
2093 #[test]
2094 fn session_config_new_fields_default() {
2095 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
2096 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
2097 assert!(cfg.await_extensions);
2098 assert!(!cfg.keep_warm);
2099 let cfg: SessionConfig =
2100 serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
2101 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
2102 assert!(!cfg.await_extensions);
2103 }
2104
2105 #[test]
2106 fn session_lifecycle_u8_round_trip() {
2107 for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
2108 assert_eq!(SessionLifecycle::from_u8(l as u8), l);
2109 }
2110 assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
2111 }
2112
2113 #[test]
2114 fn host_event_is_never_serialised() {
2115 let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
2116 crate::extensions::loader::ExtensionLoaderEvent::Started,
2117 ));
2118 assert!(serde_json::to_string(&cmd).is_err());
2119 }
2120}
2121
2122mod double_option {
2126 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
2127
2128 #[derive(Serialize, Deserialize)]
2129 #[serde(untagged)]
2130 enum Wire {
2131 Latest { latest: bool },
2132 Id(String),
2133 }
2134
2135 pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
2136 match v {
2137 None => s.serialize_none(),
2138 Some(None) => Wire::Latest { latest: true }.serialize(s),
2139 Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
2140 }
2141 }
2142
2143 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
2144 match Option::<Wire>::deserialize(d)? {
2145 None => Ok(None),
2146 Some(Wire::Latest { latest: true }) => Ok(Some(None)),
2147 Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
2148 Some(Wire::Id(id)) => Ok(Some(Some(id))),
2149 }
2150 }
2151
2152 #[cfg(test)]
2153 mod tests {
2154 use super::super::SessionConfig;
2155
2156 fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
2157 let cfg = SessionConfig { continue_session: v, ..Default::default() };
2158 let j = serde_json::to_string(&cfg).unwrap();
2159 serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
2160 }
2161
2162 #[test]
2163 fn continue_session_round_trips_all_three_states() {
2164 assert_eq!(rt(None), None);
2165 assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
2166 assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
2167 }
2168
2169 #[test]
2170 fn continue_session_accepts_legacy_encodings() {
2171 let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
2172 assert_eq!(old_null.continue_session, None);
2173 let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
2174 assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
2175 let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
2176 assert_eq!(absent.continue_session, None);
2177 }
2178 }
2179}
2180
2181pub trait PinSink: Send + Sync {
2186 fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&std::path::Path>, cache_keepwarm: Option<&str>);
2190 fn unpinned(&self, id: &str, journal_id: &str);
2192 fn moved(&self, from: &str, to: &str);
2195 fn renamed(&self, journal_id: &str, name: Option<&str>);
2198}