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 #[serde(skip)]
208 pub message_clock: Option<crate::runtime::message_time::MessageClock>,
209}
210
211#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum SessionMemoryMode {
217 Off,
218 RecallEachPrompt,
219 CaptureOnly,
220 CaptureAndRecall,
221}
222
223impl SessionMemoryMode {
224 fn deserialize_lenient<'de, D>(d: D) -> Result<Option<Self>, D::Error>
229 where
230 D: serde::Deserializer<'de>,
231 {
232 let v = serde_json::Value::deserialize(d)?;
233 if v.is_null() {
234 return Ok(None);
235 }
236 Ok(Some(serde_json::from_value(v.clone()).unwrap_or_else(|_| {
237 tracing::warn!(mode = %v, "unknown session memory mode; treating as off");
238 Self::Off
239 })))
240 }
241
242 pub fn from_command_arg(arg: &str) -> Option<Self> {
244 match arg.trim() {
245 "off" => Some(Self::Off),
246 "recall" => Some(Self::RecallEachPrompt),
247 "capture" => Some(Self::CaptureOnly),
248 "on" => Some(Self::CaptureAndRecall),
249 _ => None,
250 }
251 }
252
253 pub fn lease_mode(self) -> Option<crate::runtime::memory_context::MemoryContextMode> {
255 use crate::runtime::memory_context::MemoryContextMode as M;
256 match self {
257 Self::Off => None,
258 Self::RecallEachPrompt => Some(M::RecallEachPrompt),
259 Self::CaptureOnly => Some(M::CaptureOnly),
260 Self::CaptureAndRecall => Some(M::CaptureAndRecall),
261 }
262 }
263}
264
265fn default_true() -> bool {
266 true
267}
268
269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum CompactionPolicyWire {
273 #[default]
274 InPlace,
275 LinkedSuccessor,
276}
277
278impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
279 fn from(p: CompactionPolicyWire) -> Self {
280 match p {
281 CompactionPolicyWire::InPlace => Self::InPlace,
282 CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
283 }
284 }
285}
286
287impl Default for SessionConfig {
288 fn default() -> Self {
289 Self {
290 continue_session: None,
291 system: None,
292 prompt_manifest: None,
293 cwd: None,
294 env: None,
295 env_stripped: Vec::new(),
296 auto_approve_confirms: false,
297 model_override: None,
298 persist: true,
299 auto_compact: false,
300 compaction_policy: CompactionPolicyWire::InPlace,
301 await_extensions: true,
302 keep_warm: false,
303 client_input: None,
304 name: None,
305 max_session_cost: None,
306 memory_mode: None,
307 restore_parked: false,
308 message_clock: None,
309 }
310 }
311}
312
313#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
315pub struct ContinueInfoWire {
316 pub session_id: String,
317 pub resolved_via: Option<String>,
319 pub query: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub compaction_notice: Option<String>,
323}
324
325impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
326 fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
327 Self {
328 session_id: c.session_id.clone(),
329 resolved_via: c.resolved_via.clone(),
330 query: c.query.clone(),
331 compaction_notice: c.compaction_notice.clone(),
332 }
333 }
334}
335
336#[derive(Clone, Debug, Serialize, Deserialize)]
337pub struct SessionMeta {
338 pub id: SessionId,
339 pub name: Option<String>,
340 pub model: String,
341 pub cwd: Option<PathBuf>,
342 pub created_at: chrono::DateTime<chrono::Utc>,
343 pub continued: bool,
344 pub continue_info: Option<ContinueInfoWire>,
345 pub host_pid: u32,
347 #[serde(default)]
349 pub lifecycle: SessionLifecycle,
350 #[serde(default)]
351 pub clients: usize,
352 #[serde(default)]
353 pub input_owner: Option<ClientId>,
354 #[serde(default)]
355 pub awaiting_input: usize,
356 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub roster: Vec<ClientInfo>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub client_input: Option<ClientInput>,
364 #[serde(default)]
366 pub journal_id: String,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub locked_by: Option<String>,
371 #[serde(default)]
375 pub pinned: bool,
376 #[serde(default)]
380 pub busy: bool,
381 #[serde(default)]
384 pub state_since: Option<chrono::DateTime<chrono::Utc>>,
385 #[serde(default)]
388 pub park_due: Option<chrono::DateTime<chrono::Utc>>,
389 #[serde(default)]
391 pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
392 #[serde(default)]
394 pub title: Option<String>,
395 #[serde(default)]
397 pub cost_usd: f64,
398 #[serde(default)]
400 pub messages: usize,
401}
402
403#[repr(u8)]
405#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case")]
407pub enum SessionLifecycle {
408 #[default]
409 Live = 0,
410 Parking = 1,
411 Parked = 2,
412 Ending = 3,
413}
414
415impl SessionLifecycle {
416 pub fn from_u8(v: u8) -> Self {
417 match v {
418 1 => Self::Parking,
419 2 => Self::Parked,
420 3 => Self::Ending,
421 _ => Self::Live,
422 }
423 }
424}
425
426pub use crate::core::config::ClientInput;
427
428#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
430#[serde(transparent)]
431pub struct ClientId(pub u64);
432
433#[derive(Clone, Debug, Serialize, Deserialize)]
434pub struct ClientMeta {
435 pub kind: ClientKind,
436 pub terminal: Option<String>,
438 pub instance: String,
440 #[serde(default)]
442 pub history: HistoryMode,
443 #[serde(default = "default_tail_items")]
445 pub tail_items: usize,
446 #[serde(default, skip_serializing_if = "ClientCapabilities::is_none")]
450 pub capabilities: ClientCapabilities,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub name: Option<String>,
458}
459
460pub const CLIENT_NAME_MAX: usize = 32;
462
463pub fn validate_client_name(name: &str) -> Result<(), String> {
467 let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
468 let ok_rest = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
469 if name.is_empty() || name.len() > CLIENT_NAME_MAX || !ok_first || !ok_rest {
470 return Err(format!(
471 "invalid client name {name:?}: use 1-{CLIENT_NAME_MAX} letters, digits, '-' or '_', starting with a letter or digit"
472 ));
473 }
474 Ok(())
475}
476
477pub fn client_name_from_env() -> Option<String> {
480 std::env::var("SYNAPS_CLIENT_NAME")
481 .ok()
482 .map(|v| v.trim().to_string())
483 .filter(|v| !v.is_empty())
484}
485
486pub fn resolve_client_name<'a>(
491 requested: Option<&str>,
492 kind: ClientKind,
493 taken: impl IntoIterator<Item = &'a str>,
494) -> String {
495 let base = requested
496 .filter(|n| validate_client_name(n).is_ok())
497 .unwrap_or(kind.default_name());
498 let taken: std::collections::HashSet<String> = taken.into_iter().map(str::to_ascii_lowercase).collect();
499 if !taken.contains(&base.to_ascii_lowercase()) {
500 return base.to_string();
501 }
502 (2..)
503 .map(|n| format!("{base}-{n}"))
504 .find(|c| !taken.contains(&c.to_ascii_lowercase()))
505 .expect("an unbounded range always has a free suffix")
506}
507
508#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
511pub struct ClientInfo {
512 pub id: ClientId,
513 pub name: String,
515 pub kind: ClientKind,
516 pub mode: AttachMode,
517 pub attached_at_ms: u64,
519}
520
521impl ClientInfo {
522 pub fn label(&self) -> String {
524 format!("{} (#{})", self.name, self.id.0)
525 }
526}
527
528#[derive(Clone, Debug, Default, PartialEq, Eq)]
532pub struct Roster(Vec<ClientInfo>);
533
534impl Roster {
535 pub fn from_snapshot(snapshot: &AttachSnapshot) -> Self {
536 let mut r = Self(snapshot.roster.clone());
537 if r.0.is_empty() {
539 r.0 = snapshot
540 .clients
541 .iter()
542 .map(|(id, kind)| ClientInfo {
543 id: *id,
544 name: kind.default_name().to_string(),
545 kind: *kind,
546 mode: AttachMode::Mirror,
547 attached_at_ms: 0,
548 })
549 .collect();
550 }
551 r
552 }
553
554 pub fn apply(&mut self, event: &SessionEventWire) {
555 match event {
556 SessionEventWire::ClientJoined { client, kind, name, mode } => {
557 self.0.retain(|c| c.id != *client);
558 self.0.push(ClientInfo {
559 id: *client,
560 name: if name.is_empty() { kind.default_name().to_string() } else { name.clone() },
561 kind: *kind,
562 mode: *mode,
563 attached_at_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
565 });
566 self.0.sort_by_key(|c| c.id.0);
567 }
568 SessionEventWire::ClientLeft { client } => self.0.retain(|c| c.id != *client),
569 _ => {}
570 }
571 }
572
573 pub fn clients(&self) -> &[ClientInfo] {
574 &self.0
575 }
576
577 pub fn get(&self, id: ClientId) -> Option<&ClientInfo> {
578 self.0.iter().find(|c| c.id == id)
579 }
580
581 pub fn label(&self, id: ClientId) -> String {
583 self.get(id).map(ClientInfo::label).unwrap_or_else(|| format!("client #{}", id.0))
584 }
585}
586
587#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
594pub struct ClientCapabilities {
595 #[serde(default)]
597 pub tool_queue: bool,
598 #[serde(default)]
602 pub owner_reason_input: bool,
603}
604
605impl ClientCapabilities {
606 pub fn current() -> Self {
608 Self { tool_queue: true, owner_reason_input: true }
609 }
610
611 fn is_none(&self) -> bool {
612 *self == Self::default()
613 }
614}
615
616pub const DEFAULT_TAIL_ITEMS: usize = 120;
618
619fn default_tail_items() -> usize {
620 DEFAULT_TAIL_ITEMS
621}
622
623pub fn tail_items_from_env() -> usize {
626 std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
627 .ok()
628 .and_then(|v| v.trim().parse::<usize>().ok())
629 .unwrap_or(DEFAULT_TAIL_ITEMS)
630}
631
632impl ClientMeta {
633 pub fn new(kind: ClientKind) -> Self {
635 Self {
636 kind,
637 terminal: None,
638 instance: uuid::Uuid::new_v4().to_string(),
639 history: HistoryMode::default(),
640 tail_items: tail_items_from_env(),
641 capabilities: ClientCapabilities::current(),
642 name: None,
643 }
644 }
645
646 pub fn named(mut self, name: Option<String>) -> Self {
648 self.name = name;
649 self
650 }
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
657#[serde(rename_all = "snake_case")]
658pub enum HistoryMode {
659 #[default]
660 Full,
661 Digest,
662}
663
664impl HistoryMode {
665 pub fn from_env_or(default: Self) -> Self {
668 match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
669 Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
670 Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
671 _ => default,
672 }
673 }
674
675 pub fn attach_client_default() -> Self {
678 Self::Digest
679 }
680}
681
682#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
683#[serde(rename_all = "snake_case")]
684pub enum ClientKind {
685 Tui,
686 Chat,
687 Attach,
688 Rpc,
689 Server,
690 Test,
691}
692
693impl ClientKind {
694 pub fn default_name(self) -> &'static str {
696 match self {
697 Self::Tui => "tui",
698 Self::Chat => "chat",
699 Self::Attach => "attach",
700 Self::Rpc => "rpc",
701 Self::Server => "server",
702 Self::Test => "test",
703 }
704 }
705}
706
707#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(rename_all = "snake_case")]
711pub enum AttachMode {
712 Mirror,
713 Takeover,
714 Observe,
715}
716
717#[derive(Clone, Copy, Debug, PartialEq, Eq)]
719pub enum AttachState {
720 Attached(usize),
721 Detached { running: bool },
722 Parking,
724 Parked,
726}
727
728#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum PromptKind {
733 Secret,
734 Confirm,
735}
736
737pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
740pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
744
745impl PromptKind {
746 pub fn from_title(title: &str) -> Self {
749 if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
750 Self::Confirm
751 } else {
752 Self::Secret
753 }
754 }
755}
756
757#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
762pub struct ConfirmScope {
763 pub extensions: Vec<String>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub tool: Option<String>,
769 #[serde(default, skip_serializing_if = "Vec::is_empty")]
773 pub rewritten_by: Vec<String>,
774}
775
776impl ConfirmScope {
777 pub fn extensions_label(&self) -> String {
779 self.extensions.join(", ")
780 }
781
782 pub fn allow_all_label(&self) -> String {
784 format!("Allow all from {} this session", self.extensions_label())
785 }
786
787 pub fn allow_all_scope(&self) -> String {
789 format!(
790 "Allow all: later confirms from {} (any tool) are approved without asking until \
791 /new or /confirms reset. Other extensions still ask.",
792 self.extensions_label()
793 )
794 }
795}
796
797pub fn sanitize_prompt_text(text: &str) -> String {
808 text.chars()
809 .filter_map(|c| match c {
810 '\n' => Some('\n'),
811 '\t' => Some(' '),
812 c if c.is_control() => None,
813 '\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{2028}' | '\u{2029}' => None,
814 '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => None,
815 c => Some(c),
816 })
817 .collect()
818}
819
820#[derive(Clone, Debug, Serialize, Deserialize)]
821pub struct PromptRequest {
822 pub id: u64,
824 pub kind: PromptKind,
825 pub title: String,
826 pub prompt: String,
827 pub raised_at: chrono::DateTime<chrono::Utc>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub confirm_scope: Option<ConfirmScope>,
832}
833
834#[derive(Debug)]
839pub struct Addressed {
840 pub from: Option<ClientId>,
841 pub cmd: SessionCommand,
842}
843
844#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
846#[serde(rename_all = "snake_case")]
847pub enum CheckpointReason {
848 Reload,
849 HostRequest,
850}
851
852#[derive(Serialize, Deserialize)]
857#[serde(tag = "cmd", rename_all = "snake_case")]
858pub enum SessionCommand {
859 Submit {
862 text: String,
863 #[serde(default)]
867 attachments: Vec<serde_json::Value>,
868 },
869 Steer { text: String },
872 SubagentSteer { handle_id: String, text: String },
877 Cancel,
881 Answer { prompt_id: u64, value: Option<String> },
884 Set { id: u64, setting: SessionSetting },
886 Compact { instructions: Option<String> },
887 NewSession,
889 Save,
890 Query { id: u64, query: SessionQuery },
892 Attach { client: ClientMeta, mode: AttachMode },
894 Detach { client: ClientId },
895 End { reason: EndReason },
897 Resync { client: ClientId, since_seq: u64 },
899 EngineCommand { id: u64, name: String, arg: String },
903 SubmitPrepared {
907 messages: Vec<crate::SharedMessage>,
908 #[serde(default)]
909 user_text: Option<String>,
910 },
911 PluginCommand { id: u64, plugin: String, name: String, arg: String },
914 Resume { id: u64, query: String },
917 Checkpoint { reason: CheckpointReason },
921 KeepWarm { on: bool },
924 TakeInput,
930 #[serde(skip)]
933 Park,
934 #[serde(skip)]
939 AdoptWorkers { workers: Vec<crate::runtime::subagent::CarriedWorker> },
940 #[serde(skip)]
946 RestoreToolActivations {
947 activations: Vec<crate::tools::activation::CarriedActivation>,
948 },
949 #[serde(skip)]
956 ArmPark,
957 #[serde(skip)]
962 ReloadAborted,
963 #[serde(skip)]
965 HostEvent(HostEvent),
966 DriverStart {
971 plugin: String,
972 command: String,
973 arg: String,
974 },
975}
976
977#[derive(Clone, Debug, Serialize, Deserialize)]
982pub struct SessionReloadRecord {
983 pub config: SessionConfig,
984 pub keep_warm: bool,
985 pub lifecycle: SessionLifecycle,
986 #[serde(default)]
987 pub settings_replay: Vec<SessionSetting>,
988 #[serde(default)]
990 pub cache_keepwarm: Option<String>,
991 pub model: String,
992 pub thinking_level: String,
993 #[serde(default)]
996 pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
997 #[serde(default)]
1001 pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
1002}
1003
1004pub const RELOADING_REFUSAL: &str = "daemon reloading; retry in a moment";
1006
1007impl SessionCommand {
1008 pub fn refused_while_reloading(&self) -> bool {
1016 matches!(
1017 self,
1018 Self::Submit { .. }
1019 | Self::SubmitPrepared { .. }
1020 | Self::Steer { .. }
1021 | Self::SubagentSteer { .. }
1022 | Self::Compact { .. }
1023 | Self::NewSession
1024 | Self::Resume { .. }
1025 | Self::EngineCommand { .. }
1026 | Self::PluginCommand { .. }
1027 | Self::DriverStart { .. }
1028 | Self::Set { .. }
1029 | Self::KeepWarm { .. }
1030 )
1031 }
1032}
1033
1034impl std::fmt::Debug for SessionCommand {
1035 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1036 match self {
1037 Self::Submit { attachments, .. } => {
1038 let kinds: Vec<&str> = attachments
1039 .iter()
1040 .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
1041 .collect();
1042 f.debug_struct("Submit")
1043 .field("text", &format_args!("<redacted>"))
1044 .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
1045 .finish()
1046 }
1047 Self::Steer { .. } => f
1048 .debug_struct("Steer")
1049 .field("text", &format_args!("<redacted>"))
1050 .finish(),
1051 Self::SubagentSteer { handle_id, .. } => f
1052 .debug_struct("SubagentSteer")
1053 .field("handle_id", handle_id)
1054 .field("text", &format_args!("<redacted>"))
1055 .finish(),
1056 Self::SubmitPrepared { .. } => f
1057 .debug_struct("SubmitPrepared")
1058 .field("messages", &format_args!("<redacted>"))
1059 .field("user_text", &format_args!("<redacted>"))
1060 .finish(),
1061 Self::Answer { prompt_id, .. } => f
1062 .debug_struct("Answer")
1063 .field("prompt_id", prompt_id)
1064 .field("value", &format_args!("<redacted>"))
1065 .finish(),
1066 Self::Resume { id, .. } => f
1067 .debug_struct("Resume")
1068 .field("id", id)
1069 .field("query", &format_args!("<redacted>"))
1070 .finish(),
1071 Self::Cancel => f.write_str("Cancel"),
1072 Self::Set { id, setting } => {
1073 f.debug_struct("Set").field("id", id).field("setting", setting).finish()
1074 }
1075 Self::Compact { instructions } => {
1076 f.debug_struct("Compact").field("instructions", instructions).finish()
1077 }
1078 Self::NewSession => f.write_str("NewSession"),
1079 Self::Save => f.write_str("Save"),
1080 Self::TakeInput => f.write_str("TakeInput"),
1081 Self::Park => f.write_str("Park"),
1082 Self::AdoptWorkers { workers } => f
1083 .debug_struct("AdoptWorkers")
1084 .field("workers", &workers.iter().map(|w| w.id.as_str()).collect::<Vec<_>>())
1085 .finish(),
1086 Self::RestoreToolActivations { activations } => f
1087 .debug_struct("RestoreToolActivations")
1088 .field(
1089 "tools",
1090 &activations.iter().map(|a| a.tool_id.as_str()).collect::<Vec<_>>(),
1091 )
1092 .finish(),
1093 Self::ReloadAborted => f.write_str("ReloadAborted"),
1094 Self::ArmPark => f.write_str("ArmPark"),
1095 Self::Query { id, query } => {
1096 f.debug_struct("Query").field("id", id).field("query", query).finish()
1097 }
1098 Self::Attach { client, mode } => {
1099 f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
1100 }
1101 Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
1102 Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
1103 Self::Resync { client, since_seq } => f
1104 .debug_struct("Resync")
1105 .field("client", client)
1106 .field("since_seq", since_seq)
1107 .finish(),
1108 Self::EngineCommand { id, name, arg } => f
1109 .debug_struct("EngineCommand")
1110 .field("id", id)
1111 .field("name", name)
1112 .field("arg", arg)
1113 .finish(),
1114 Self::PluginCommand { id, plugin, name, arg } => f
1115 .debug_struct("PluginCommand")
1116 .field("id", id)
1117 .field("plugin", plugin)
1118 .field("name", name)
1119 .field("arg", arg)
1120 .finish(),
1121 Self::Checkpoint { reason } => {
1122 f.debug_struct("Checkpoint").field("reason", reason).finish()
1123 }
1124 Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
1125 Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
1126 Self::DriverStart { plugin, command, .. } => f
1127 .debug_struct("DriverStart")
1128 .field("plugin", plugin)
1129 .field("command", command)
1130 .field("arg", &format_args!("<redacted>"))
1131 .finish(),
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone)]
1138pub enum HostEvent {
1139 ExtensionNotification {
1140 extension_id: String,
1141 method: String,
1142 params: serde_json::Value,
1143 },
1144 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149#[serde(tag = "setting", rename_all = "snake_case")]
1150pub enum SessionSetting {
1151 Model { model: String },
1153 ReasoningLevel {
1155 #[serde(with = "reasoning_level_serde")]
1156 level: agent_core::reasoning::ReasoningLevel,
1157 },
1158 ContextWindow { tokens: Option<u64> },
1160 CompactionModel { model: Option<String> },
1161 ApiRetries { n: u32 },
1162 SubagentTimeout { secs: u64 },
1163 MaxToolOutput { bytes: usize },
1164 BashTimeout { secs: u64 },
1165 BashMaxTimeout { secs: u64 },
1166 SystemPrompt { text: String },
1168 ReloadPrompt,
1170 GrantWorkerModel { model: String },
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct SettingApplied {
1176 #[serde(default)]
1178 pub id: u64,
1179 pub setting: String,
1180 pub ok: bool,
1181 pub message: Option<String>,
1182 pub view: RuntimeView,
1183 #[serde(default)]
1186 pub clamp: Option<ReasoningClampWire>,
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1190pub struct ReasoningClampWire {
1191 pub from: String,
1192 pub to: String,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1196#[serde(tag = "query", rename_all = "snake_case")]
1197pub enum SessionQuery {
1198 Status,
1200 Messages,
1202 SubagentRows,
1204 PromptInspection,
1206 ToolsSchema,
1208 View,
1210 ContextAssessment,
1212 ContextReport,
1214 DisplayTail { items: usize },
1216 SubagentWatch {
1223 handle_id: String,
1224 since: u64,
1225 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1226 tool_queue: bool,
1227 },
1228}
1229
1230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1231#[serde(rename_all = "snake_case")]
1232pub enum EndReason {
1233 ClientQuit,
1234 HostShutdown,
1235 Evicted,
1236 Error,
1237 Idle,
1241}
1242
1243#[derive(Debug, Clone)]
1249#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
1251 Stream(crate::StreamEvent),
1252 TurnStarted {
1259 turn_baseline: usize,
1260 trigger: TurnTrigger,
1261 user_text: Option<String>,
1262 },
1263 Conversation(ConversationSnapshot),
1266 Prompt(PromptRequest),
1267 PromptResolved { prompt_id: u64 },
1269 External(crate::events::types::Event),
1271 AutoTurnCapReached { cap: u32 },
1272 Idle,
1276 Steered { text: String, delivered: bool },
1278 Dequeued { text: String },
1279 SystemNotice(String),
1281 LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
1282 ExtensionNotification {
1283 extension_id: String,
1284 method: String,
1285 params: serde_json::Value,
1286 },
1287 SettingChanged(SettingApplied),
1288 QueryResult { id: u64, value: serde_json::Value },
1289 Attached {
1291 client: ClientId,
1292 snapshot: AttachSnapshot,
1293 },
1294 ClientJoined { client: ClientId, kind: ClientKind, name: String, mode: AttachMode },
1296 ClientLeft { client: ClientId },
1297 Ended { reason: EndReason },
1298 Aborted { context_saved: bool },
1302 Cleared { session_id: String },
1304 CompactionStarted { source: String, disclosure: String },
1306 CompactionApplied {
1307 previous_session_id: String,
1308 session_id: String,
1309 chains_advanced: Vec<String>,
1310 queued_restored: Option<String>,
1311 msg_count: usize,
1312 },
1313 CompactionFailed { message: String, panicked: bool },
1315 CompactionCancelled,
1316 SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
1319 Resumed {
1321 id: u64,
1322 old_id: String,
1323 new_id: String,
1324 via: Option<String>,
1325 clamp_notice: Option<String>,
1326 },
1327 InputOwnerChanged {
1330 from: Option<ClientId>,
1331 to: Option<ClientId>,
1332 reason: OwnerChangeReason,
1333 },
1334 Refused { client: ClientId, command: String, reason: String },
1336 AttachRefused { message: String },
1338 Lifecycle(SessionLifecycle),
1340 Reloading { generation: u64, retry_after_ms: u64 },
1342 DriverArmed {
1344 plugin_id: String,
1345 run_id: String,
1346 models: Vec<crate::extensions::session_driver::Selection>,
1347 selection: crate::extensions::session_driver::Selection,
1348 deadline_ms: Option<u64>,
1350 notice: String,
1351 },
1352 DriverRevoked {
1354 reason: String,
1355 undelivered_steering: Vec<String>,
1356 },
1357 DriverTurnOutcome {
1359 outcome: crate::extensions::session_driver::Outcome,
1360 selection: crate::extensions::session_driver::Selection,
1361 feedback: Option<String>,
1362 },
1363 CostCapReached {
1368 scope: String,
1369 cost: f64,
1370 cap: f64,
1371 },
1372}
1373
1374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1375#[serde(rename_all = "snake_case")]
1376pub enum OwnerChangeReason {
1377 Attach,
1378 Takeover,
1379 OwnerDetached,
1380 Reload,
1381 Input,
1386}
1387
1388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1389#[serde(rename_all = "snake_case")]
1390pub enum TurnTrigger {
1391 User,
1392 QueuedAuto,
1393 EventAuto,
1394 PluginCommand,
1395 Compaction,
1396 DriverAuto,
1398}
1399
1400#[derive(Debug, Clone)]
1403pub struct Envelope {
1404 pub session_id: SessionId,
1405 pub seq: u64,
1406 pub ts: chrono::DateTime<chrono::Utc>,
1407 pub event: SessionEventWire,
1408}
1409
1410#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1411pub struct ConversationTokens {
1412 pub input: u64,
1413 pub output: u64,
1414 pub cache_read: u64,
1415 pub cache_creation: u64,
1416}
1417
1418#[derive(Clone, Default)]
1425pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
1426
1427type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
1429
1430impl MessagesHashMemo {
1431 pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
1432 let mut memo = self
1433 .0
1434 .lock()
1435 .unwrap_or_else(std::sync::PoisonError::into_inner);
1436 if let Some((len, last, hash)) = memo.as_ref() {
1437 let same_last = match (last, messages.last()) {
1438 (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
1439 (None, None) => true,
1440 _ => false,
1441 };
1442 if *len == messages.len() && same_last {
1443 return *hash;
1444 }
1445 }
1446 let hash = super::wire::messages_hash(messages);
1447 *memo = Some((messages.len(), messages.last().cloned(), hash));
1448 hash
1449 }
1450}
1451
1452impl std::fmt::Debug for MessagesHashMemo {
1453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1454 f.write_str("MessagesHashMemo")
1455 }
1456}
1457
1458#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1461pub struct ConversationSnapshot {
1462 #[serde(default)]
1464 pub header: SessionHeader,
1465 pub api_messages: Vec<crate::SharedMessage>,
1466 #[serde(default)]
1469 pub messages_len: usize,
1470 pub tokens: ConversationTokens,
1471 pub cost: f64,
1472 #[serde(default)]
1474 pub keepwarm_cost: f64,
1475 #[serde(default)]
1477 pub keepwarm_tokens: ConversationTokens,
1478 pub abort_context: Option<String>,
1481 pub queued_message: Option<String>,
1482 pub pending_events_len: usize,
1483 pub consecutive_auto_turns: u32,
1484 #[serde(skip)]
1486 pub messages_hash_memo: MessagesHashMemo,
1487}
1488
1489#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1491pub struct SessionHeader {
1492 pub id: String,
1493 pub title: String,
1494 pub name: Option<String>,
1495 pub model: String,
1496 pub thinking_level: String,
1497 pub system_prompt: Option<String>,
1498 pub created_at: chrono::DateTime<chrono::Utc>,
1499 pub updated_at: chrono::DateTime<chrono::Utc>,
1500 pub parent_session: Option<String>,
1501}
1502
1503impl From<&crate::Session> for SessionHeader {
1504 fn from(s: &crate::Session) -> Self {
1505 Self {
1506 id: s.id.clone(),
1507 title: s.title.clone(),
1508 name: s.name.clone(),
1509 model: s.model.clone(),
1510 thinking_level: s.thinking_level.clone(),
1511 system_prompt: s.system_prompt.clone(),
1512 created_at: s.created_at,
1513 updated_at: s.updated_at,
1514 parent_session: s.parent_session.clone(),
1515 }
1516 }
1517}
1518
1519#[derive(Debug, Clone)]
1520pub struct AttachSnapshot {
1521 pub meta: SessionMeta,
1522 pub view: RuntimeView,
1523 pub conversation: ConversationSnapshot,
1524 pub streaming: bool,
1525 pub replay: Vec<Envelope>,
1528 pub pending_prompts: Vec<PromptRequest>,
1529 pub clients: Vec<(ClientId, ClientKind)>,
1530 pub roster: Vec<ClientInfo>,
1533 pub client_input: ClientInput,
1535 pub input_owner: Option<ClientId>,
1538 pub display_tail: Option<crate::session::display::DisplayTail>,
1541}
1542
1543impl AttachSnapshot {
1544 pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1552 if self.client_input == ClientInput::Shared {
1553 return None;
1554 }
1555 let owner = self.input_owner.filter(|o| *o != me)?;
1556 let who = Roster::from_snapshot(self).label(owner);
1557 Some(format!("input is owned by {who}; attach with --takeover to steal it"))
1558 }
1559}
1560
1561pub mod reasoning_level_serde {
1564 use agent_core::reasoning::ReasoningLevel;
1565 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1566
1567 pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1568 level.as_str().serialize(s)
1569 }
1570
1571 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1572 let s = String::deserialize(d)?;
1573 ReasoningLevel::parse(&s)
1574 .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1575 }
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580 use super::*;
1581
1582 #[test]
1583 fn client_names_are_short_plain_labels() {
1584 for ok in ["tui", "discord", "Dash_2", "a", "9lives", &"x".repeat(CLIENT_NAME_MAX)] {
1585 assert_eq!(validate_client_name(ok), Ok(()), "{ok}");
1586 }
1587 for bad in ["", "-lead", "_lead", "has space", "dots.no", "émoji", &"x".repeat(CLIENT_NAME_MAX + 1)] {
1588 let err = validate_client_name(bad).unwrap_err();
1589 assert!(err.starts_with("invalid client name"), "{bad}: {err}");
1590 }
1591 }
1592
1593 #[test]
1594 fn a_session_resolves_names_to_unique_ones() {
1595 assert_eq!(resolve_client_name(Some("discord"), ClientKind::Rpc, []), "discord");
1597 assert_eq!(resolve_client_name(None, ClientKind::Tui, []), "tui");
1598 assert_eq!(resolve_client_name(Some("no spaces"), ClientKind::Attach, []), "attach");
1599 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui"]), "tui-2");
1601 assert_eq!(resolve_client_name(Some("TUI"), ClientKind::Tui, ["tui", "tui-2"]), "TUI-3");
1602 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui", "tui-3"]), "tui-2");
1603 assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui-2"]), "tui");
1605 }
1606
1607 #[test]
1608 fn a_roster_follows_joins_and_leaves_and_labels_by_name() {
1609 let joined = |id: u64, name: &str, mode: AttachMode| SessionEventWire::ClientJoined {
1610 client: ClientId(id),
1611 kind: ClientKind::Attach,
1612 name: name.to_string(),
1613 mode,
1614 };
1615 let mut r = Roster::default();
1616 r.apply(&joined(2, "dash", AttachMode::Observe));
1617 r.apply(&joined(1, "terminal", AttachMode::Mirror));
1618 r.apply(&joined(3, "", AttachMode::Mirror));
1620 let ids: Vec<u64> = r.clients().iter().map(|c| c.id.0).collect();
1621 assert_eq!(ids, [1, 2, 3], "kept in id order");
1622 assert_eq!(r.get(ClientId(2)).unwrap().mode, AttachMode::Observe);
1623 assert_eq!(r.label(ClientId(1)), "terminal (#1)");
1624 assert_eq!(r.label(ClientId(3)), "attach (#3)");
1625 r.apply(&SessionEventWire::ClientLeft { client: ClientId(1) });
1626 assert_eq!(r.label(ClientId(1)), "client #1", "gone: id only");
1627 assert_eq!(r.clients().len(), 2);
1628 }
1629
1630 #[test]
1634 fn sanitize_prompt_text_strips_separators_keeps_zwj_zwnj() {
1635 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";
1636 assert_eq!(sanitize_prompt_text(input), "abcdefghij k\nl");
1637 let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
1638 assert_eq!(sanitize_prompt_text(family), family, "ZWJ emoji intact");
1639 let persian = "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}";
1640 assert_eq!(sanitize_prompt_text(persian), persian, "ZWNJ intact");
1641 }
1642
1643 #[test]
1644 fn prompt_kind_from_title() {
1645 assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1646 assert_eq!(
1647 PromptKind::from_title("Confirm tool activation"),
1648 PromptKind::Confirm
1649 );
1650 assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1651 }
1652
1653 #[test]
1654 fn session_config_default_persists() {
1655 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1656 assert!(cfg.persist);
1657 assert!(cfg.cwd.is_none());
1658 assert_eq!(
1659 serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1660 serde_json::Value::Bool(true)
1661 );
1662 }
1663
1664 #[test]
1665 fn session_config_env_defaults_to_none() {
1666 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1667 assert!(cfg.env.is_none());
1668 }
1669
1670 #[test]
1671 fn session_config_env_round_trips() {
1672 let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1673 let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1674 let json = serde_json::to_string(&cfg).unwrap();
1675 let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1676 assert_eq!(cfg2.env, Some(env));
1677 }
1678
1679 #[test]
1680 fn should_strip_env_client_prefixes() {
1681 assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1682 assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1683 assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1684 assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1685 assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1686 assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1688 assert!(!should_strip_env("HOME"));
1689 assert!(!should_strip_env("PATH"));
1690 assert!(!should_strip_env("VIRTUAL_ENV"));
1691 }
1692
1693 #[test]
1694 fn should_strip_env_secrets() {
1695 assert!(should_strip_env("ANTHROPIC_API_KEY"));
1696 assert!(should_strip_env("OPENAI_API_KEY"));
1697 assert!(should_strip_env("my_secret_token"));
1698 assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1699 assert!(should_strip_env("DB_PASSWORD"));
1700 assert!(should_strip_env("GCP_CREDENTIALS"));
1701 assert!(should_strip_env("My_Api_Key"));
1703 assert!(!should_strip_env("MY_VALUE"));
1705 assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1706 assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1708 assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1709 assert!(!should_strip_env("SSH_KEY_PATH"));
1710 assert!(should_strip_env("GH_TOKEN"));
1711 assert!(should_strip_env("SSH_KEY"));
1712 assert!(should_strip_env("SECRET_KEY_BASE"));
1713 assert!(should_strip_env("MY_PASSWD"));
1714 }
1715
1716 #[test]
1717 fn strip_client_env_drops_credential_values_whatever_the_key() {
1718 let vars = vec![
1719 ("HOME".to_string(), "/home/u".to_string()),
1720 (
1721 "DATABASE_URL".to_string(),
1722 "postgres://app:hunter2@db/prod".to_string(),
1723 ),
1724 (
1725 "HTTPS_PROXY".to_string(),
1726 "http://user:pass@proxy:3128".to_string(),
1727 ),
1728 (
1729 "SENTRY_DSN".to_string(),
1730 "https://abc123@o1.ingest.sentry.io/42".to_string(),
1731 ),
1732 ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1733 (
1734 "API_BASE_URL".to_string(),
1735 "https://api.example.com/v1".to_string(),
1736 ),
1737 ];
1738 let (env, stripped) = strip_client_env(vars);
1739 let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1740 assert_eq!(
1741 kept,
1742 vec!["API_BASE_URL", "HOME"],
1743 "only non-secrets survive"
1744 );
1745 assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1746 && !v.contains("user:pass")
1747 && !v.contains("abc123")));
1748 assert_eq!(
1749 stripped,
1750 vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1751 "stripped names are recorded"
1752 );
1753 }
1754
1755 #[test]
1756 fn capture_client_env_is_sorted_and_stripped() {
1757 let (env, stripped) = capture_client_env();
1760 for name in &stripped {
1763 let by_value = std::env::var(name)
1764 .map(|v| agent_core::core::config::is_secret_env_value(&v))
1765 .unwrap_or(false);
1766 assert!(
1767 is_secret_key(name) || by_value,
1768 "stripped non-secret: {name}"
1769 );
1770 assert!(
1771 env.iter().all(|(k, _)| k != name),
1772 "stripped name leaked: {name}"
1773 );
1774 }
1775 for w in env.windows(2) {
1777 assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1778 }
1779 for (k, v) in &env {
1781 assert!(!should_strip_env(k), "should have been stripped: {k}");
1782 assert!(
1783 !agent_core::core::config::is_secret_env_value(v),
1784 "credential value kept: {k}"
1785 );
1786 }
1787 }
1788
1789 #[test]
1790 fn command_json_round_trip() {
1791 let cmd = SessionCommand::Set {
1792 id: 7,
1793 setting: SessionSetting::ReasoningLevel {
1794 level: agent_core::reasoning::ReasoningLevel::Ultra,
1795 },
1796 };
1797 let json = serde_json::to_string(&cmd).unwrap();
1798 assert_eq!(
1799 json,
1800 r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1801 );
1802 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1803 assert!(matches!(
1804 back,
1805 SessionCommand::Set {
1806 id: 7,
1807 setting: SessionSetting::ReasoningLevel {
1808 level: agent_core::reasoning::ReasoningLevel::Ultra
1809 }
1810 }
1811 ));
1812
1813 let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1814 match submit {
1815 SessionCommand::Submit { text, attachments } => {
1816 assert_eq!(text, "hi");
1817 assert!(attachments.is_empty());
1818 }
1819 other => panic!("unexpected {other:?}"),
1820 }
1821
1822 let blocks = vec![
1824 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
1825 serde_json::json!({"type":"text","text":"hello"}),
1826 ];
1827 let submit_with = SessionCommand::Submit {
1828 text: "describe".into(),
1829 attachments: blocks.clone(),
1830 };
1831 let json = serde_json::to_string(&submit_with).unwrap();
1832 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1833 match back {
1834 SessionCommand::Submit { text, attachments } => {
1835 assert_eq!(text, "describe");
1836 assert_eq!(attachments.len(), 2);
1837 assert_eq!(attachments[0]["type"], "image");
1838 assert_eq!(attachments[1]["type"], "text");
1839 }
1840 other => panic!("unexpected {other:?}"),
1841 }
1842 }
1843
1844 #[test]
1845 fn new_commands_round_trip() {
1846 let cmds = vec![
1847 SessionCommand::SubmitPrepared {
1848 messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
1849 user_text: Some("t".into()),
1850 },
1851 SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
1852 SessionCommand::Resume { id: 2, query: "q".into() },
1853 SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
1854 SessionCommand::KeepWarm { on: true },
1855 SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
1856 SessionCommand::Query {
1857 id: 4,
1858 query: SessionQuery::SubagentWatch { handle_id: "sa_2".into(), since: 7, tool_queue: true },
1859 },
1860 SessionCommand::SubagentSteer { handle_id: "sa_2".into(), text: "focus on X".into() },
1861 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
1862 SessionCommand::TakeInput,
1863 ];
1864 for cmd in cmds {
1865 let json = serde_json::to_string(&cmd).unwrap();
1866 let back: SessionCommand = serde_json::from_str(&json).unwrap();
1867 assert_eq!(serde_json::to_string(&back).unwrap(), json);
1868 }
1869 let take: SessionCommand = serde_json::from_str(r#"{"cmd":"take_input"}"#).unwrap();
1870 assert!(matches!(take, SessionCommand::TakeInput));
1871 let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
1872 assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
1873 }
1874
1875 #[test]
1876 fn command_debug_redacts_bodies_without_lengths() {
1877 let secret = "hunter2-very-secret";
1878 let cmds = vec![
1879 SessionCommand::Submit { text: secret.into(), attachments: vec![] },
1880 SessionCommand::Steer { text: secret.into() },
1881 SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: secret.into() },
1882 SessionCommand::SubmitPrepared {
1883 messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
1884 user_text: Some(secret.into()),
1885 },
1886 SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1887 SessionCommand::Resume { id: 1, query: secret.into() },
1888 SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
1889 ];
1890 for cmd in cmds {
1891 let d = format!("{cmd:?}");
1892 assert!(!d.contains(secret), "{d}");
1893 assert!(d.contains("<redacted>"), "{d}");
1894 assert!(!d.contains("chars"), "{d}");
1895 assert!(!d.contains(&secret.len().to_string()), "{d}");
1896 }
1897 let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
1898 assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
1899
1900 let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
1902 let cmd = SessionCommand::Submit {
1903 text: "x".into(),
1904 attachments: vec![
1905 serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
1906 serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
1907 ],
1908 };
1909 let d = format!("{cmd:?}");
1910 assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
1911 assert!(!d.contains(b64_secret), "base64 leaked: {d}");
1912 }
1913
1914 #[test]
1915 fn session_config_new_fields_default() {
1916 let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1917 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
1918 assert!(cfg.await_extensions);
1919 assert!(!cfg.keep_warm);
1920 let cfg: SessionConfig =
1921 serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
1922 assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
1923 assert!(!cfg.await_extensions);
1924 }
1925
1926 #[test]
1927 fn session_lifecycle_u8_round_trip() {
1928 for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
1929 assert_eq!(SessionLifecycle::from_u8(l as u8), l);
1930 }
1931 assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
1932 }
1933
1934 #[test]
1935 fn host_event_is_never_serialised() {
1936 let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
1937 crate::extensions::loader::ExtensionLoaderEvent::Started,
1938 ));
1939 assert!(serde_json::to_string(&cmd).is_err());
1940 }
1941}
1942
1943mod double_option {
1947 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
1948
1949 #[derive(Serialize, Deserialize)]
1950 #[serde(untagged)]
1951 enum Wire {
1952 Latest { latest: bool },
1953 Id(String),
1954 }
1955
1956 pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
1957 match v {
1958 None => s.serialize_none(),
1959 Some(None) => Wire::Latest { latest: true }.serialize(s),
1960 Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
1961 }
1962 }
1963
1964 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
1965 match Option::<Wire>::deserialize(d)? {
1966 None => Ok(None),
1967 Some(Wire::Latest { latest: true }) => Ok(Some(None)),
1968 Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
1969 Some(Wire::Id(id)) => Ok(Some(Some(id))),
1970 }
1971 }
1972
1973 #[cfg(test)]
1974 mod tests {
1975 use super::super::SessionConfig;
1976
1977 fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
1978 let cfg = SessionConfig { continue_session: v, ..Default::default() };
1979 let j = serde_json::to_string(&cfg).unwrap();
1980 serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
1981 }
1982
1983 #[test]
1984 fn continue_session_round_trips_all_three_states() {
1985 assert_eq!(rt(None), None);
1986 assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
1987 assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
1988 }
1989
1990 #[test]
1991 fn continue_session_accepts_legacy_encodings() {
1992 let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
1993 assert_eq!(old_null.continue_session, None);
1994 let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
1995 assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
1996 let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
1997 assert_eq!(absent.continue_session, None);
1998 }
1999 }
2000}
2001
2002pub trait PinSink: Send + Sync {
2007 fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&std::path::Path>, cache_keepwarm: Option<&str>);
2011 fn unpinned(&self, id: &str, journal_id: &str);
2013 fn moved(&self, from: &str, to: &str);
2016 fn renamed(&self, journal_id: &str, name: Option<&str>);
2019}