use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::view::RuntimeView;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub String);
impl SessionId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for SessionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
pub type SessionEnv = Vec<(String, String)>;
const ENV_STRIP_PREFIXES: &[&str] = &[
"SYNAPS_CLIENT_",
"SYNAPS_TUI_",
"SYNAPS_DAEMON_",
"SYNAPS_MEM_TRACE",
];
pub fn capture_client_env() -> (SessionEnv, Vec<String>) {
strip_client_env(std::env::vars())
}
pub fn strip_client_env(
vars: impl IntoIterator<Item = (String, String)>,
) -> (SessionEnv, Vec<String>) {
let mut env: SessionEnv = Vec::new();
let mut stripped: Vec<String> = Vec::new();
for (k, v) in vars {
let secret_value = agent_core::core::config::is_secret_env_value(&v);
if should_strip_env(&k) || secret_value {
if is_secret_key(&k) || secret_value {
stripped.push(k);
}
} else {
env.push((k, v));
}
}
env.sort_by(|(a, _), (b, _)| a.cmp(b));
stripped.sort();
(env, stripped)
}
pub fn should_strip_env(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
for prefix in ENV_STRIP_PREFIXES {
if upper.starts_with(prefix) {
return true;
}
}
is_secret_key(&upper)
}
pub fn is_secret_key(key: &str) -> bool {
agent_core::core::config::is_secret_key(key)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionConfig {
#[serde(default, with = "double_option", skip_serializing_if = "Option::is_none")]
pub continue_session: Option<Option<String>>,
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub prompt_manifest: Option<PathBuf>,
#[serde(default)]
pub cwd: Option<PathBuf>,
#[serde(default)]
pub env: Option<SessionEnv>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env_stripped: Vec<String>,
#[serde(default)]
pub auto_approve_confirms: bool,
#[serde(default)]
pub model_override: Option<String>,
#[serde(default = "default_true")]
pub persist: bool,
#[serde(default)]
pub auto_compact: bool,
#[serde(default)]
pub compaction_policy: CompactionPolicyWire,
#[serde(default = "default_true")]
pub await_extensions: bool,
#[serde(default)]
pub keep_warm: bool,
#[serde(default)]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_session_cost: Option<f64>,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompactionPolicyWire {
#[default]
InPlace,
LinkedSuccessor,
}
impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
fn from(p: CompactionPolicyWire) -> Self {
match p {
CompactionPolicyWire::InPlace => Self::InPlace,
CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
}
}
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
continue_session: None,
system: None,
prompt_manifest: None,
cwd: None,
env: None,
env_stripped: Vec::new(),
auto_approve_confirms: false,
model_override: None,
persist: true,
auto_compact: false,
compaction_policy: CompactionPolicyWire::InPlace,
await_extensions: true,
keep_warm: false,
name: None,
max_session_cost: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContinueInfoWire {
pub session_id: String,
pub resolved_via: Option<String>,
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction_notice: Option<String>,
}
impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
Self {
session_id: c.session_id.clone(),
resolved_via: c.resolved_via.clone(),
query: c.query.clone(),
compaction_notice: c.compaction_notice.clone(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionMeta {
pub id: SessionId,
pub name: Option<String>,
pub model: String,
pub cwd: Option<PathBuf>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub continued: bool,
pub continue_info: Option<ContinueInfoWire>,
pub host_pid: u32,
#[serde(default)]
pub lifecycle: SessionLifecycle,
#[serde(default)]
pub clients: usize,
#[serde(default)]
pub input_owner: Option<ClientId>,
#[serde(default)]
pub awaiting_input: usize,
#[serde(default)]
pub journal_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locked_by: Option<String>,
}
#[repr(u8)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionLifecycle {
#[default]
Live = 0,
Parking = 1,
Parked = 2,
Ending = 3,
}
impl SessionLifecycle {
pub fn from_u8(v: u8) -> Self {
match v {
1 => Self::Parking,
2 => Self::Parked,
3 => Self::Ending,
_ => Self::Live,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClientId(pub u64);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ClientMeta {
pub kind: ClientKind,
pub terminal: Option<String>,
pub instance: String,
#[serde(default)]
pub history: HistoryMode,
#[serde(default = "default_tail_items")]
pub tail_items: usize,
}
pub const DEFAULT_TAIL_ITEMS: usize = 120;
fn default_tail_items() -> usize {
DEFAULT_TAIL_ITEMS
}
pub fn tail_items_from_env() -> usize {
std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_TAIL_ITEMS)
}
impl ClientMeta {
pub fn new(kind: ClientKind) -> Self {
Self {
kind,
terminal: None,
instance: uuid::Uuid::new_v4().to_string(),
history: HistoryMode::default(),
tail_items: tail_items_from_env(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HistoryMode {
#[default]
Full,
Digest,
}
impl HistoryMode {
pub fn from_env_or(default: Self) -> Self {
match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
_ => default,
}
}
pub fn attach_client_default() -> Self {
Self::Digest
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientKind {
Tui,
Chat,
Attach,
Rpc,
Server,
Test,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttachMode {
Mirror,
Takeover,
Observe,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttachState {
Attached(usize),
Detached { running: bool },
Parking,
Parked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptKind {
Secret,
Confirm,
}
pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
impl PromptKind {
pub fn from_title(title: &str) -> Self {
if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
Self::Confirm
} else {
Self::Secret
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PromptRequest {
pub id: u64,
pub kind: PromptKind,
pub title: String,
pub prompt: String,
pub raised_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug)]
pub struct Addressed {
pub from: Option<ClientId>,
pub cmd: SessionCommand,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckpointReason {
Reload,
HostRequest,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum SessionCommand {
Submit {
text: String,
#[serde(default)]
attachments: Vec<serde_json::Value>,
},
Steer { text: String },
Cancel,
Answer { prompt_id: u64, value: Option<String> },
Set { id: u64, setting: SessionSetting },
Compact { instructions: Option<String> },
NewSession,
Save,
Query { id: u64, query: SessionQuery },
Attach { client: ClientMeta, mode: AttachMode },
Detach { client: ClientId },
End { reason: EndReason },
Resync { client: ClientId, since_seq: u64 },
EngineCommand { id: u64, name: String, arg: String },
SubmitPrepared {
messages: Vec<crate::SharedMessage>,
#[serde(default)]
user_text: Option<String>,
},
PluginCommand { id: u64, plugin: String, name: String, arg: String },
Resume { id: u64, query: String },
Checkpoint { reason: CheckpointReason },
KeepWarm { on: bool },
#[serde(skip)]
Park,
#[serde(skip)]
HostEvent(HostEvent),
DriverStart {
plugin: String,
command: String,
arg: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionReloadRecord {
pub config: SessionConfig,
pub keep_warm: bool,
pub lifecycle: SessionLifecycle,
#[serde(default)]
pub settings_replay: Vec<SessionSetting>,
pub model: String,
pub thinking_level: String,
}
impl std::fmt::Debug for SessionCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Submit { attachments, .. } => {
let kinds: Vec<&str> = attachments
.iter()
.filter_map(|b| b.get("type").and_then(|v| v.as_str()))
.collect();
f.debug_struct("Submit")
.field("text", &format_args!("<redacted>"))
.field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
.finish()
}
Self::Steer { .. } => f
.debug_struct("Steer")
.field("text", &format_args!("<redacted>"))
.finish(),
Self::SubmitPrepared { .. } => f
.debug_struct("SubmitPrepared")
.field("messages", &format_args!("<redacted>"))
.field("user_text", &format_args!("<redacted>"))
.finish(),
Self::Answer { prompt_id, .. } => f
.debug_struct("Answer")
.field("prompt_id", prompt_id)
.field("value", &format_args!("<redacted>"))
.finish(),
Self::Resume { id, .. } => f
.debug_struct("Resume")
.field("id", id)
.field("query", &format_args!("<redacted>"))
.finish(),
Self::Cancel => f.write_str("Cancel"),
Self::Set { id, setting } => {
f.debug_struct("Set").field("id", id).field("setting", setting).finish()
}
Self::Compact { instructions } => {
f.debug_struct("Compact").field("instructions", instructions).finish()
}
Self::NewSession => f.write_str("NewSession"),
Self::Save => f.write_str("Save"),
Self::Park => f.write_str("Park"),
Self::Query { id, query } => {
f.debug_struct("Query").field("id", id).field("query", query).finish()
}
Self::Attach { client, mode } => {
f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
}
Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
Self::Resync { client, since_seq } => f
.debug_struct("Resync")
.field("client", client)
.field("since_seq", since_seq)
.finish(),
Self::EngineCommand { id, name, arg } => f
.debug_struct("EngineCommand")
.field("id", id)
.field("name", name)
.field("arg", arg)
.finish(),
Self::PluginCommand { id, plugin, name, arg } => f
.debug_struct("PluginCommand")
.field("id", id)
.field("plugin", plugin)
.field("name", name)
.field("arg", arg)
.finish(),
Self::Checkpoint { reason } => {
f.debug_struct("Checkpoint").field("reason", reason).finish()
}
Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
Self::DriverStart { plugin, command, .. } => f
.debug_struct("DriverStart")
.field("plugin", plugin)
.field("command", command)
.field("arg", &format_args!("<redacted>"))
.finish(),
}
}
}
#[derive(Debug, Clone)]
pub enum HostEvent {
ExtensionNotification {
extension_id: String,
method: String,
params: serde_json::Value,
},
LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "setting", rename_all = "snake_case")]
pub enum SessionSetting {
Model { model: String },
ReasoningLevel {
#[serde(with = "reasoning_level_serde")]
level: agent_core::reasoning::ReasoningLevel,
},
ContextWindow { tokens: Option<u64> },
CompactionModel { model: Option<String> },
ApiRetries { n: u32 },
SubagentTimeout { secs: u64 },
MaxToolOutput { bytes: usize },
BashTimeout { secs: u64 },
BashMaxTimeout { secs: u64 },
SystemPrompt { text: String },
ReloadPrompt,
GrantWorkerModel { model: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingApplied {
#[serde(default)]
pub id: u64,
pub setting: String,
pub ok: bool,
pub message: Option<String>,
pub view: RuntimeView,
#[serde(default)]
pub clamp: Option<ReasoningClampWire>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReasoningClampWire {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "query", rename_all = "snake_case")]
pub enum SessionQuery {
Status,
Messages,
SubagentRows,
PromptInspection,
ToolsSchema,
View,
ContextAssessment,
ContextReport,
DisplayTail { items: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EndReason {
ClientQuit,
HostShutdown,
Evicted,
Error,
Idle,
}
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)] pub enum SessionEventWire {
Stream(crate::StreamEvent),
TurnStarted {
turn_baseline: usize,
trigger: TurnTrigger,
user_text: Option<String>,
},
Conversation(ConversationSnapshot),
Prompt(PromptRequest),
PromptResolved { prompt_id: u64 },
External(crate::events::types::Event),
AutoTurnCapReached { cap: u32 },
Idle,
Steered { text: String, delivered: bool },
Dequeued { text: String },
SystemNotice(String),
LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
ExtensionNotification {
extension_id: String,
method: String,
params: serde_json::Value,
},
SettingChanged(SettingApplied),
QueryResult { id: u64, value: serde_json::Value },
Attached {
client: ClientId,
snapshot: AttachSnapshot,
},
ClientJoined { client: ClientId, kind: ClientKind },
ClientLeft { client: ClientId },
Ended { reason: EndReason },
Aborted { context_saved: bool },
Cleared { session_id: String },
CompactionStarted { source: String, disclosure: String },
CompactionApplied {
previous_session_id: String,
session_id: String,
chains_advanced: Vec<String>,
queued_restored: Option<String>,
msg_count: usize,
},
CompactionFailed { message: String, panicked: bool },
CompactionCancelled,
SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
Resumed {
id: u64,
old_id: String,
new_id: String,
via: Option<String>,
clamp_notice: Option<String>,
},
InputOwnerChanged {
from: Option<ClientId>,
to: Option<ClientId>,
reason: OwnerChangeReason,
},
Refused { client: ClientId, command: String, reason: String },
AttachRefused { message: String },
Lifecycle(SessionLifecycle),
Reloading { generation: u64, retry_after_ms: u64 },
DriverArmed {
plugin_id: String,
run_id: String,
models: Vec<crate::extensions::session_driver::Selection>,
selection: crate::extensions::session_driver::Selection,
deadline_ms: Option<u64>,
notice: String,
},
DriverRevoked {
reason: String,
undelivered_steering: Vec<String>,
},
DriverTurnOutcome {
outcome: crate::extensions::session_driver::Outcome,
selection: crate::extensions::session_driver::Selection,
feedback: Option<String>,
},
CostCapReached {
scope: String,
cost: f64,
cap: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OwnerChangeReason {
Attach,
Takeover,
OwnerDetached,
Reload,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnTrigger {
User,
QueuedAuto,
EventAuto,
PluginCommand,
Compaction,
DriverAuto,
}
#[derive(Debug, Clone)]
pub struct Envelope {
pub session_id: SessionId,
pub seq: u64,
pub ts: chrono::DateTime<chrono::Utc>,
pub event: SessionEventWire,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversationTokens {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_creation: u64,
}
#[derive(Clone, Default)]
pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
impl MessagesHashMemo {
pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
let mut memo = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((len, last, hash)) = memo.as_ref() {
let same_last = match (last, messages.last()) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if *len == messages.len() && same_last {
return *hash;
}
}
let hash = super::wire::messages_hash(messages);
*memo = Some((messages.len(), messages.last().cloned(), hash));
hash
}
}
impl std::fmt::Debug for MessagesHashMemo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("MessagesHashMemo")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConversationSnapshot {
#[serde(default)]
pub header: SessionHeader,
pub api_messages: Vec<crate::SharedMessage>,
#[serde(default)]
pub messages_len: usize,
pub tokens: ConversationTokens,
pub cost: f64,
pub abort_context: Option<String>,
pub queued_message: Option<String>,
pub pending_events_len: usize,
pub consecutive_auto_turns: u32,
#[serde(skip)]
pub messages_hash_memo: MessagesHashMemo,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionHeader {
pub id: String,
pub title: String,
pub name: Option<String>,
pub model: String,
pub thinking_level: String,
pub system_prompt: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub parent_session: Option<String>,
}
impl From<&crate::Session> for SessionHeader {
fn from(s: &crate::Session) -> Self {
Self {
id: s.id.clone(),
title: s.title.clone(),
name: s.name.clone(),
model: s.model.clone(),
thinking_level: s.thinking_level.clone(),
system_prompt: s.system_prompt.clone(),
created_at: s.created_at,
updated_at: s.updated_at,
parent_session: s.parent_session.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct AttachSnapshot {
pub meta: SessionMeta,
pub view: RuntimeView,
pub conversation: ConversationSnapshot,
pub streaming: bool,
pub replay: Vec<Envelope>,
pub pending_prompts: Vec<PromptRequest>,
pub clients: Vec<(ClientId, ClientKind)>,
pub input_owner: Option<ClientId>,
pub display_tail: Option<crate::session::display::DisplayTail>,
}
impl AttachSnapshot {
pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
let owner = self.input_owner.filter(|o| *o != me)?;
let kind = self
.clients
.iter()
.find(|(c, _)| *c == owner)
.map(|(_, k)| format!("{k:?}").to_lowercase())
.unwrap_or_else(|| "?".into());
Some(format!(
"input is owned by client #{} ({kind}); attach with --takeover to steal it",
owner.0
))
}
}
pub mod reasoning_level_serde {
use agent_core::reasoning::ReasoningLevel;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
level.as_str().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
let s = String::deserialize(d)?;
ReasoningLevel::parse(&s)
.ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_kind_from_title() {
assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
assert_eq!(
PromptKind::from_title("Confirm tool activation"),
PromptKind::Confirm
);
assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
}
#[test]
fn session_config_default_persists() {
let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
assert!(cfg.persist);
assert!(cfg.cwd.is_none());
assert_eq!(
serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
serde_json::Value::Bool(true)
);
}
#[test]
fn session_config_env_defaults_to_none() {
let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
assert!(cfg.env.is_none());
}
#[test]
fn session_config_env_round_trips() {
let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
let json = serde_json::to_string(&cfg).unwrap();
let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg2.env, Some(env));
}
#[test]
fn should_strip_env_client_prefixes() {
assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
assert!(should_strip_env("SYNAPS_MEM_TRACE"));
assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
assert!(!should_strip_env("HOME"));
assert!(!should_strip_env("PATH"));
assert!(!should_strip_env("VIRTUAL_ENV"));
}
#[test]
fn should_strip_env_secrets() {
assert!(should_strip_env("ANTHROPIC_API_KEY"));
assert!(should_strip_env("OPENAI_API_KEY"));
assert!(should_strip_env("my_secret_token"));
assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
assert!(should_strip_env("DB_PASSWORD"));
assert!(should_strip_env("GCP_CREDENTIALS"));
assert!(should_strip_env("My_Api_Key"));
assert!(!should_strip_env("MY_VALUE"));
assert!(!should_strip_env("TOKENBUCKET_SIZE"));
assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
assert!(!should_strip_env("KEYBOARD_LAYOUT"));
assert!(!should_strip_env("SSH_KEY_PATH"));
assert!(should_strip_env("GH_TOKEN"));
assert!(should_strip_env("SSH_KEY"));
assert!(should_strip_env("SECRET_KEY_BASE"));
assert!(should_strip_env("MY_PASSWD"));
}
#[test]
fn strip_client_env_drops_credential_values_whatever_the_key() {
let vars = vec![
("HOME".to_string(), "/home/u".to_string()),
(
"DATABASE_URL".to_string(),
"postgres://app:hunter2@db/prod".to_string(),
),
(
"HTTPS_PROXY".to_string(),
"http://user:pass@proxy:3128".to_string(),
),
(
"SENTRY_DSN".to_string(),
"https://abc123@o1.ingest.sentry.io/42".to_string(),
),
("GITHUB_PAT".to_string(), "ghp_x".to_string()),
(
"API_BASE_URL".to_string(),
"https://api.example.com/v1".to_string(),
),
];
let (env, stripped) = strip_client_env(vars);
let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(
kept,
vec!["API_BASE_URL", "HOME"],
"only non-secrets survive"
);
assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
&& !v.contains("user:pass")
&& !v.contains("abc123")));
assert_eq!(
stripped,
vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
"stripped names are recorded"
);
}
#[test]
fn capture_client_env_is_sorted_and_stripped() {
let (env, stripped) = capture_client_env();
for name in &stripped {
let by_value = std::env::var(name)
.map(|v| agent_core::core::config::is_secret_env_value(&v))
.unwrap_or(false);
assert!(
is_secret_key(name) || by_value,
"stripped non-secret: {name}"
);
assert!(
env.iter().all(|(k, _)| k != name),
"stripped name leaked: {name}"
);
}
for w in env.windows(2) {
assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
}
for (k, v) in &env {
assert!(!should_strip_env(k), "should have been stripped: {k}");
assert!(
!agent_core::core::config::is_secret_env_value(v),
"credential value kept: {k}"
);
}
}
#[test]
fn command_json_round_trip() {
let cmd = SessionCommand::Set {
id: 7,
setting: SessionSetting::ReasoningLevel {
level: agent_core::reasoning::ReasoningLevel::Ultra,
},
};
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(
json,
r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
);
let back: SessionCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(
back,
SessionCommand::Set {
id: 7,
setting: SessionSetting::ReasoningLevel {
level: agent_core::reasoning::ReasoningLevel::Ultra
}
}
));
let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
match submit {
SessionCommand::Submit { text, attachments } => {
assert_eq!(text, "hi");
assert!(attachments.is_empty());
}
other => panic!("unexpected {other:?}"),
}
let blocks = vec![
serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
serde_json::json!({"type":"text","text":"hello"}),
];
let submit_with = SessionCommand::Submit {
text: "describe".into(),
attachments: blocks.clone(),
};
let json = serde_json::to_string(&submit_with).unwrap();
let back: SessionCommand = serde_json::from_str(&json).unwrap();
match back {
SessionCommand::Submit { text, attachments } => {
assert_eq!(text, "describe");
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0]["type"], "image");
assert_eq!(attachments[1]["type"], "text");
}
other => panic!("unexpected {other:?}"),
}
}
#[test]
fn new_commands_round_trip() {
let cmds = vec![
SessionCommand::SubmitPrepared {
messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
user_text: Some("t".into()),
},
SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
SessionCommand::Resume { id: 2, query: "q".into() },
SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
SessionCommand::KeepWarm { on: true },
SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
];
for cmd in cmds {
let json = serde_json::to_string(&cmd).unwrap();
let back: SessionCommand = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&back).unwrap(), json);
}
let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
}
#[test]
fn command_debug_redacts_bodies_without_lengths() {
let secret = "hunter2-very-secret";
let cmds = vec![
SessionCommand::Submit { text: secret.into(), attachments: vec![] },
SessionCommand::Steer { text: secret.into() },
SessionCommand::SubmitPrepared {
messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
user_text: Some(secret.into()),
},
SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
SessionCommand::Resume { id: 1, query: secret.into() },
SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
];
for cmd in cmds {
let d = format!("{cmd:?}");
assert!(!d.contains(secret), "{d}");
assert!(d.contains("<redacted>"), "{d}");
assert!(!d.contains("chars"), "{d}");
assert!(!d.contains(&secret.len().to_string()), "{d}");
}
let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
let cmd = SessionCommand::Submit {
text: "x".into(),
attachments: vec![
serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
],
};
let d = format!("{cmd:?}");
assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
assert!(!d.contains(b64_secret), "base64 leaked: {d}");
}
#[test]
fn session_config_new_fields_default() {
let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
assert!(cfg.await_extensions);
assert!(!cfg.keep_warm);
let cfg: SessionConfig =
serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
assert!(!cfg.await_extensions);
}
#[test]
fn session_lifecycle_u8_round_trip() {
for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
assert_eq!(SessionLifecycle::from_u8(l as u8), l);
}
assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
}
#[test]
fn host_event_is_never_serialised() {
let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
crate::extensions::loader::ExtensionLoaderEvent::Started,
));
assert!(serde_json::to_string(&cmd).is_err());
}
}
mod double_option {
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Wire {
Latest { latest: bool },
Id(String),
}
pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
match v {
None => s.serialize_none(),
Some(None) => Wire::Latest { latest: true }.serialize(s),
Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
match Option::<Wire>::deserialize(d)? {
None => Ok(None),
Some(Wire::Latest { latest: true }) => Ok(Some(None)),
Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
Some(Wire::Id(id)) => Ok(Some(Some(id))),
}
}
#[cfg(test)]
mod tests {
use super::super::SessionConfig;
fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
let cfg = SessionConfig { continue_session: v, ..Default::default() };
let j = serde_json::to_string(&cfg).unwrap();
serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
}
#[test]
fn continue_session_round_trips_all_three_states() {
assert_eq!(rt(None), None);
assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
}
#[test]
fn continue_session_accepts_legacy_encodings() {
let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
assert_eq!(old_null.continue_session, None);
let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(absent.continue_session, None);
}
}
}