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, skip_serializing_if = "Option::is_none")]
pub client_input: Option<ClientInput>,
#[serde(default)]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_session_cost: Option<f64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "SessionMemoryMode::deserialize_lenient"
)]
pub memory_mode: Option<SessionMemoryMode>,
#[serde(skip)]
pub restore_parked: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionMemoryMode {
Off,
RecallEachPrompt,
CaptureOnly,
CaptureAndRecall,
}
impl SessionMemoryMode {
fn deserialize_lenient<'de, D>(d: D) -> Result<Option<Self>, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(d)?;
if v.is_null() {
return Ok(None);
}
Ok(Some(serde_json::from_value(v.clone()).unwrap_or_else(|_| {
tracing::warn!(mode = %v, "unknown session memory mode; treating as off");
Self::Off
})))
}
pub fn from_command_arg(arg: &str) -> Option<Self> {
match arg.trim() {
"off" => Some(Self::Off),
"recall" => Some(Self::RecallEachPrompt),
"capture" => Some(Self::CaptureOnly),
"on" => Some(Self::CaptureAndRecall),
_ => None,
}
}
pub fn lease_mode(self) -> Option<crate::runtime::memory_context::MemoryContextMode> {
use crate::runtime::memory_context::MemoryContextMode as M;
match self {
Self::Off => None,
Self::RecallEachPrompt => Some(M::RecallEachPrompt),
Self::CaptureOnly => Some(M::CaptureOnly),
Self::CaptureAndRecall => Some(M::CaptureAndRecall),
}
}
}
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,
client_input: None,
name: None,
max_session_cost: None,
memory_mode: None,
restore_parked: false,
}
}
}
#[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, skip_serializing_if = "Vec::is_empty")]
pub roster: Vec<ClientInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_input: Option<ClientInput>,
#[serde(default)]
pub journal_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locked_by: Option<String>,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub busy: bool,
#[serde(default)]
pub state_since: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub park_due: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub evict_due: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub cost_usd: f64,
#[serde(default)]
pub messages: usize,
}
#[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,
}
}
}
pub use crate::core::config::ClientInput;
#[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,
#[serde(default, skip_serializing_if = "ClientCapabilities::is_none")]
pub capabilities: ClientCapabilities,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
pub const CLIENT_NAME_MAX: usize = 32;
pub fn validate_client_name(name: &str) -> Result<(), String> {
let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_alphanumeric());
let ok_rest = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if name.is_empty() || name.len() > CLIENT_NAME_MAX || !ok_first || !ok_rest {
return Err(format!(
"invalid client name {name:?}: use 1-{CLIENT_NAME_MAX} letters, digits, '-' or '_', starting with a letter or digit"
));
}
Ok(())
}
pub fn client_name_from_env() -> Option<String> {
std::env::var("SYNAPS_CLIENT_NAME")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
pub fn resolve_client_name<'a>(
requested: Option<&str>,
kind: ClientKind,
taken: impl IntoIterator<Item = &'a str>,
) -> String {
let base = requested
.filter(|n| validate_client_name(n).is_ok())
.unwrap_or(kind.default_name());
let taken: std::collections::HashSet<String> = taken.into_iter().map(str::to_ascii_lowercase).collect();
if !taken.contains(&base.to_ascii_lowercase()) {
return base.to_string();
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|c| !taken.contains(&c.to_ascii_lowercase()))
.expect("an unbounded range always has a free suffix")
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
pub id: ClientId,
pub name: String,
pub kind: ClientKind,
pub mode: AttachMode,
pub attached_at_ms: u64,
}
impl ClientInfo {
pub fn label(&self) -> String {
format!("{} (#{})", self.name, self.id.0)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Roster(Vec<ClientInfo>);
impl Roster {
pub fn from_snapshot(snapshot: &AttachSnapshot) -> Self {
let mut r = Self(snapshot.roster.clone());
if r.0.is_empty() {
r.0 = snapshot
.clients
.iter()
.map(|(id, kind)| ClientInfo {
id: *id,
name: kind.default_name().to_string(),
kind: *kind,
mode: AttachMode::Mirror,
attached_at_ms: 0,
})
.collect();
}
r
}
pub fn apply(&mut self, event: &SessionEventWire) {
match event {
SessionEventWire::ClientJoined { client, kind, name, mode } => {
self.0.retain(|c| c.id != *client);
self.0.push(ClientInfo {
id: *client,
name: if name.is_empty() { kind.default_name().to_string() } else { name.clone() },
kind: *kind,
mode: *mode,
attached_at_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
});
self.0.sort_by_key(|c| c.id.0);
}
SessionEventWire::ClientLeft { client } => self.0.retain(|c| c.id != *client),
_ => {}
}
}
pub fn clients(&self) -> &[ClientInfo] {
&self.0
}
pub fn get(&self, id: ClientId) -> Option<&ClientInfo> {
self.0.iter().find(|c| c.id == id)
}
pub fn label(&self, id: ClientId) -> String {
self.get(id).map(ClientInfo::label).unwrap_or_else(|| format!("client #{}", id.0))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientCapabilities {
#[serde(default)]
pub tool_queue: bool,
#[serde(default)]
pub owner_reason_input: bool,
}
impl ClientCapabilities {
pub fn current() -> Self {
Self { tool_queue: true, owner_reason_input: true }
}
fn is_none(&self) -> bool {
*self == Self::default()
}
}
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(),
capabilities: ClientCapabilities::current(),
name: None,
}
}
pub fn named(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
}
#[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,
}
impl ClientKind {
pub fn default_name(self) -> &'static str {
match self {
Self::Tui => "tui",
Self::Chat => "chat",
Self::Attach => "attach",
Self::Rpc => "rpc",
Self::Server => "server",
Self::Test => "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, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfirmScope {
pub extensions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rewritten_by: Vec<String>,
}
impl ConfirmScope {
pub fn extensions_label(&self) -> String {
self.extensions.join(", ")
}
pub fn allow_all_label(&self) -> String {
format!("Allow all from {} this session", self.extensions_label())
}
pub fn allow_all_scope(&self) -> String {
format!(
"Allow all: later confirms from {} (any tool) are approved without asking until \
/new or /confirms reset. Other extensions still ask.",
self.extensions_label()
)
}
}
pub fn sanitize_prompt_text(text: &str) -> String {
text.chars()
.filter_map(|c| match c {
'\n' => Some('\n'),
'\t' => Some(' '),
c if c.is_control() => None,
'\u{200B}' | '\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{2028}' | '\u{2029}' => None,
'\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' => None,
c => Some(c),
})
.collect()
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirm_scope: Option<ConfirmScope>,
}
#[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 },
SubagentSteer { handle_id: String, 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 },
TakeInput,
#[serde(skip)]
Park,
#[serde(skip)]
AdoptWorkers { workers: Vec<crate::runtime::subagent::CarriedWorker> },
#[serde(skip)]
RestoreToolActivations {
activations: Vec<crate::tools::activation::CarriedActivation>,
},
#[serde(skip)]
ArmPark,
#[serde(skip)]
ReloadAborted,
#[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>,
#[serde(default)]
pub cache_keepwarm: Option<String>,
pub model: String,
pub thinking_level: String,
#[serde(default)]
pub workers: Vec<crate::runtime::subagent::CarriedWorker>,
#[serde(default)]
pub tool_activations: Vec<crate::tools::activation::CarriedActivation>,
}
pub const RELOADING_REFUSAL: &str = "daemon reloading; retry in a moment";
impl SessionCommand {
pub fn refused_while_reloading(&self) -> bool {
matches!(
self,
Self::Submit { .. }
| Self::SubmitPrepared { .. }
| Self::Steer { .. }
| Self::SubagentSteer { .. }
| Self::Compact { .. }
| Self::NewSession
| Self::Resume { .. }
| Self::EngineCommand { .. }
| Self::PluginCommand { .. }
| Self::DriverStart { .. }
| Self::Set { .. }
| Self::KeepWarm { .. }
)
}
}
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::SubagentSteer { handle_id, .. } => f
.debug_struct("SubagentSteer")
.field("handle_id", handle_id)
.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::TakeInput => f.write_str("TakeInput"),
Self::Park => f.write_str("Park"),
Self::AdoptWorkers { workers } => f
.debug_struct("AdoptWorkers")
.field("workers", &workers.iter().map(|w| w.id.as_str()).collect::<Vec<_>>())
.finish(),
Self::RestoreToolActivations { activations } => f
.debug_struct("RestoreToolActivations")
.field(
"tools",
&activations.iter().map(|a| a.tool_id.as_str()).collect::<Vec<_>>(),
)
.finish(),
Self::ReloadAborted => f.write_str("ReloadAborted"),
Self::ArmPark => f.write_str("ArmPark"),
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 },
SubagentWatch {
handle_id: String,
since: u64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tool_queue: bool,
},
}
#[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, name: String, mode: AttachMode },
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,
Input,
}
#[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,
#[serde(default)]
pub keepwarm_cost: f64,
#[serde(default)]
pub keepwarm_tokens: ConversationTokens,
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 roster: Vec<ClientInfo>,
pub client_input: ClientInput,
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> {
if self.client_input == ClientInput::Shared {
return None;
}
let owner = self.input_owner.filter(|o| *o != me)?;
let who = Roster::from_snapshot(self).label(owner);
Some(format!("input is owned by {who}; attach with --takeover to steal it"))
}
}
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 client_names_are_short_plain_labels() {
for ok in ["tui", "discord", "Dash_2", "a", "9lives", &"x".repeat(CLIENT_NAME_MAX)] {
assert_eq!(validate_client_name(ok), Ok(()), "{ok}");
}
for bad in ["", "-lead", "_lead", "has space", "dots.no", "émoji", &"x".repeat(CLIENT_NAME_MAX + 1)] {
let err = validate_client_name(bad).unwrap_err();
assert!(err.starts_with("invalid client name"), "{bad}: {err}");
}
}
#[test]
fn a_session_resolves_names_to_unique_ones() {
assert_eq!(resolve_client_name(Some("discord"), ClientKind::Rpc, []), "discord");
assert_eq!(resolve_client_name(None, ClientKind::Tui, []), "tui");
assert_eq!(resolve_client_name(Some("no spaces"), ClientKind::Attach, []), "attach");
assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui"]), "tui-2");
assert_eq!(resolve_client_name(Some("TUI"), ClientKind::Tui, ["tui", "tui-2"]), "TUI-3");
assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui", "tui-3"]), "tui-2");
assert_eq!(resolve_client_name(None, ClientKind::Tui, ["tui-2"]), "tui");
}
#[test]
fn a_roster_follows_joins_and_leaves_and_labels_by_name() {
let joined = |id: u64, name: &str, mode: AttachMode| SessionEventWire::ClientJoined {
client: ClientId(id),
kind: ClientKind::Attach,
name: name.to_string(),
mode,
};
let mut r = Roster::default();
r.apply(&joined(2, "dash", AttachMode::Observe));
r.apply(&joined(1, "terminal", AttachMode::Mirror));
r.apply(&joined(3, "", AttachMode::Mirror));
let ids: Vec<u64> = r.clients().iter().map(|c| c.id.0).collect();
assert_eq!(ids, [1, 2, 3], "kept in id order");
assert_eq!(r.get(ClientId(2)).unwrap().mode, AttachMode::Observe);
assert_eq!(r.label(ClientId(1)), "terminal (#1)");
assert_eq!(r.label(ClientId(3)), "attach (#3)");
r.apply(&SessionEventWire::ClientLeft { client: ClientId(1) });
assert_eq!(r.label(ClientId(1)), "client #1", "gone: id only");
assert_eq!(r.clients().len(), 2);
}
#[test]
fn sanitize_prompt_text_strips_separators_keeps_zwj_zwnj() {
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";
assert_eq!(sanitize_prompt_text(input), "abcdefghij k\nl");
let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
assert_eq!(sanitize_prompt_text(family), family, "ZWJ emoji intact");
let persian = "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}";
assert_eq!(sanitize_prompt_text(persian), persian, "ZWNJ intact");
}
#[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::Query {
id: 4,
query: SessionQuery::SubagentWatch { handle_id: "sa_2".into(), since: 7, tool_queue: true },
},
SessionCommand::SubagentSteer { handle_id: "sa_2".into(), text: "focus on X".into() },
SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
SessionCommand::TakeInput,
];
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 take: SessionCommand = serde_json::from_str(r#"{"cmd":"take_input"}"#).unwrap();
assert!(matches!(take, SessionCommand::TakeInput));
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::SubagentSteer { handle_id: "sa_1".into(), 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);
}
}
}
pub trait PinSink: Send + Sync {
fn pinned(&self, id: &str, journal_id: &str, name: Option<&str>, cwd: Option<&std::path::Path>, cache_keepwarm: Option<&str>);
fn unpinned(&self, id: &str, journal_id: &str);
fn moved(&self, from: &str, to: &str);
}