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, skip_serializing_if = "Option::is_none")]
177 pub client_input: Option<ClientInput>,
178 #[serde(default)]
182 pub name: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub max_session_cost: Option<f64>,
189 #[serde(
195 default,
196 skip_serializing_if = "Option::is_none",
197 deserialize_with = "SessionMemoryMode::deserialize_lenient"
198 )]
199 pub memory_mode: Option<SessionMemoryMode>,
200 #[serde(skip)]
204 pub restore_parked: bool,
205}
206
207#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum SessionMemoryMode {
213 Off,
214 RecallEachPrompt,
215 CaptureOnly,
216 CaptureAndRecall,
217}
218
219impl SessionMemoryMode {
220 fn deserialize_lenient<'de, D>(d: D) -> Result<Option<Self>, D::Error>
225 where
226 D: serde::Deserializer<'de>,
227 {
228 let v = serde_json::Value::deserialize(d)?;
229 if v.is_null() {
230 return Ok(None);
231 }
232 Ok(Some(serde_json::from_value(v.clone()).unwrap_or_else(|_| {
233 tracing::warn!(mode = %v, "unknown session memory mode; treating as off");
234 Self::Off
235 })))
236 }
237
238 pub fn from_command_arg(arg: &str) -> Option<Self> {
240 match arg.trim() {
241 "off" => Some(Self::Off),
242 "recall" => Some(Self::RecallEachPrompt),
243 "capture" => Some(Self::CaptureOnly),
244 "on" => Some(Self::CaptureAndRecall),
245 _ => None,
246 }
247 }
248
249 pub fn lease_mode(self) -> Option<crate::runtime::memory_context::MemoryContextMode> {
251 use crate::runtime::memory_context::MemoryContextMode as M;
252 match self {
253 Self::Off => None,
254 Self::RecallEachPrompt => Some(M::RecallEachPrompt),
255 Self::CaptureOnly => Some(M::CaptureOnly),
256 Self::CaptureAndRecall => Some(M::CaptureAndRecall),
257 }
258 }
259}
260
261fn default_true() -> bool {
262 true
263}
264
265#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "snake_case")]
268pub enum CompactionPolicyWire {
269 #[default]
270 InPlace,
271 LinkedSuccessor,
272}
273
274impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
275 fn from(p: CompactionPolicyWire) -> Self {
276 match p {
277 CompactionPolicyWire::InPlace => Self::InPlace,
278 CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
279 }
280 }
281}
282
283impl Default for SessionConfig {
284 fn default() -> Self {
285 Self {
286 continue_session: None,
287 system: None,
288 prompt_manifest: None,
289 cwd: None,
290 env: None,
291 env_stripped: Vec::new(),
292 auto_approve_confirms: false,
293 model_override: None,
294 persist: true,
295 auto_compact: false,
296 compaction_policy: CompactionPolicyWire::InPlace,
297 await_extensions: true,
298 keep_warm: false,
299 client_input: None,
300 name: None,
301 max_session_cost: None,
302 memory_mode: None,
303 restore_parked: false,
304 }
305 }
306}
307
308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
310pub struct ContinueInfoWire {
311 pub session_id: String,
312 pub resolved_via: Option<String>,
314 pub query: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub compaction_notice: Option<String>,
318}
319
320impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
321 fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
322 Self {
323 session_id: c.session_id.clone(),
324 resolved_via: c.resolved_via.clone(),
325 query: c.query.clone(),
326 compaction_notice: c.compaction_notice.clone(),
327 }
328 }
329}
330
331#[derive(Clone, Debug, Serialize, Deserialize)]
332pub struct SessionMeta {
333 pub id: SessionId,
334 pub name: Option<String>,
335 pub model: String,
336 pub cwd: Option<PathBuf>,
337 pub created_at: chrono::DateTime<chrono::Utc>,
338 pub continued: bool,
339 pub continue_info: Option<ContinueInfoWire>,
340 pub host_pid: u32,
342 #[serde(default)]
344 pub lifecycle: SessionLifecycle,
345 #[serde(default)]
346 pub clients: usize,
347 #[serde(default)]
348 pub input_owner: Option<ClientId>,
349 #[serde(default)]
350 pub awaiting_input: usize,
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub roster: Vec<ClientInfo>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub client_input: Option<ClientInput>,
359 #[serde(default)]
361 pub journal_id: String,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub locked_by: Option<String>,
366 #[serde(default)]
370 pub pinned: bool,
371 #[serde(default)]
375 pub busy: bool,
376 #[serde(default)]
379 pub state_since: Option<chrono::DateTime<chrono::Utc>>,
380 #[serde(default)]
383 pub park_due: Option<chrono::DateTime<chrono::Utc>>,
384 #[serde(default)]
386 pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
387 #[serde(default)]
389 pub title: Option<String>,
390 #[serde(default)]
392 pub cost_usd: f64,
393 #[serde(default)]
395 pub messages: usize,
396}
397
398#[repr(u8)]
400#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum SessionLifecycle {
403 #[default]
404 Live = 0,
405 Parking = 1,
406 Parked = 2,
407 Ending = 3,
408}
409
410impl SessionLifecycle {
411 pub fn from_u8(v: u8) -> Self {
412 match v {
413 1 => Self::Parking,
414 2 => Self::Parked,
415 3 => Self::Ending,
416 _ => Self::Live,
417 }
418 }
419}
420
421pub use crate::core::config::ClientInput;
422
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
425#[serde(transparent)]
426pub struct ClientId(pub u64);
427
428#[derive(Clone, Debug, Serialize, Deserialize)]
429pub struct ClientMeta {
430 pub kind: ClientKind,
431 pub terminal: Option<String>,
433 pub instance: String,
435 #[serde(default)]
437 pub history: HistoryMode,
438 #[serde(default = "default_tail_items")]
440 pub tail_items: usize,
441 #[serde(default, skip_serializing_if = "ClientCapabilities::is_none")]
445 pub capabilities: ClientCapabilities,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub name: Option<String>,
453}
454
455pub const CLIENT_NAME_MAX: usize = 32;
457
458pub fn validate_client_name(name: &str) -> Result<(), String> {
462 let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
463 let ok_rest = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
464 if name.is_empty() || name.len() > CLIENT_NAME_MAX || !ok_first || !ok_rest {
465 return Err(format!(
466 "invalid client name {name:?}: use 1-{CLIENT_NAME_MAX} letters, digits, '-' or '_', starting with a letter or digit"
467 ));
468 }
469 Ok(())
470}
471
472pub fn client_name_from_env() -> Option<String> {
475 std::env::var("SYNAPS_CLIENT_NAME")
476 .ok()
477 .map(|v| v.trim().to_string())
478 .filter(|v| !v.is_empty())
479}
480
481pub fn resolve_client_name<'a>(
486 requested: Option<&str>,
487 kind: ClientKind,
488 taken: impl IntoIterator<Item = &'a str>,
489) -> String {
490 let base = requested
491 .filter(|n| validate_client_name(n).is_ok())
492 .unwrap_or(kind.default_name());
493 let taken: std::collections::HashSet<String> = taken.into_iter().map(str::to_ascii_lowercase).collect();
494 if !taken.contains(&base.to_ascii_lowercase()) {
495 return base.to_string();
496 }
497 (2..)
498 .map(|n| format!("{base}-{n}"))
499 .find(|c| !taken.contains(&c.to_ascii_lowercase()))
500 .expect("an unbounded range always has a free suffix")
501}
502
503#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
506pub struct ClientInfo {
507 pub id: ClientId,
508 pub name: String,
510 pub kind: ClientKind,
511 pub mode: AttachMode,
512 pub attached_at_ms: u64,
514}
515
516impl ClientInfo {
517 pub fn label(&self) -> String {
519 format!("{} (#{})", self.name, self.id.0)
520 }
521}
522
523#[derive(Clone, Debug, Default, PartialEq, Eq)]
527pub struct Roster(Vec<ClientInfo>);
528
529impl Roster {
530 pub fn from_snapshot(snapshot: &AttachSnapshot) -> Self {
531 let mut r = Self(snapshot.roster.clone());
532 if r.0.is_empty() {
534 r.0 = snapshot
535 .clients
536 .iter()
537 .map(|(id, kind)| ClientInfo {
538 id: *id,
539 name: kind.default_name().to_string(),
540 kind: *kind,
541 mode: AttachMode::Mirror,
542 attached_at_ms: 0,
543 })
544 .collect();
545 }
546 r
547 }
548
549 pub fn apply(&mut self, event: &SessionEventWire) {
550 match event {
551 SessionEventWire::ClientJoined { client, kind, name, mode } => {
552 self.0.retain(|c| c.id != *client);
553 self.0.push(ClientInfo {
554 id: *client,
555 name: if name.is_empty() { kind.default_name().to_string() } else { name.clone() },
556 kind: *kind,
557 mode: *mode,
558 attached_at_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
560 });
561 self.0.sort_by_key(|c| c.id.0);
562 }
563 SessionEventWire::ClientLeft { client } => self.0.retain(|c| c.id != *client),
564 _ => {}
565 }
566 }
567
568 pub fn clients(&self) -> &[ClientInfo] {
569 &self.0
570 }
571
572 pub fn get(&self, id: ClientId) -> Option<&ClientInfo> {
573 self.0.iter().find(|c| c.id == id)
574 }
575
576 pub fn label(&self, id: ClientId) -> String {
578 self.get(id).map(ClientInfo::label).unwrap_or_else(|| format!("client #{}", id.0))
579 }
580}
581
582#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
589pub struct ClientCapabilities {
590 #[serde(default)]
592 pub tool_queue: bool,
593 #[serde(default)]
597 pub owner_reason_input: bool,
598}
599
600impl ClientCapabilities {
601 pub fn current() -> Self {
603 Self { tool_queue: true, owner_reason_input: true }
604 }
605
606 fn is_none(&self) -> bool {
607 *self == Self::default()
608 }
609}
610
611pub const DEFAULT_TAIL_ITEMS: usize = 120;
613
614fn default_tail_items() -> usize {
615 DEFAULT_TAIL_ITEMS
616}
617
618pub fn tail_items_from_env() -> usize {
621 std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
622 .ok()
623 .and_then(|v| v.trim().parse::<usize>().ok())
624 .unwrap_or(DEFAULT_TAIL_ITEMS)
625}
626
627impl ClientMeta {
628 pub fn new(kind: ClientKind) -> Self {
630 Self {
631 kind,
632 terminal: None,
633 instance: uuid::Uuid::new_v4().to_string(),
634 history: HistoryMode::default(),
635 tail_items: tail_items_from_env(),
636 capabilities: ClientCapabilities::current(),
637 name: None,
638 }
639 }
640
641 pub fn named(mut self, name: Option<String>) -> Self {
643 self.name = name;
644 self
645 }
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
652#[serde(rename_all = "snake_case")]
653pub enum HistoryMode {
654 #[default]
655 Full,
656 Digest,
657}
658
659impl HistoryMode {
660 pub fn from_env_or(default: Self) -> Self {
663 match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
664 Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
665 Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
666 _ => default,
667 }
668 }
669
670 pub fn attach_client_default() -> Self {
673 Self::Digest
674 }
675}
676
677#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(rename_all = "snake_case")]
679pub enum ClientKind {
680 Tui,
681 Chat,
682 Attach,
683 Rpc,
684 Server,
685 Test,
686}
687
688impl ClientKind {
689 pub fn default_name(self) -> &'static str {
691 match self {
692 Self::Tui => "tui",
693 Self::Chat => "chat",
694 Self::Attach => "attach",
695 Self::Rpc => "rpc",
696 Self::Server => "server",
697 Self::Test => "test",
698 }
699 }
700}
701
702#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
705#[serde(rename_all = "snake_case")]
706pub enum AttachMode {
707 Mirror,
708 Takeover,
709 Observe,
710}
711
712#[derive(Clone, Copy, Debug, PartialEq, Eq)]
714pub enum AttachState {
715 Attached(usize),
716 Detached { running: bool },
717 Parking,
719 Parked,
721}
722
723#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
726#[serde(rename_all = "snake_case")]
727pub enum PromptKind {
728 Secret,
729 Confirm,
730}
731
732pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
735pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
739
740impl PromptKind {
741 pub fn from_title(title: &str) -> Self {
744 if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
745 Self::Confirm
746 } else {
747 Self::Secret
748 }
749 }
750}
751
752#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
757pub struct ConfirmScope {
758 pub extensions: Vec<String>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub tool: Option<String>,
764 #[serde(default, skip_serializing_if = "Vec::is_empty")]
768 pub rewritten_by: Vec<String>,
769}
770
771impl ConfirmScope {
772 pub fn extensions_label(&self) -> String {
774 self.extensions.join(", ")
775 }
776
777 pub fn allow_all_label(&self) -> String {
779 format!("Allow all from {} this session", self.extensions_label())
780 }
781
782 pub fn allow_all_scope(&self) -> String {
784 format!(
785 "Allow all: later confirms from {} (any tool) are approved without asking until \
786 /new or /confirms reset. Other extensions still ask.",
787 self.extensions_label()
788 )
789 }
790}
791
792pub fn sanitize_prompt_text(text: &str) -> String {
803 text.chars()
804 .filter_map(|c| match c {
805 '\n' => Some('\n'),
806 '\t' => Some(' '),
807 c if c.is_control() => None,
808 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{2028}' | '\u{2029}' => None,
809 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => None,
810 c => Some(c),
811 })
812 .collect()
813}
814
815#[derive(Clone, Debug, Serialize, Deserialize)]
816pub struct PromptRequest {
817 pub id: u64,
819 pub kind: PromptKind,
820 pub title: String,
821 pub prompt: String,
822 pub raised_at: chrono::DateTime<chrono::Utc>,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub confirm_scope: Option<ConfirmScope>,
827}
828
829#[derive(Debug)]
834pub struct Addressed {
835 pub from: Option<ClientId>,
836 pub cmd: SessionCommand,
837}
838
839#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(rename_all = "snake_case")]
842pub enum CheckpointReason {
843 Reload,
844 HostRequest,
845}
846
847#[derive(Serialize, Deserialize)]
852#[serde(tag = "cmd", rename_all = "snake_case")]
853pub enum SessionCommand {
854 Submit {
857 text: String,
858 #[serde(default)]
862 attachments: Vec<serde_json::Value>,
863 },
864 Steer { text: String },
867 SubagentSteer { handle_id: String, text: String },
872 Cancel,
876 Answer { prompt_id: u64, value: Option<String> },
879 Set { id: u64, setting: SessionSetting },
881 Compact { instructions: Option<String> },
882 NewSession,
884 Save,
885 Query { id: u64, query: SessionQuery },
887 Attach { client: ClientMeta, mode: AttachMode },
889 Detach { client: ClientId },
890 End { reason: EndReason },
892 Resync { client: ClientId, since_seq: u64 },
894 EngineCommand { id: u64, name: String, arg: String },
898 SubmitPrepared {
902 messages: Vec<crate::SharedMessage>,
903 #[serde(default)]
904 user_text: Option<String>,
905 },
906 PluginCommand { id: u64, plugin: String, name: String, arg: String },
909 Resume { id: u64, query: String },
912 Checkpoint { reason: CheckpointReason },
916 KeepWarm { on: bool },
919 TakeInput,
925 #[serde(skip)]
928 Park,
929 #[serde(skip)]
934 AdoptWorkers { workers: Vec<crate::runtime::subagent::CarriedWorker> },
935 #[serde(skip)]
941 RestoreToolActivations {
942 activations: Vec<crate::tools::activation::CarriedActivation>,
943 },
944 #[serde(skip)]
951 ArmPark,
952 #[serde(skip)]
957 ReloadAborted,
958 #[serde(skip)]
960 HostEvent(HostEvent),
961 DriverStart {
966 plugin: String,
967 command: String,
968 arg: String,
969 },
970}
971
972#[derive(Clone, Debug, Serialize, Deserialize)]
977pub struct SessionReloadRecord {
978 pub config: SessionConfig,
979 pub keep_warm: bool,
980 pub lifecycle: SessionLifecycle,
981 #[serde(default)]
982 pub settings_replay: Vec<SessionSetting>,
983 #[serde(default)]
985 pub cache_keepwarm: Option<String>,
986 pub model: String,
987 pub thinking_level: String,
988 #[serde(default)]
991 pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
992 #[serde(default)]
996 pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
997}
998
999pub const RELOADING_REFUSAL: &str = "daemon reloading; retry in a moment";
1001
1002impl SessionCommand {
1003 pub fn refused_while_reloading(&self) -> bool {
1011 matches!(
1012 self,
1013 Self::Submit { .. }
1014 | Self::SubmitPrepared { .. }
1015 | Self::Steer { .. }
1016 | Self::SubagentSteer { .. }
1017 | Self::Compact { .. }
1018 | Self::NewSession
1019 | Self::Resume { .. }
1020 | Self::EngineCommand { .. }
1021 | Self::PluginCommand { .. }
1022 | Self::DriverStart { .. }
1023 | Self::Set { .. }
1024 | Self::KeepWarm { .. }
1025 )
1026 }
1027}
1028
1029impl std::fmt::Debug for SessionCommand {
1030 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1031 match self {
1032 Self::Submit { attachments, .. } => {
1033 let kinds: Vec<&str> = attachments
1034 .iter()
1035 .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
1036 .collect();
1037 f.debug_struct("Submit")
1038 .field("text", &format_args!("<redacted>"))
1039 .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
1040 .finish()
1041 }
1042 Self::Steer { .. } => f
1043 .debug_struct("Steer")
1044 .field("text", &format_args!("<redacted>"))
1045 .finish(),
1046 Self::SubagentSteer { handle_id, .. } => f
1047 .debug_struct("SubagentSteer")
1048 .field("handle_id", handle_id)
1049 .field("text", &format_args!("<redacted>"))
1050 .finish(),
1051 Self::SubmitPrepared { .. } => f
1052 .debug_struct("SubmitPrepared")
1053 .field("messages", &format_args!("<redacted>"))
1054 .field("user_text", &format_args!("<redacted>"))
1055 .finish(),
1056 Self::Answer { prompt_id, .. } => f
1057 .debug_struct("Answer")
1058 .field("prompt_id", prompt_id)
1059 .field("value", &format_args!("<redacted>"))
1060 .finish(),
1061 Self::Resume { id, .. } => f
1062 .debug_struct("Resume")
1063 .field("id", id)
1064 .field("query", &format_args!("<redacted>"))
1065 .finish(),
1066 Self::Cancel => f.write_str("Cancel"),
1067 Self::Set { id, setting } => {
1068 f.debug_struct("Set").field("id", id).field("setting", setting).finish()
1069 }
1070 Self::Compact { instructions } => {
1071 f.debug_struct("Compact").field("instructions", instructions).finish()
1072 }
1073 Self::NewSession => f.write_str("NewSession"),
1074 Self::Save => f.write_str("Save"),
1075 Self::TakeInput => f.write_str("TakeInput"),
1076 Self::Park => f.write_str("Park"),
1077 Self::AdoptWorkers { workers } => f
1078 .debug_struct("AdoptWorkers")
1079 .field("workers", &workers.iter().map(|w| w.id.as_str()).collect::<Vec<_>>())
1080 .finish(),
1081 Self::RestoreToolActivations { activations } => f
1082 .debug_struct("RestoreToolActivations")
1083 .field(
1084 "tools",
1085 &activations.iter().map(|a| a.tool_id.as_str()).collect::<Vec<_>>(),
1086 )
1087 .finish(),
1088 Self::ReloadAborted => f.write_str("ReloadAborted"),
1089 Self::ArmPark => f.write_str("ArmPark"),
1090 Self::Query { id, query } => {
1091 f.debug_struct("Query").field("id", id).field("query", query).finish()
1092 }
1093 Self::Attach { client, mode } => {
1094 f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
1095 }
1096 Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
1097 Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
1098 Self::Resync { client, since_seq } => f
1099 .debug_struct("Resync")
1100 .field("client", client)
1101 .field("since_seq", since_seq)
1102 .finish(),
1103 Self::EngineCommand { id, name, arg } => f
1104 .debug_struct("EngineCommand")
1105 .field("id", id)
1106 .field("name", name)
1107 .field("arg", arg)
1108 .finish(),
1109 Self::PluginCommand { id, plugin, name, arg } => f
1110 .debug_struct("PluginCommand")
1111 .field("id", id)
1112 .field("plugin", plugin)
1113 .field("name", name)
1114 .field("arg", arg)
1115 .finish(),
1116 Self::Checkpoint { reason } => {
1117 f.debug_struct("Checkpoint").field("reason", reason).finish()
1118 }
1119 Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
1120 Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
1121 Self::DriverStart { plugin, command, .. } => f
1122 .debug_struct("DriverStart")
1123 .field("plugin", plugin)
1124 .field("command", command)
1125 .field("arg", &format_args!("<redacted>"))
1126 .finish(),
1127 }
1128 }
1129}
1130
1131#[derive(Debug, Clone)]
1133pub enum HostEvent {
1134 ExtensionNotification {
1135 extension_id: String,
1136 method: String,
1137 params: serde_json::Value,
1138 },
1139 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1140}
1141
1142#[derive(Debug, Clone, Serialize, Deserialize)]
1144#[serde(tag = "setting", rename_all = "snake_case")]
1145pub enum SessionSetting {
1146 Model { model: String },
1148 ReasoningLevel {
1150 #[serde(with = "reasoning_level_serde")]
1151 level: agent_core::reasoning::ReasoningLevel,
1152 },
1153 ContextWindow { tokens: Option<u64> },
1155 CompactionModel { model: Option<String> },
1156 ApiRetries { n: u32 },
1157 SubagentTimeout { secs: u64 },
1158 MaxToolOutput { bytes: usize },
1159 BashTimeout { secs: u64 },
1160 BashMaxTimeout { secs: u64 },
1161 SystemPrompt { text: String },
1163 ReloadPrompt,
1165 GrantWorkerModel { model: String },
1167}
1168
1169#[derive(Debug, Clone, Serialize, Deserialize)]
1170pub struct SettingApplied {
1171 #[serde(default)]
1173 pub id: u64,
1174 pub setting: String,
1175 pub ok: bool,
1176 pub message: Option<String>,
1177 pub view: RuntimeView,
1178 #[serde(default)]
1181 pub clamp: Option<ReasoningClampWire>,
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1185pub struct ReasoningClampWire {
1186 pub from: String,
1187 pub to: String,
1188}
1189
1190#[derive(Debug, Clone, Serialize, Deserialize)]
1191#[serde(tag = "query", rename_all = "snake_case")]
1192pub enum SessionQuery {
1193 Status,
1195 Messages,
1197 SubagentRows,
1199 PromptInspection,
1201 ToolsSchema,
1203 View,
1205 ContextAssessment,
1207 ContextReport,
1209 DisplayTail { items: usize },
1211 SubagentWatch {
1218 handle_id: String,
1219 since: u64,
1220 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1221 tool_queue: bool,
1222 },
1223}
1224
1225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1226#[serde(rename_all = "snake_case")]
1227pub enum EndReason {
1228 ClientQuit,
1229 HostShutdown,
1230 Evicted,
1231 Error,
1232 Idle,
1236}
1237
1238#[derive(Debug, Clone)]
1244#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
1246 Stream(crate::StreamEvent),
1247 TurnStarted {
1254 turn_baseline: usize,
1255 trigger: TurnTrigger,
1256 user_text: Option<String>,
1257 },
1258 Conversation(ConversationSnapshot),
1261 Prompt(PromptRequest),
1262 PromptResolved { prompt_id: u64 },
1264 External(crate::events::types::Event),
1266 AutoTurnCapReached { cap: u32 },
1267 Idle,
1271 Steered { text: String, delivered: bool },
1273 Dequeued { text: String },
1274 SystemNotice(String),
1276 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1277 ExtensionNotification {
1278 extension_id: String,
1279 method: String,
1280 params: serde_json::Value,
1281 },
1282 SettingChanged(SettingApplied),
1283 QueryResult { id: u64, value: serde_json::Value },
1284 Attached {
1286 client: ClientId,
1287 snapshot: AttachSnapshot,
1288 },
1289 ClientJoined { client: ClientId, kind: ClientKind, name: String, mode: AttachMode },
1291 ClientLeft { client: ClientId },
1292 Ended { reason: EndReason },
1293 Aborted { context_saved: bool },
1297 Cleared { session_id: String },
1299 CompactionStarted { source: String, disclosure: String },
1301 CompactionApplied {
1302 previous_session_id: String,
1303 session_id: String,
1304 chains_advanced: Vec<String>,
1305 queued_restored: Option<String>,
1306 msg_count: usize,
1307 },
1308 CompactionFailed { message: String, panicked: bool },
1310 CompactionCancelled,
1311 SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
1314 Resumed {
1316 id: u64,
1317 old_id: String,
1318 new_id: String,
1319 via: Option<String>,
1320 clamp_notice: Option<String>,
1321 },
1322 InputOwnerChanged {
1325 from: Option<ClientId>,
1326 to: Option<ClientId>,
1327 reason: OwnerChangeReason,
1328 },
1329 Refused { client: ClientId, command: String, reason: String },
1331 AttachRefused { message: String },
1333 Lifecycle(SessionLifecycle),
1335 Reloading { generation: u64, retry_after_ms: u64 },
1337 DriverArmed {
1339 plugin_id: String,
1340 run_id: String,
1341 models: Vec<crate::extensions::session_driver::Selection>,
1342 selection: crate::extensions::session_driver::Selection,
1343 deadline_ms: Option<u64>,
1345 notice: String,
1346 },
1347 DriverRevoked {
1349 reason: String,
1350 undelivered_steering: Vec<String>,
1351 },
1352 DriverTurnOutcome {
1354 outcome: crate::extensions::session_driver::Outcome,
1355 selection: crate::extensions::session_driver::Selection,
1356 feedback: Option<String>,
1357 },
1358 CostCapReached {
1363 scope: String,
1364 cost: f64,
1365 cap: f64,
1366 },
1367}
1368
1369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1370#[serde(rename_all = "snake_case")]
1371pub enum OwnerChangeReason {
1372 Attach,
1373 Takeover,
1374 OwnerDetached,
1375 Reload,
1376 Input,
1381}
1382
1383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1384#[serde(rename_all = "snake_case")]
1385pub enum TurnTrigger {
1386 User,
1387 QueuedAuto,
1388 EventAuto,
1389 PluginCommand,
1390 Compaction,
1391 DriverAuto,
1393}
1394
1395#[derive(Debug, Clone)]
1398pub struct Envelope {
1399 pub session_id: SessionId,
1400 pub seq: u64,
1401 pub ts: chrono::DateTime<chrono::Utc>,
1402 pub event: SessionEventWire,
1403}
1404
1405#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1406pub struct ConversationTokens {
1407 pub input: u64,
1408 pub output: u64,
1409 pub cache_read: u64,
1410 pub cache_creation: u64,
1411}
1412
1413#[derive(Clone, Default)]
1420pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
1421
1422type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
1424
1425impl MessagesHashMemo {
1426 pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
1427 let mut memo = self
1428 .0
1429 .lock()
1430 .unwrap_or_else(std::sync::PoisonError::into_inner);
1431 if let Some((len, last, hash)) = memo.as_ref() {
1432 let same_last = match (last, messages.last()) {
1433 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
1434 (None, None) => true,
1435 _ => false,
1436 };
1437 if *len == messages.len() && same_last {
1438 return *hash;
1439 }
1440 }
1441 let hash = super::wire::messages_hash(messages);
1442 *memo = Some((messages.len(), messages.last().cloned(), hash));
1443 hash
1444 }
1445}
1446
1447impl std::fmt::Debug for MessagesHashMemo {
1448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1449 f.write_str("MessagesHashMemo")
1450 }
1451}
1452
1453#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1456pub struct ConversationSnapshot {
1457 #[serde(default)]
1459 pub header: SessionHeader,
1460 pub api_messages: Vec<crate::SharedMessage>,
1461 #[serde(default)]
1464 pub messages_len: usize,
1465 pub tokens: ConversationTokens,
1466 pub cost: f64,
1467 #[serde(default)]
1469 pub keepwarm_cost: f64,
1470 #[serde(default)]
1472 pub keepwarm_tokens: ConversationTokens,
1473 pub abort_context: Option<String>,
1476 pub queued_message: Option<String>,
1477 pub pending_events_len: usize,
1478 pub consecutive_auto_turns: u32,
1479 #[serde(skip)]
1481 pub messages_hash_memo: MessagesHashMemo,
1482}
1483
1484#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1486pub struct SessionHeader {
1487 pub id: String,
1488 pub title: String,
1489 pub name: Option<String>,
1490 pub model: String,
1491 pub thinking_level: String,
1492 pub system_prompt: Option<String>,
1493 pub created_at: chrono::DateTime<chrono::Utc>,
1494 pub updated_at: chrono::DateTime<chrono::Utc>,
1495 pub parent_session: Option<String>,
1496}
1497
1498impl From<&crate::Session> for SessionHeader {
1499 fn from(s: &crate::Session) -> Self {
1500 Self {
1501 id: s.id.clone(),
1502 title: s.title.clone(),
1503 name: s.name.clone(),
1504 model: s.model.clone(),
1505 thinking_level: s.thinking_level.clone(),
1506 system_prompt: s.system_prompt.clone(),
1507 created_at: s.created_at,
1508 updated_at: s.updated_at,
1509 parent_session: s.parent_session.clone(),
1510 }
1511 }
1512}
1513
1514#[derive(Debug, Clone)]
1515pub struct AttachSnapshot {
1516 pub meta: SessionMeta,
1517 pub view: RuntimeView,
1518 pub conversation: ConversationSnapshot,
1519 pub streaming: bool,
1520 pub replay: Vec<Envelope>,
1523 pub pending_prompts: Vec<PromptRequest>,
1524 pub clients: Vec<(ClientId, ClientKind)>,
1525 pub roster: Vec<ClientInfo>,
1528 pub client_input: ClientInput,
1530 pub input_owner: Option<ClientId>,
1533 pub display_tail: Option<crate::session::display::DisplayTail>,
1536}
1537
1538impl AttachSnapshot {
1539 pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1547 if self.client_input == ClientInput::Shared {
1548 return None;
1549 }
1550 let owner = self.input_owner.filter(|o| *o != me)?;
1551 let who = Roster::from_snapshot(self).label(owner);
1552 Some(format!("input is owned by {who}; attach with --takeover to steal it"))
1553 }
1554}
1555
1556pub mod reasoning_level_serde {
1559 use agent_core::reasoning::ReasoningLevel;
1560 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1561
1562 pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1563 level.as_str().serialize(s)
1564 }
1565
1566 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1567 let s = String::deserialize(d)?;
1568 ReasoningLevel::parse(&s)
1569 .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1570 }
1571}
1572
1573#[cfg(test)]
1574mod tests {
1575 use super::*;
1576
1577 #[test]
1578 fn client_names_are_short_plain_labels() {
1579 for ok in ["tui", "discord", "Dash_2", "a", "9lives", &"x".repeat(CLIENT_NAME_MAX)] {
1580 assert_eq!(validate_client_name(ok), Ok(()), "{ok}");
1581 }
1582 for bad in ["", "-lead", "_lead", "has space", "dots.no", "émoji", &"x".repeat(CLIENT_NAME_MAX + 1)] {
1583 let err = validate_client_name(bad).unwrap_err();
1584 assert!(err.starts_with("invalid client name"), "{bad}: {err}");
1585 }
1586 }
1587
1588 #[test]
1589 fn a_session_resolves_names_to_unique_ones() {
1590 assert_eq!(resolve_client_name(Some("discord"), ClientKind::Rpc, []), "discord");
1592 assert_eq!(resolve_client_name(None, ClientKind::Tui, []), "tui");
1593 assert_eq!(resolve_client_name(Some("no spaces"), ClientKind::Attach, []), "attach");
1594 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui"]), "tui-2");
1596 assert_eq!(resolve_client_name(Some("TUI"), ClientKind::Tui, ["tui", "tui-2"]), "TUI-3");
1597 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui", "tui-3"]), "tui-2");
1598 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui-2"]), "tui");
1600 }
1601
1602 #[test]
1603 fn a_roster_follows_joins_and_leaves_and_labels_by_name() {
1604 let joined = |id: u64, name: &str, mode: AttachMode| SessionEventWire::ClientJoined {
1605 client: ClientId(id),
1606 kind: ClientKind::Attach,
1607 name: name.to_string(),
1608 mode,
1609 };
1610 let mut r = Roster::default();
1611 r.apply(&joined(2, "dash", AttachMode::Observe));
1612 r.apply(&joined(1, "terminal", AttachMode::Mirror));
1613 r.apply(&joined(3, "", AttachMode::Mirror));
1615 let ids: Vec<u64> = r.clients().iter().map(|c| c.id.0).collect();
1616 assert_eq!(ids, [1, 2, 3], "kept in id order");
1617 assert_eq!(r.get(ClientId(2)).unwrap().mode, AttachMode::Observe);
1618 assert_eq!(r.label(ClientId(1)), "terminal (#1)");
1619 assert_eq!(r.label(ClientId(3)), "attach (#3)");
1620 r.apply(&SessionEventWire::ClientLeft { client: ClientId(1) });
1621 assert_eq!(r.label(ClientId(1)), "client #1", "gone: id only");
1622 assert_eq!(r.clients().len(), 2);
1623 }
1624
1625 #[test]
1629 fn sanitize_prompt_text_strips_separators_keeps_zwj_zwnj() {
1630 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";
1631 assert_eq!(sanitize_prompt_text(input), "abcdefghij k\nl");
1632 let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1633 assert_eq!(sanitize_prompt_text(family), family, "ZWJ emoji intact");
1634 let persian = "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}";
1635 assert_eq!(sanitize_prompt_text(persian), persian, "ZWNJ intact");
1636 }
1637
1638 #[test]
1639 fn prompt_kind_from_title() {
1640 assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1641 assert_eq!(
1642 PromptKind::from_title("Confirm tool activation"),
1643 PromptKind::Confirm
1644 );
1645 assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1646 }
1647
1648 #[test]
1649 fn session_config_default_persists() {
1650 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1651 assert!(cfg.persist);
1652 assert!(cfg.cwd.is_none());
1653 assert_eq!(
1654 serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1655 serde_json::Value::Bool(true)
1656 );
1657 }
1658
1659 #[test]
1660 fn session_config_env_defaults_to_none() {
1661 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1662 assert!(cfg.env.is_none());
1663 }
1664
1665 #[test]
1666 fn session_config_env_round_trips() {
1667 let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1668 let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1669 let json = serde_json::to_string(&cfg).unwrap();
1670 let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1671 assert_eq!(cfg2.env, Some(env));
1672 }
1673
1674 #[test]
1675 fn should_strip_env_client_prefixes() {
1676 assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1677 assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1678 assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1679 assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1680 assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1681 assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1683 assert!(!should_strip_env("HOME"));
1684 assert!(!should_strip_env("PATH"));
1685 assert!(!should_strip_env("VIRTUAL_ENV"));
1686 }
1687
1688 #[test]
1689 fn should_strip_env_secrets() {
1690 assert!(should_strip_env("ANTHROPIC_API_KEY"));
1691 assert!(should_strip_env("OPENAI_API_KEY"));
1692 assert!(should_strip_env("my_secret_token"));
1693 assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1694 assert!(should_strip_env("DB_PASSWORD"));
1695 assert!(should_strip_env("GCP_CREDENTIALS"));
1696 assert!(should_strip_env("My_Api_Key"));
1698 assert!(!should_strip_env("MY_VALUE"));
1700 assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1701 assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1703 assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1704 assert!(!should_strip_env("SSH_KEY_PATH"));
1705 assert!(should_strip_env("GH_TOKEN"));
1706 assert!(should_strip_env("SSH_KEY"));
1707 assert!(should_strip_env("SECRET_KEY_BASE"));
1708 assert!(should_strip_env("MY_PASSWD"));
1709 }
1710
1711 #[test]
1712 fn strip_client_env_drops_credential_values_whatever_the_key() {
1713 let vars = vec![
1714 ("HOME".to_string(), "/home/u".to_string()),
1715 (
1716 "DATABASE_URL".to_string(),
1717 "postgres://app:hunter2@db/prod".to_string(),
1718 ),
1719 (
1720 "HTTPS_PROXY".to_string(),
1721 "http://user:pass@proxy:3128".to_string(),
1722 ),
1723 (
1724 "SENTRY_DSN".to_string(),
1725 "https://abc123@o1.ingest.sentry.io/42".to_string(),
1726 ),
1727 ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1728 (
1729 "API_BASE_URL".to_string(),
1730 "https://api.example.com/v1".to_string(),
1731 ),
1732 ];
1733 let (env, stripped) = strip_client_env(vars);
1734 let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1735 assert_eq!(
1736 kept,
1737 vec!["API_BASE_URL", "HOME"],
1738 "only non-secrets survive"
1739 );
1740 assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1741 && !v.contains("user:pass")
1742 && !v.contains("abc123")));
1743 assert_eq!(
1744 stripped,
1745 vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1746 "stripped names are recorded"
1747 );
1748 }
1749
1750 #[test]
1751 fn capture_client_env_is_sorted_and_stripped() {
1752 let (env, stripped) = capture_client_env();
1755 for name in &stripped {
1758 let by_value = std::env::var(name)
1759 .map(|v| agent_core::core::config::is_secret_env_value(&v))
1760 .unwrap_or(false);
1761 assert!(
1762 is_secret_key(name) || by_value,
1763 "stripped non-secret: {name}"
1764 );
1765 assert!(
1766 env.iter().all(|(k, _)| k != name),
1767 "stripped name leaked: {name}"
1768 );
1769 }
1770 for w in env.windows(2) {
1772 assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1773 }
1774 for (k, v) in &env {
1776 assert!(!should_strip_env(k), "should have been stripped: {k}");
1777 assert!(
1778 !agent_core::core::config::is_secret_env_value(v),
1779 "credential value kept: {k}"
1780 );
1781 }
1782 }
1783
1784 #[test]
1785 fn command_json_round_trip() {
1786 let cmd = SessionCommand::Set {
1787 id: 7,
1788 setting: SessionSetting::ReasoningLevel {
1789 level: agent_core::reasoning::ReasoningLevel::Ultra,
1790 },
1791 };
1792 let json = serde_json::to_string(&cmd).unwrap();
1793 assert_eq!(
1794 json,
1795 r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1796 );
1797 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1798 assert!(matches!(
1799 back,
1800 SessionCommand::Set {
1801 id: 7,
1802 setting: SessionSetting::ReasoningLevel {
1803 level: agent_core::reasoning::ReasoningLevel::Ultra
1804 }
1805 }
1806 ));
1807
1808 let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1809 match submit {
1810 SessionCommand::Submit { text, attachments } => {
1811 assert_eq!(text, "hi");
1812 assert!(attachments.is_empty());
1813 }
1814 other => panic!("unexpected {other:?}"),
1815 }
1816
1817 let blocks = vec![
1819 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
1820 serde_json::json!({"type":"text","text":"hello"}),
1821 ];
1822 let submit_with = SessionCommand::Submit {
1823 text: "describe".into(),
1824 attachments: blocks.clone(),
1825 };
1826 let json = serde_json::to_string(&submit_with).unwrap();
1827 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1828 match back {
1829 SessionCommand::Submit { text, attachments } => {
1830 assert_eq!(text, "describe");
1831 assert_eq!(attachments.len(), 2);
1832 assert_eq!(attachments[0]["type"], "image");
1833 assert_eq!(attachments[1]["type"], "text");
1834 }
1835 other => panic!("unexpected {other:?}"),
1836 }
1837 }
1838
1839 #[test]
1840 fn new_commands_round_trip() {
1841 let cmds = vec![
1842 SessionCommand::SubmitPrepared {
1843 messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
1844 user_text: Some("t".into()),
1845 },
1846 SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
1847 SessionCommand::Resume { id: 2, query: "q".into() },
1848 SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
1849 SessionCommand::KeepWarm { on: true },
1850 SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
1851 SessionCommand::Query {
1852 id: 4,
1853 query: SessionQuery::SubagentWatch { handle_id: "sa_2".into(), since: 7, tool_queue: true },
1854 },
1855 SessionCommand::SubagentSteer { handle_id: "sa_2".into(), text: "focus on X".into() },
1856 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
1857 SessionCommand::TakeInput,
1858 ];
1859 for cmd in cmds {
1860 let json = serde_json::to_string(&cmd).unwrap();
1861 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1862 assert_eq!(serde_json::to_string(&back).unwrap(), json);
1863 }
1864 let take: SessionCommand = serde_json::from_str(r#"{"cmd":"take_input"}"#).unwrap();
1865 assert!(matches!(take, SessionCommand::TakeInput));
1866 let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
1867 assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
1868 }
1869
1870 #[test]
1871 fn command_debug_redacts_bodies_without_lengths() {
1872 let secret = "hunter2-very-secret";
1873 let cmds = vec![
1874 SessionCommand::Submit { text: secret.into(), attachments: vec![] },
1875 SessionCommand::Steer { text: secret.into() },
1876 SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: secret.into() },
1877 SessionCommand::SubmitPrepared {
1878 messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
1879 user_text: Some(secret.into()),
1880 },
1881 SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1882 SessionCommand::Resume { id: 1, query: secret.into() },
1883 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
1884 ];
1885 for cmd in cmds {
1886 let d = format!("{cmd:?}");
1887 assert!(!d.contains(secret), "{d}");
1888 assert!(d.contains("<redacted>"), "{d}");
1889 assert!(!d.contains("chars"), "{d}");
1890 assert!(!d.contains(&secret.len().to_string()), "{d}");
1891 }
1892 let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
1893 assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
1894
1895 let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
1897 let cmd = SessionCommand::Submit {
1898 text: "x".into(),
1899 attachments: vec![
1900 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
1901 serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
1902 ],
1903 };
1904 let d = format!("{cmd:?}");
1905 assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
1906 assert!(!d.contains(b64_secret), "base64 leaked: {d}");
1907 }
1908
1909 #[test]
1910 fn session_config_new_fields_default() {
1911 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1912 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
1913 assert!(cfg.await_extensions);
1914 assert!(!cfg.keep_warm);
1915 let cfg: SessionConfig =
1916 serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
1917 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
1918 assert!(!cfg.await_extensions);
1919 }
1920
1921 #[test]
1922 fn session_lifecycle_u8_round_trip() {
1923 for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
1924 assert_eq!(SessionLifecycle::from_u8(l as u8), l);
1925 }
1926 assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
1927 }
1928
1929 #[test]
1930 fn host_event_is_never_serialised() {
1931 let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
1932 crate::extensions::loader::ExtensionLoaderEvent::Started,
1933 ));
1934 assert!(serde_json::to_string(&cmd).is_err());
1935 }
1936}
1937
1938mod double_option {
1942 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
1943
1944 #[derive(Serialize, Deserialize)]
1945 #[serde(untagged)]
1946 enum Wire {
1947 Latest { latest: bool },
1948 Id(String),
1949 }
1950
1951 pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
1952 match v {
1953 None => s.serialize_none(),
1954 Some(None) => Wire::Latest { latest: true }.serialize(s),
1955 Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
1956 }
1957 }
1958
1959 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
1960 match Option::<Wire>::deserialize(d)? {
1961 None => Ok(None),
1962 Some(Wire::Latest { latest: true }) => Ok(Some(None)),
1963 Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
1964 Some(Wire::Id(id)) => Ok(Some(Some(id))),
1965 }
1966 }
1967
1968 #[cfg(test)]
1969 mod tests {
1970 use super::super::SessionConfig;
1971
1972 fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
1973 let cfg = SessionConfig { continue_session: v, ..Default::default() };
1974 let j = serde_json::to_string(&cfg).unwrap();
1975 serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
1976 }
1977
1978 #[test]
1979 fn continue_session_round_trips_all_three_states() {
1980 assert_eq!(rt(None), None);
1981 assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
1982 assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
1983 }
1984
1985 #[test]
1986 fn continue_session_accepts_legacy_encodings() {
1987 let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
1988 assert_eq!(old_null.continue_session, None);
1989 let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
1990 assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
1991 let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
1992 assert_eq!(absent.continue_session, None);
1993 }
1994 }
1995}
1996
1997pub trait PinSink: Send + Sync {
2002 fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&std::path::Path>, cache_keepwarm: Option<&str>);
2006 fn unpinned(&self, id: &str, journal_id: &str);
2008 fn moved(&self, from: &str, to: &str);
2011}