Skip to main content

agent_engine/session/
types.rs

1//! Identity, configuration, commands, events and the envelope — the entire
2//! client↔session contract (PLAN-phase2 §2.1–§2.4). Everything a client can
3//! send is serde-able from day one; `SessionEventWire::Stream` deliberately
4//! is not (it carries the un-serialised `StreamEvent` in-process).
5
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use super::view::RuntimeView;
11
12// ── identity + configuration ──────────────────────────────────────────────────
13
14/// Conversation session id — the `Session.id` string (`{name}-{ts}-{pid}`; see
15/// events/registry.rs sanitize rules). NOT the tools::activation::SessionId
16/// (that is the per-runtime gate identity minted at Runtime::from_parts).
17#[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
45/// Ordered key-value snapshot of a client's environment.
46pub type SessionEnv = Vec<(String, String)>;
47
48/// Prefixes stripped from the client env before it enters the session.
49/// These are client-only or daemon-only knobs — never session identity.
50const ENV_STRIP_PREFIXES: &[&str] = &[
51    "SYNAPS_CLIENT_",
52    "SYNAPS_TUI_",
53    "SYNAPS_DAEMON_",
54    "SYNAPS_MEM_TRACE",
55];
56
57/// Secret patterns (case-insensitive suffix/infix) stripped at the client.
58/// Secrets never reach the daemon — the broker owns credentials.
59/// Build a [`SessionEnv`] from the current process environment, stripping
60/// client-only prefixes and secrets. Returns `(kept_env, stripped_secret_names)`.
61/// The stripped names are ONLY the secrets (not `SYNAPS_*` prefixes, those are noise).
62/// Used by thin clients before `Hello`.
63pub fn capture_client_env() -> (SessionEnv, Vec<String>) {
64    strip_client_env(std::env::vars())
65}
66
67/// The pure part of [`capture_client_env`]: filter `vars` (unit-testable
68/// without touching the process environment).
69pub 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        // A credential VALUE is stripped whatever its key is called
76        // (`DATABASE_URL=postgres://u:p@h`): the name denylist alone let it
77        // through to the daemon and the session files.
78        let secret_value = agent_core::core::config::is_secret_env_value(&v);
79        if should_strip_env(&k) || secret_value {
80            // Only record secret names, not SYNAPS_* prefix noise.
81            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
93/// Whether a key should be stripped from the client env snapshot.
94pub 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
104/// Whether a key matches the secret denylist (case-insensitive).
105/// Delegates to the canonical implementation in `agent_core::core::config`.
106pub fn is_secret_key(key: &str) -> bool {
107    agent_core::core::config::is_secret_key(key)
108}
109
110/// Everything `EngineHost::create_session` needs. Serializable: it is the
111/// payload of the wire `Attach::Create`. Mirrors `EngineOpts` minus the
112/// host-level fields (profile, no_extensions), plus the per-session facts a
113/// daemon cannot inherit from its own process (cwd, env overlay).
114#[derive(Clone, Debug, Serialize, Deserialize)]
115pub struct SessionConfig {
116    /// `EngineOpts.continue_session`. `Some(None)` = bare `--continue` (most
117    /// recent journal). Plain serde encodes `Some(None)` as `null` and decodes
118    /// that as `None`, so the daemon could never receive a bare `--continue`
119    /// (soak F21) — `double_option` keeps the two levels apart on the wire.
120    #[serde(default, with = "double_option", skip_serializing_if = "Option::is_none")]
121    pub continue_session: Option<Option<String>>,
122    /// `EngineOpts.system`.
123    #[serde(default)]
124    pub system: Option<String>,
125    /// `EngineOpts.prompt_manifest`.
126    #[serde(default)]
127    pub prompt_manifest: Option<PathBuf>,
128    /// Per-session working directory. `None` = process cwd (in-process hosts
129    /// pass `None` today → byte-identical). The daemon ALWAYS fills it from
130    /// `Hello.cwd`.
131    #[serde(default)]
132    pub cwd: Option<PathBuf>,
133    /// Per-session environment snapshot. `None` = inherit process env
134    /// (in-process hosts). The daemon fills it from `Hello.env` on
135    /// `Attach::Create` only; attaching to an existing session does not
136    /// change the creator's env.
137    #[serde(default)]
138    pub env: Option<SessionEnv>,
139    /// Names of environment variables stripped as secrets at the client
140    /// (e.g. `GH_TOKEN`, `AWS_SECRET_ACCESS_KEY`). NOT the `SYNAPS_*`
141    /// prefixes (those are noise). Used for loud notices when a tool fails
142    /// referencing a stripped secret. Persisted in the journal (names only,
143    /// never values).
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub env_stripped: Vec<String>,
146    /// `Confirm` hook results auto-approved (server `--auto-approve-confirms`).
147    /// Interactive hosts: false.
148    #[serde(default)]
149    pub auto_approve_confirms: bool,
150    /// CLI `--model` override applied after session resolution.
151    #[serde(default)]
152    pub model_override: Option<String>,
153    /// Save policy: only "save at all" — the TUI/chat/rpc always save; tests
154    /// may disable. Default `true`.
155    #[serde(default = "default_true")]
156    pub persist: bool,
157    /// Headless-chat policy: after every turn, save + `assess_context` and
158    /// auto-compact when the engine budget says so (chat.rs post-turn block).
159    /// The TUI does its own compaction; default `false`.
160    #[serde(default)]
161    pub auto_compact: bool,
162    /// Compaction transition policy: `InPlace` (default; chat, today's
163    /// actor) or `LinkedSuccessor` (the TUI's successor-session chain).
164    #[serde(default)]
165    pub compaction_policy: CompactionPolicyWire,
166    /// Block `create` on process-level extension discovery before emitting
167    /// `on_session_start` (chat/daemon). The TUI passes `false`: the actor
168    /// emits it from a spawned waiter so boot never blocks on discovery.
169    #[serde(default = "default_true")]
170    pub await_extensions: bool,
171    /// Never `Park` this session (`--keep-warm`). Default `false`.
172    #[serde(default)]
173    pub keep_warm: bool,
174    /// `--name`: name the session at create (same rules as `/cmd saveas`;
175    /// must be unique among saved sessions). Applied before the registry
176    /// entry is written so `synaps send --session <name>` resolves at once.
177    #[serde(default)]
178    pub name: Option<String>,
179    /// (E-P7, §S3) Hard per-session spend ceiling in USD. `None` = unbounded.
180    /// Checked after every `Usage` stream event; a breach cancels the turn,
181    /// revokes any armed driver, and emits `CostCapReached`. This is the
182    /// host-owned circuit breaker for an actor-resident autonomous driver.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub max_session_cost: Option<f64>,
185}
186
187fn default_true() -> bool {
188    true
189}
190
191/// Serializable mirror of `runtime::compaction::CompactionPolicy`.
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum CompactionPolicyWire {
195    #[default]
196    InPlace,
197    LinkedSuccessor,
198}
199
200impl From<CompactionPolicyWire> for crate::runtime::compaction::CompactionPolicy {
201    fn from(p: CompactionPolicyWire) -> Self {
202        match p {
203            CompactionPolicyWire::InPlace => Self::InPlace,
204            CompactionPolicyWire::LinkedSuccessor => Self::LinkedSuccessor,
205        }
206    }
207}
208
209impl Default for SessionConfig {
210    fn default() -> Self {
211        Self {
212            continue_session: None,
213            system: None,
214            prompt_manifest: None,
215            cwd: None,
216            env: None,
217            env_stripped: Vec::new(),
218            auto_approve_confirms: false,
219            model_override: None,
220            persist: true,
221            auto_compact: false,
222            compaction_policy: CompactionPolicyWire::InPlace,
223            await_extensions: true,
224            keep_warm: false,
225            name: None,
226            max_session_cost: None,
227        }
228    }
229}
230
231/// Serializable copy of `engine::setup::ContinueInfo`.
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ContinueInfoWire {
234    pub session_id: String,
235    /// "chain", "name", "compacted", or None.
236    pub resolved_via: Option<String>,
237    pub query: String,
238    /// F24: notice when the session was followed through a compaction chain.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub compaction_notice: Option<String>,
241}
242
243impl From<&crate::engine::setup::ContinueInfo> for ContinueInfoWire {
244    fn from(c: &crate::engine::setup::ContinueInfo) -> Self {
245        Self {
246            session_id: c.session_id.clone(),
247            resolved_via: c.resolved_via.clone(),
248            query: c.query.clone(),
249            compaction_notice: c.compaction_notice.clone(),
250        }
251    }
252}
253
254#[derive(Clone, Debug, Serialize, Deserialize)]
255pub struct SessionMeta {
256    pub id: SessionId,
257    pub name: Option<String>,
258    pub model: String,
259    pub cwd: Option<PathBuf>,
260    pub created_at: chrono::DateTime<chrono::Utc>,
261    pub continued: bool,
262    pub continue_info: Option<ContinueInfoWire>,
263    /// Daemon pid (in-process: this process).
264    pub host_pid: u32,
265    // ── filled at list time from the handle's cells (meta itself is frozen) ──
266    #[serde(default)]
267    pub lifecycle: SessionLifecycle,
268    #[serde(default)]
269    pub clients: usize,
270    #[serde(default)]
271    pub input_owner: Option<ClientId>,
272    #[serde(default)]
273    pub awaiting_input: usize,
274    /// `conv.session.id` — differs from `id` after a LinkedSuccessor compaction.
275    #[serde(default)]
276    pub journal_id: String,
277    /// Set when the session's journal is locked by another process (F23):
278    /// `"pid N (kind)"`. Shown in `daemon sessions`; cleared on unpark.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub locked_by: Option<String>,
281}
282
283/// Where a session is in its park/unpark life (`SessionHandle::lifecycle`).
284#[repr(u8)]
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum SessionLifecycle {
288    #[default]
289    Live = 0,
290    Parking = 1,
291    Parked = 2,
292    Ending = 3,
293}
294
295impl SessionLifecycle {
296    pub fn from_u8(v: u8) -> Self {
297        match v {
298            1 => Self::Parking,
299            2 => Self::Parked,
300            3 => Self::Ending,
301            _ => Self::Live,
302        }
303    }
304}
305
306/// Minted by the actor per Attach.
307#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
308#[serde(transparent)]
309pub struct ClientId(pub u64);
310
311#[derive(Clone, Debug, Serialize, Deserialize)]
312pub struct ClientMeta {
313    pub kind: ClientKind,
314    /// `$TERM`, informational.
315    pub terminal: Option<String>,
316    /// Client-generated uuid; reattach dedup (day 3).
317    pub instance: String,
318    /// How much history this client wants mirrored (phase 4).
319    #[serde(default)]
320    pub history: HistoryMode,
321    /// Display items in `Attached.display_tail` / `DisplayTail` queries (phase 4).
322    #[serde(default = "default_tail_items")]
323    pub tail_items: usize,
324}
325
326/// Default for `ClientMeta.tail_items`: matches the TUI's resumed-display cap.
327pub const DEFAULT_TAIL_ITEMS: usize = 120;
328
329fn default_tail_items() -> usize {
330    DEFAULT_TAIL_ITEMS
331}
332
333/// `SYNAPS_ATTACH_TAIL_ITEMS` (client side): display items requested in
334/// `Attached.display_tail`; `DEFAULT_TAIL_ITEMS` when unset/invalid.
335pub fn tail_items_from_env() -> usize {
336    std::env::var("SYNAPS_ATTACH_TAIL_ITEMS")
337        .ok()
338        .and_then(|v| v.trim().parse::<usize>().ok())
339        .unwrap_or(DEFAULT_TAIL_ITEMS)
340}
341
342impl ClientMeta {
343    /// Fresh meta with a random instance id and no terminal.
344    pub fn new(kind: ClientKind) -> Self {
345        Self {
346            kind,
347            terminal: None,
348            instance: uuid::Uuid::new_v4().to_string(),
349            history: HistoryMode::default(),
350            tail_items: tail_items_from_env(),
351        }
352    }
353}
354
355/// What a client wants in `Attached` / `Conversation`: the full
356/// `api_messages` mirror (`Full`, the 741b6b60 behaviour) or only the
357/// digest + a daemon-side display tail (`Digest`, phase 4).
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
359#[serde(rename_all = "snake_case")]
360pub enum HistoryMode {
361    #[default]
362    Full,
363    Digest,
364}
365
366impl HistoryMode {
367    /// `SYNAPS_CLIENT_HISTORY=full|digest`; `default` wins when unset or
368    /// unrecognised.
369    pub fn from_env_or(default: Self) -> Self {
370        match std::env::var("SYNAPS_CLIENT_HISTORY").ok().as_deref().map(str::trim) {
371            Some(v) if v.eq_ignore_ascii_case("full") => Self::Full,
372            Some(v) if v.eq_ignore_ascii_case("digest") => Self::Digest,
373            _ => default,
374        }
375    }
376
377    /// What `synaps --attach` asks for (phase 4 B7: `Digest`).
378    /// `SYNAPS_CLIENT_HISTORY=full` restores the 741b6b60 mirror wholesale.
379    pub fn attach_client_default() -> Self {
380        Self::Digest
381    }
382}
383
384#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case")]
386pub enum ClientKind {
387    Tui,
388    Chat,
389    Attach,
390    Rpc,
391    Server,
392    Test,
393}
394
395/// Attach mode. Ownership (B1): `Mirror` owns input iff nobody does,
396/// `Takeover` steals it, `Observe` never owns it.
397#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum AttachMode {
400    Mirror,
401    Takeover,
402    Observe,
403}
404
405/// Actor-side client accounting (§8): `Detached` never cancels a stream.
406#[derive(Clone, Copy, Debug, PartialEq, Eq)]
407pub enum AttachState {
408    Attached(usize),
409    Detached { running: bool },
410    /// Save + PTY teardown in progress (B3).
411    Parking,
412    /// `runtime`/`conv` dropped; `background` alive; unpark on Attach/wake (B3).
413    Parked,
414}
415
416// ── prompts ───────────────────────────────────────────────────────────────────
417
418#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum PromptKind {
421    Secret,
422    Confirm,
423}
424
425/// Fixed title `resolve_before_tool_call_result` uses for Confirm prompts
426/// (runtime/mod.rs).
427pub const CONFIRM_PROMPT_TITLE: &str = "Confirm tool call";
428/// Fixed title `confirm_activation_with_host` uses for the `activate_tools`
429/// y/yes gate (tools/discovery.rs). Everything not in the confirm set is
430/// `Secret` (masked input).
431pub const CONFIRM_ACTIVATION_PROMPT_TITLE: &str = "Confirm tool activation";
432
433impl PromptKind {
434    /// Derive the kind from the prompt title (the actor classifies at receipt;
435    /// `SecretPromptHandle::prompt` classifies for the in-process TUI).
436    pub fn from_title(title: &str) -> Self {
437        if title == CONFIRM_PROMPT_TITLE || title == CONFIRM_ACTIVATION_PROMPT_TITLE {
438            Self::Confirm
439        } else {
440            Self::Secret
441        }
442    }
443}
444
445#[derive(Clone, Debug, Serialize, Deserialize)]
446pub struct PromptRequest {
447    /// Per-session monotonic; actor dedups answers on it.
448    pub id: u64,
449    pub kind: PromptKind,
450    pub title: String,
451    pub prompt: String,
452    pub raised_at: chrono::DateTime<chrono::Utc>,
453}
454
455// ── commands, settings, queries ───────────────────────────────────────────────
456
457/// A command with its origin. `from: None` = host-originated (router,
458/// lifecycle, idle probe) and bypasses input ownership (B1).
459#[derive(Debug)]
460pub struct Addressed {
461    pub from: Option<ClientId>,
462    pub cmd: SessionCommand,
463}
464
465/// Why a `Checkpoint` was requested.
466#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(rename_all = "snake_case")]
468pub enum CheckpointReason {
469    Reload,
470    HostRequest,
471}
472
473/// The entire client→session control surface (SEAMS §5.2/§5.3).
474///
475/// `Debug` is manual: `Submit`/`Steer`/`SubmitPrepared`/`Answer`/`Resume`
476/// print `<redacted>` (no lengths) so a command can be traced by *type*.
477#[derive(Serialize, Deserialize)]
478#[serde(tag = "cmd", rename_all = "snake_case")]
479pub enum SessionCommand {
480    /// User-authored prompt. Actor: reset auto-turn counter, push user msg
481    /// verbatim, start turn.
482    Submit {
483        text: String,
484        /// Pre-built canonical user content blocks (images, documents)
485        /// produced by `PendingAttachments::build_content` on the client.
486        /// Empty ⇔ text-only (backward compatible).
487        #[serde(default)]
488        attachments: Vec<serde_json::Value>,
489    },
490    /// Text typed while streaming. Actor: steer if a steer_tx is live else
491    /// queue; ALWAYS also sets queued_message.
492    Steer { text: String },
493    /// Esc. Cancel, adopt the turn's partial history + append the
494    /// interruption marker (`engine::interrupt`), dequeue, flush pending
495    /// events, cancel subagents, save.
496    Cancel,
497    /// Answer to a PromptRequest. `None` = cancelled.
498    /// NEVER journaled, NEVER replayed, NEVER traced.
499    Answer { prompt_id: u64, value: Option<String> },
500    /// `id` is client-chosen and echoed in `SettingChanged.id`.
501    Set { id: u64, setting: SessionSetting },
502    Compact { instructions: Option<String> },
503    /// `/new`: ConversationState::clear.
504    NewSession,
505    Save,
506    /// Reply = `SessionEventWire::QueryResult { id, value }`.
507    Query { id: u64, query: SessionQuery },
508    /// Reply = `Attached { client_id, … }` on THIS client's channel only.
509    Attach { client: ClientMeta, mode: AttachMode },
510    Detach { client: ClientId },
511    /// Host shutdown of this session: save → on_session_end → leases → Ended.
512    End { reason: EndReason },
513    /// Resync after Lagged (B, day 3): actor re-sends history + turn_replay.
514    Resync { client: ClientId, since_seq: u64 },
515    /// Runtime-backed slash command (`engine::commands::handle_engine_command`:
516    /// /model /thinking /context /trace /memory /compact …). Reply =
517    /// `QueryResult { id, value: {"kind": .., "text": ..} }`.
518    EngineCommand { id: u64, name: String, arg: String },
519    /// (A3) dispatch.rs LoadSkill — pre-built tool_use/tool_result pair
520    /// (+ optional user text) then a turn. Does NOT reset
521    /// consecutive_auto_turns.
522    SubmitPrepared {
523        messages: Vec<crate::SharedMessage>,
524        #[serde(default)]
525        user_text: Option<String>,
526    },
527    /// (A3) tools-backed (non-interactive) plugin command on THE runtime's
528    /// tool set. Reply = `QueryResult{id, {kind:"plugin_output", ..}}`.
529    PluginCommand { id: u64, plugin: String, name: String, arg: String },
530    /// (A3) `/resume`: save current, load `query`, restore model/reasoning/
531    /// system prompt, swap conversation. Reply = `Resumed{id, ..}`.
532    Resume { id: u64, query: String },
533    /// (B1, used by C3 reload) cancel any turn (partial history + marker),
534    /// abort compaction, save, close PTYs, emit Notice. Never ends the
535    /// session. Reply = `QueryResult{id: CHECKPOINT_QUERY_ID, {ok:true}}`.
536    Checkpoint { reason: CheckpointReason },
537    /// (B3) pin/unpin (`--keep-warm`, `/keep-warm on|off`).
538    KeepWarm { on: bool },
539    /// Host-only (never wire): park now if `can_park()` (reload rehydrates
540    /// a Parked session Parked). A no-op otherwise.
541    #[serde(skip)]
542    Park,
543    /// Host→session (never wire): the actor re-emits as `SessionEventWire`.
544    #[serde(skip)]
545    HostEvent(HostEvent),
546    /// (E-P0) Start or re-arm the session driver for `plugin`.
547    /// `command` is the interactive command name (e.g. "auto");
548    /// `arg` is everything after the command ("start -- do X").
549    /// No `DriverStop` — `Cancel` handles that (§2).
550    DriverStart {
551        plugin: String,
552        command: String,
553        arg: String,
554    },
555}
556
557/// What `Checkpoint{Reload}` reports so `daemon reload` can rebuild the
558/// session as it IS (not as it was created): the actor's config, keep-warm
559/// pin, lifecycle, the non-persisted knobs (`settings_replay`, incl.
560/// `/system`), and the CURRENT model/thinking.
561#[derive(Clone, Debug, Serialize, Deserialize)]
562pub struct SessionReloadRecord {
563    pub config: SessionConfig,
564    pub keep_warm: bool,
565    pub lifecycle: SessionLifecycle,
566    #[serde(default)]
567    pub settings_replay: Vec<SessionSetting>,
568    /// Per-session `/keepwarm` override (`off`/`auto`/`<N>h`), if any.
569    #[serde(default)]
570    pub cache_keepwarm: Option<String>,
571    pub model: String,
572    pub thinking_level: String,
573}
574
575impl std::fmt::Debug for SessionCommand {
576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577        match self {
578            Self::Submit { attachments, .. } => {
579                let kinds: Vec<&str> = attachments
580                    .iter()
581                    .filter_map(|b| b.get("type").and_then(|v| v.as_str()))
582                    .collect();
583                f.debug_struct("Submit")
584                    .field("text", &format_args!("<redacted>"))
585                    .field("attachments", &format_args!("{}:{:?}", attachments.len(), kinds))
586                    .finish()
587            }
588            Self::Steer { .. } => f
589                .debug_struct("Steer")
590                .field("text", &format_args!("<redacted>"))
591                .finish(),
592            Self::SubmitPrepared { .. } => f
593                .debug_struct("SubmitPrepared")
594                .field("messages", &format_args!("<redacted>"))
595                .field("user_text", &format_args!("<redacted>"))
596                .finish(),
597            Self::Answer { prompt_id, .. } => f
598                .debug_struct("Answer")
599                .field("prompt_id", prompt_id)
600                .field("value", &format_args!("<redacted>"))
601                .finish(),
602            Self::Resume { id, .. } => f
603                .debug_struct("Resume")
604                .field("id", id)
605                .field("query", &format_args!("<redacted>"))
606                .finish(),
607            Self::Cancel => f.write_str("Cancel"),
608            Self::Set { id, setting } => {
609                f.debug_struct("Set").field("id", id).field("setting", setting).finish()
610            }
611            Self::Compact { instructions } => {
612                f.debug_struct("Compact").field("instructions", instructions).finish()
613            }
614            Self::NewSession => f.write_str("NewSession"),
615            Self::Save => f.write_str("Save"),
616            Self::Park => f.write_str("Park"),
617            Self::Query { id, query } => {
618                f.debug_struct("Query").field("id", id).field("query", query).finish()
619            }
620            Self::Attach { client, mode } => {
621                f.debug_struct("Attach").field("client", client).field("mode", mode).finish()
622            }
623            Self::Detach { client } => f.debug_struct("Detach").field("client", client).finish(),
624            Self::End { reason } => f.debug_struct("End").field("reason", reason).finish(),
625            Self::Resync { client, since_seq } => f
626                .debug_struct("Resync")
627                .field("client", client)
628                .field("since_seq", since_seq)
629                .finish(),
630            Self::EngineCommand { id, name, arg } => f
631                .debug_struct("EngineCommand")
632                .field("id", id)
633                .field("name", name)
634                .field("arg", arg)
635                .finish(),
636            Self::PluginCommand { id, plugin, name, arg } => f
637                .debug_struct("PluginCommand")
638                .field("id", id)
639                .field("plugin", plugin)
640                .field("name", name)
641                .field("arg", arg)
642                .finish(),
643            Self::Checkpoint { reason } => {
644                f.debug_struct("Checkpoint").field("reason", reason).finish()
645            }
646            Self::KeepWarm { on } => f.debug_struct("KeepWarm").field("on", on).finish(),
647            Self::HostEvent(ev) => f.debug_tuple("HostEvent").field(ev).finish(),
648            Self::DriverStart { plugin, command, .. } => f
649                .debug_struct("DriverStart")
650                .field("plugin", plugin)
651                .field("command", command)
652                .field("arg", &format_args!("<redacted>"))
653                .finish(),
654        }
655    }
656}
657
658/// Host-originated events pushed into a session (C3 router, loader).
659#[derive(Debug, Clone)]
660pub enum HostEvent {
661    ExtensionNotification {
662        extension_id: String,
663        method: String,
664        params: serde_json::Value,
665    },
666    LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
667}
668
669/// The `&mut Runtime` setters the TUI/rpc/server call (SEAMS §5.3).
670#[derive(Debug, Clone, Serialize, Deserialize)]
671#[serde(tag = "setting", rename_all = "snake_case")]
672pub enum SessionSetting {
673    /// try_set_model → SettingApplied::Model{applied, thinking_level}
674    Model { model: String },
675    /// set_reasoning_level_checked
676    ReasoningLevel {
677        #[serde(with = "reasoning_level_serde")]
678        level: agent_core::reasoning::ReasoningLevel,
679    },
680    /// set_context_window
681    ContextWindow { tokens: Option<u64> },
682    CompactionModel { model: Option<String> },
683    ApiRetries { n: u32 },
684    SubagentTimeout { secs: u64 },
685    MaxToolOutput { bytes: usize },
686    BashTimeout { secs: u64 },
687    BashMaxTimeout { secs: u64 },
688    /// set_system_prompt
689    SystemPrompt { text: String },
690    /// reload_prompt() → SettingApplied::PromptReloaded{generation, source}
691    ReloadPrompt,
692    /// grant_worker_model
693    GrantWorkerModel { model: String },
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct SettingApplied {
698    /// Correlates with `Set{id}`.
699    #[serde(default)]
700    pub id: u64,
701    pub setting: String,
702    pub ok: bool,
703    pub message: Option<String>,
704    pub view: RuntimeView,
705    /// `{from, to}` when a model change clamped the reasoning level, so the
706    /// TUI renders `reasoning_clamp_notice` byte-for-byte.
707    #[serde(default)]
708    pub clamp: Option<ReasoningClampWire>,
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
712pub struct ReasoningClampWire {
713    pub from: String,
714    pub to: String,
715}
716
717#[derive(Debug, Clone, Serialize, Deserialize)]
718#[serde(tag = "query", rename_all = "snake_case")]
719pub enum SessionQuery {
720    /// {session, model, tokens, cost, streaming, auto_turns, attached, pending_prompts}
721    Status,
722    /// Vec<SharedMessage> (api_messages)
723    Messages,
724    /// Vec<SubagentDisplayRow>
725    SubagentRows,
726    /// runtime.prompt_inspection_json()
727    PromptInspection,
728    /// rpc_dispatch::build_tools_list_body input
729    ToolsSchema,
730    /// RuntimeView (cheap; LocalTransport answers without a hop)
731    View,
732    /// assess_context
733    ContextAssessment,
734    /// `engine::commands::context_command(runtime, Some(api_messages))` text.
735    ContextReport,
736    /// `display::DisplayTail` of the last `items` display items (phase 4).
737    DisplayTail { items: usize },
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum EndReason {
743    ClientQuit,
744    HostShutdown,
745    Evicted,
746    Error,
747    /// F18: zero turns, zero clients, park grace elapsed. There is nothing
748    /// to journal, so the session cannot park; it ends instead of holding a
749    /// Runtime resident forever.
750    Idle,
751}
752
753// ── events + envelope ─────────────────────────────────────────────────────────
754
755/// What a session tells its clients. `Stream` carries the un-serialised
756/// StreamEvent in-process (LocalTransport) — that IS the byte-identical
757/// contract. The socket boundary converts to `wire::WireSessionEvent`.
758#[derive(Debug, Clone)]
759#[allow(clippy::large_enum_variant)] // Attached carries the full snapshot by design (§2.4)
760pub enum SessionEventWire {
761    Stream(crate::StreamEvent),
762    /// Actor bookkeeping the client needs to mirror `App` fields exactly.
763    /// `user_text` = the prompt that started the turn: the queued text on
764    /// `QueuedAuto` (every client pushes the User card + scroll), the
765    /// submitted text on `User` (clients OTHER than the submitter push the
766    /// card; the submitter drew it at submit); `None` otherwise. The attach
767    /// replay ring stores it as `None` (the snapshot's history has it).
768    TurnStarted {
769        turn_baseline: usize,
770        trigger: TurnTrigger,
771        user_text: Option<String>,
772    },
773    /// Emitted after every MessageHistory / Done / Error / Cancel / Compact so
774    /// mirrors never diverge: the actor's api_messages IS the truth.
775    Conversation(ConversationSnapshot),
776    Prompt(PromptRequest),
777    /// So mirrors dismiss the modal.
778    PromptResolved { prompt_id: u64 },
779    /// Drained event card — presentation only.
780    External(crate::events::types::Event),
781    AutoTurnCapReached { cap: u32 },
782    /// The turn machine is idle: a stream ended (Done/Error/Cancel) and NO
783    /// auto-turn followed. Headless hosts gate stdin on this so a queued
784    /// auto-turn is never raced by the next line.
785    Idle,
786    /// "→ steering:" vs "queued:".
787    Steered { text: String, delivered: bool },
788    Dequeued { text: String },
789    /// Any ChatMessage::System the actor would have pushed.
790    SystemNotice(String),
791    LoaderProgress(crate::extensions::loader::ExtensionLoaderEvent),
792    ExtensionNotification {
793        extension_id: String,
794        method: String,
795        params: serde_json::Value,
796    },
797    SettingChanged(SettingApplied),
798    QueryResult { id: u64, value: serde_json::Value },
799    /// Only to the attaching client.
800    Attached {
801        client: ClientId,
802        snapshot: AttachSnapshot,
803    },
804    ClientJoined { client: ClientId, kind: ClientKind },
805    ClientLeft { client: ClientId },
806    Ended { reason: EndReason },
807    /// Cancel landed. TUI: drop_empty_thinking, push Error(abort_msg),
808    /// subagents.clear(), streaming=false. `context_saved` (name kept for
809    /// protocol v3): the interrupted turn's partial work is kept in history.
810    Aborted { context_saved: bool },
811    /// `/clear`. TUI: transcript.clear, counters=0, "new session started".
812    Cleared { session_id: String },
813    /// (B2) `disclosure` = preview_compaction_disclosure().render_line().
814    CompactionStarted { source: String, disclosure: String },
815    CompactionApplied {
816        previous_session_id: String,
817        session_id: String,
818        chains_advanced: Vec<String>,
819        queued_restored: Option<String>,
820        msg_count: usize,
821    },
822    /// "compaction failed: {e}" vs "compaction task panicked: {e}".
823    CompactionFailed { message: String, panicked: bool },
824    CompactionCancelled,
825    /// `runtime.subagent_registry().display_rows()` — at Done/Error and at
826    /// 1 Hz while any row is non-terminal.
827    SubagentRows(Vec<crate::runtime::subagent::SubagentDisplayRow>),
828    /// `/resume` reply (`Resume{id}`).
829    Resumed {
830        id: u64,
831        old_id: String,
832        new_id: String,
833        via: Option<String>,
834        clamp_notice: Option<String>,
835    },
836    /// (B1) sent to the previous owner on takeover, and to everyone on any
837    /// owner change.
838    InputOwnerChanged {
839        from: Option<ClientId>,
840        to: Option<ClientId>,
841        reason: OwnerChangeReason,
842    },
843    /// (B1) a non-owner sent an input command. Only the sender renders it.
844    Refused { client: ClientId, command: String, reason: String },
845    /// (B3) targeted like `Attached`: the attach could not be honoured.
846    AttachRefused { message: String },
847    /// (B3) lifecycle transitions for mirrors and `sessions` listings.
848    Lifecycle(SessionLifecycle),
849    /// (C3) daemon is about to exec itself; clients reconnect.
850    Reloading { generation: u64, retry_after_ms: u64 },
851    /// (E-P0) Driver armed on this session.
852    DriverArmed {
853        plugin_id: String,
854        run_id: String,
855        models: Vec<crate::extensions::session_driver::Selection>,
856        selection: crate::extensions::session_driver::Selection,
857        /// Remaining ms, not absolute Instant.
858        deadline_ms: Option<u64>,
859        notice: String,
860    },
861    /// (E-P0) Driver revoked.
862    DriverRevoked {
863        reason: String,
864        undelivered_steering: Vec<String>,
865    },
866    /// (E-P0) One driver turn completed.
867    DriverTurnOutcome {
868        outcome: crate::extensions::session_driver::Outcome,
869        selection: crate::extensions::session_driver::Selection,
870        feedback: Option<String>,
871    },
872    /// (E-P7, §S3) A spend ceiling was breached: the turn was cancelled and any
873    /// armed driver revoked. `scope` is "session" (config cap) or "run" (the
874    /// driver grant's per-run cap). Additive v3 driver event — old clients skip
875    /// it via `#[serde(other)]`.
876    CostCapReached {
877        scope: String,
878        cost: f64,
879        cap: f64,
880    },
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885pub enum OwnerChangeReason {
886    Attach,
887    Takeover,
888    OwnerDetached,
889    Reload,
890}
891
892#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub enum TurnTrigger {
895    User,
896    QueuedAuto,
897    EventAuto,
898    PluginCommand,
899    Compaction,
900    /// (E-P0) Turn initiated by the session driver.
901    DriverAuto,
902}
903
904/// Per-session, gapless `seq` assigned at the single emit site
905/// (`SessionActor::emit`); `ts` from the host clock.
906#[derive(Debug, Clone)]
907pub struct Envelope {
908    pub session_id: SessionId,
909    pub seq: u64,
910    pub ts: chrono::DateTime<chrono::Utc>,
911    pub event: SessionEventWire,
912}
913
914#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
915pub struct ConversationTokens {
916    pub input: u64,
917    pub output: u64,
918    pub cache_read: u64,
919    pub cache_creation: u64,
920}
921
922/// Memo of `wire::messages_hash(api_messages)` shared by every clone of one
923/// snapshot: the daemon converts a `Conversation` to its wire digest once
924/// per socket client, and hashing serialises the whole history — now once
925/// per event, not once per client. Keyed on the history's length and last
926/// message identity (histories only grow), so a snapshot whose messages are
927/// replaced afterwards recomputes instead of reusing a stale hash.
928#[derive(Clone, Default)]
929pub struct MessagesHashMemo(pub(crate) std::sync::Arc<std::sync::Mutex<Option<HashedHistory>>>);
930
931/// `(len, last message, hash)` of the history a memo was computed for.
932type HashedHistory = (usize, Option<crate::SharedMessage>, u64);
933
934impl MessagesHashMemo {
935    pub fn hash(&self, messages: &[crate::SharedMessage]) -> u64 {
936        let mut memo = self
937            .0
938            .lock()
939            .unwrap_or_else(std::sync::PoisonError::into_inner);
940        if let Some((len, last, hash)) = memo.as_ref() {
941            let same_last = match (last, messages.last()) {
942                (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
943                (None, None) => true,
944                _ => false,
945            };
946            if *len == messages.len() && same_last {
947                return *hash;
948            }
949        }
950        let hash = super::wire::messages_hash(messages);
951        *memo = Some((messages.len(), messages.last().cloned(), hash));
952        hash
953    }
954}
955
956impl std::fmt::Debug for MessagesHashMemo {
957    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958        f.write_str("MessagesHashMemo")
959    }
960}
961
962/// Serializable mirror of `ConversationState` (+ the actor's auto-turn
963/// counter). Clients MUST replace, never merge, on `Conversation(_)`.
964#[derive(Debug, Clone, Default, Serialize, Deserialize)]
965pub struct ConversationSnapshot {
966    /// The TUI's `App.session` mirror (never `api_messages`).
967    #[serde(default)]
968    pub header: SessionHeader,
969    pub api_messages: Vec<crate::SharedMessage>,
970    /// `api_messages.len()` on the daemon — always filled by the actor;
971    /// `api_messages` itself may be empty for `HistoryMode::Digest` clients.
972    #[serde(default)]
973    pub messages_len: usize,
974    pub tokens: ConversationTokens,
975    pub cost: f64,
976    /// Prompt-cache keep-warm spend, already included in `cost` (task #444).
977    #[serde(default)]
978    pub keepwarm_cost: f64,
979    /// Keep-warm ping tokens, already included in `tokens` (task #444).
980    #[serde(default)]
981    pub keepwarm_tokens: ConversationTokens,
982    /// Legacy (protocol v3 shape kept): always `None` — an interrupted turn
983    /// is recorded in `api_messages` (`engine::interrupt`), never as a recap.
984    pub abort_context: Option<String>,
985    pub queued_message: Option<String>,
986    pub pending_events_len: usize,
987    pub consecutive_auto_turns: u32,
988    /// Never serialised; see `MessagesHashMemo`.
989    #[serde(skip)]
990    pub messages_hash_memo: MessagesHashMemo,
991}
992
993/// `Session` minus `api_messages` and accounting: what a client mirrors.
994#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
995pub struct SessionHeader {
996    pub id: String,
997    pub title: String,
998    pub name: Option<String>,
999    pub model: String,
1000    pub thinking_level: String,
1001    pub system_prompt: Option<String>,
1002    pub created_at: chrono::DateTime<chrono::Utc>,
1003    pub updated_at: chrono::DateTime<chrono::Utc>,
1004    pub parent_session: Option<String>,
1005}
1006
1007impl From<&crate::Session> for SessionHeader {
1008    fn from(s: &crate::Session) -> Self {
1009        Self {
1010            id: s.id.clone(),
1011            title: s.title.clone(),
1012            name: s.name.clone(),
1013            model: s.model.clone(),
1014            thinking_level: s.thinking_level.clone(),
1015            system_prompt: s.system_prompt.clone(),
1016            created_at: s.created_at,
1017            updated_at: s.updated_at,
1018            parent_session: s.parent_session.clone(),
1019        }
1020    }
1021}
1022
1023#[derive(Debug, Clone)]
1024pub struct AttachSnapshot {
1025    pub meta: SessionMeta,
1026    pub view: RuntimeView,
1027    pub conversation: ConversationSnapshot,
1028    pub streaming: bool,
1029    /// Current-turn events since TurnStarted (bounded ring) so a mid-turn
1030    /// attach can rebuild partial text/tool cards.
1031    pub replay: Vec<Envelope>,
1032    pub pending_prompts: Vec<PromptRequest>,
1033    pub clients: Vec<(ClientId, ClientKind)>,
1034    /// Who owns input right now (B1) — so a joiner knows immediately
1035    /// whether its keystrokes will be honoured.
1036    pub input_owner: Option<ClientId>,
1037    /// Daemon-projected display tail — `Some` iff the client attached with
1038    /// `HistoryMode::Digest` (`conversation.api_messages` is then empty).
1039    pub display_tail: Option<crate::session::display::DisplayTail>,
1040}
1041
1042impl AttachSnapshot {
1043    /// For the client this snapshot was made for (`me`): who owns input, if
1044    /// someone else does — "input is owned by client #1 (tui); attach with
1045    /// --takeover to steal it". Rendered by each client from its OWN
1046    /// snapshot; the actor used to broadcast it as a `SystemNotice`, which
1047    /// every attached client (the owner included) and later attach replays
1048    /// showed.
1049    pub fn input_owned_elsewhere(&self, me: ClientId) -> Option<String> {
1050        let owner = self.input_owner.filter(|o| *o != me)?;
1051        let kind = self
1052            .clients
1053            .iter()
1054            .find(|(c, _)| *c == owner)
1055            .map(|(_, k)| format!("{k:?}").to_lowercase())
1056            .unwrap_or_else(|| "?".into());
1057        Some(format!(
1058            "input is owned by client #{} ({kind}); attach with --takeover to steal it",
1059            owner.0
1060        ))
1061    }
1062}
1063
1064/// `ReasoningLevel` has no serde impls in agent-core; go through its
1065/// canonical string form.
1066pub mod reasoning_level_serde {
1067    use agent_core::reasoning::ReasoningLevel;
1068    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1069
1070    pub fn serialize<S: Serializer>(level: &ReasoningLevel, s: S) -> Result<S::Ok, S::Error> {
1071        level.as_str().serialize(s)
1072    }
1073
1074    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<ReasoningLevel, D::Error> {
1075        let s = String::deserialize(d)?;
1076        ReasoningLevel::parse(&s)
1077            .ok_or_else(|| serde::de::Error::custom(format!("unknown reasoning level `{s}`")))
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    #[test]
1086    fn prompt_kind_from_title() {
1087        assert_eq!(PromptKind::from_title("Confirm tool call"), PromptKind::Confirm);
1088        assert_eq!(
1089            PromptKind::from_title("Confirm tool activation"),
1090            PromptKind::Confirm
1091        );
1092        assert_eq!(PromptKind::from_title("API key"), PromptKind::Secret);
1093    }
1094
1095    #[test]
1096    fn session_config_default_persists() {
1097        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1098        assert!(cfg.persist);
1099        assert!(cfg.cwd.is_none());
1100        assert_eq!(
1101            serde_json::to_value(SessionConfig::default()).unwrap()["persist"],
1102            serde_json::Value::Bool(true)
1103        );
1104    }
1105
1106    #[test]
1107    fn session_config_env_defaults_to_none() {
1108        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1109        assert!(cfg.env.is_none());
1110    }
1111
1112    #[test]
1113    fn session_config_env_round_trips() {
1114        let env = vec![("FOO".into(), "bar".into()), ("PATH".into(), "/usr/bin".into())];
1115        let cfg = SessionConfig { env: Some(env.clone()), ..Default::default() };
1116        let json = serde_json::to_string(&cfg).unwrap();
1117        let cfg2: SessionConfig = serde_json::from_str(&json).unwrap();
1118        assert_eq!(cfg2.env, Some(env));
1119    }
1120
1121    #[test]
1122    fn should_strip_env_client_prefixes() {
1123        assert!(should_strip_env("SYNAPS_CLIENT_REEXEC"));
1124        assert!(should_strip_env("SYNAPS_TUI_SCROLLBACK"));
1125        assert!(should_strip_env("SYNAPS_DAEMON_ADOPT"));
1126        assert!(should_strip_env("SYNAPS_MEM_TRACE"));
1127        assert!(should_strip_env("SYNAPS_MEM_TRACE_FOO"));
1128        // Session env — not stripped
1129        assert!(!should_strip_env("SYNAPS_ANTHROPIC_BASE_URL"));
1130        assert!(!should_strip_env("HOME"));
1131        assert!(!should_strip_env("PATH"));
1132        assert!(!should_strip_env("VIRTUAL_ENV"));
1133    }
1134
1135    #[test]
1136    fn should_strip_env_secrets() {
1137        assert!(should_strip_env("ANTHROPIC_API_KEY"));
1138        assert!(should_strip_env("OPENAI_API_KEY"));
1139        assert!(should_strip_env("my_secret_token"));
1140        assert!(should_strip_env("AWS_SECRET_ACCESS_KEY"));
1141        assert!(should_strip_env("DB_PASSWORD"));
1142        assert!(should_strip_env("GCP_CREDENTIALS"));
1143        // Case insensitive
1144        assert!(should_strip_env("My_Api_Key"));
1145        // Not a secret
1146        assert!(!should_strip_env("MY_VALUE"));
1147        assert!(!should_strip_env("TOKENBUCKET_SIZE"));
1148        // suffix-anchored: the secret name must END the key
1149        assert!(!should_strip_env("API_TOKEN_TTL_SECS"));
1150        assert!(!should_strip_env("KEYBOARD_LAYOUT"));
1151        assert!(!should_strip_env("SSH_KEY_PATH"));
1152        assert!(should_strip_env("GH_TOKEN"));
1153        assert!(should_strip_env("SSH_KEY"));
1154        assert!(should_strip_env("SECRET_KEY_BASE"));
1155        assert!(should_strip_env("MY_PASSWD"));
1156    }
1157
1158    #[test]
1159    fn strip_client_env_drops_credential_values_whatever_the_key() {
1160        let vars = vec![
1161            ("HOME".to_string(), "/home/u".to_string()),
1162            (
1163                "DATABASE_URL".to_string(),
1164                "postgres://app:hunter2@db/prod".to_string(),
1165            ),
1166            (
1167                "HTTPS_PROXY".to_string(),
1168                "http://user:pass@proxy:3128".to_string(),
1169            ),
1170            (
1171                "SENTRY_DSN".to_string(),
1172                "https://abc123@o1.ingest.sentry.io/42".to_string(),
1173            ),
1174            ("GITHUB_PAT".to_string(), "ghp_x".to_string()),
1175            (
1176                "API_BASE_URL".to_string(),
1177                "https://api.example.com/v1".to_string(),
1178            ),
1179        ];
1180        let (env, stripped) = strip_client_env(vars);
1181        let kept: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
1182        assert_eq!(
1183            kept,
1184            vec!["API_BASE_URL", "HOME"],
1185            "only non-secrets survive"
1186        );
1187        assert!(env.iter().all(|(_, v)| !v.contains("hunter2")
1188            && !v.contains("user:pass")
1189            && !v.contains("abc123")));
1190        assert_eq!(
1191            stripped,
1192            vec!["DATABASE_URL", "GITHUB_PAT", "HTTPS_PROXY", "SENTRY_DSN"],
1193            "stripped names are recorded"
1194        );
1195    }
1196
1197    #[test]
1198    fn capture_client_env_is_sorted_and_stripped() {
1199        // We can't fully control the process env in a unit test, but we can
1200        // verify the output is sorted and that known strip-list vars are absent.
1201        let (env, stripped) = capture_client_env();
1202        // Every stripped name is a secret by our own rule, and none of them
1203        // survived into the kept env.
1204        for name in &stripped {
1205            let by_value = std::env::var(name)
1206                .map(|v| agent_core::core::config::is_secret_env_value(&v))
1207                .unwrap_or(false);
1208            assert!(
1209                is_secret_key(name) || by_value,
1210                "stripped non-secret: {name}"
1211            );
1212            assert!(
1213                env.iter().all(|(k, _)| k != name),
1214                "stripped name leaked: {name}"
1215            );
1216        }
1217        // Sorted
1218        for w in env.windows(2) {
1219            assert!(w[0].0 <= w[1].0, "not sorted: {:?} > {:?}", w[0].0, w[1].0);
1220        }
1221        // No secrets or client-only vars
1222        for (k, v) in &env {
1223            assert!(!should_strip_env(k), "should have been stripped: {k}");
1224            assert!(
1225                !agent_core::core::config::is_secret_env_value(v),
1226                "credential value kept: {k}"
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn command_json_round_trip() {
1233        let cmd = SessionCommand::Set {
1234            id: 7,
1235            setting: SessionSetting::ReasoningLevel {
1236                level: agent_core::reasoning::ReasoningLevel::Ultra,
1237            },
1238        };
1239        let json = serde_json::to_string(&cmd).unwrap();
1240        assert_eq!(
1241            json,
1242            r#"{"cmd":"set","id":7,"setting":{"setting":"reasoning_level","level":"ultra"}}"#
1243        );
1244        let back: SessionCommand = serde_json::from_str(&json).unwrap();
1245        assert!(matches!(
1246            back,
1247            SessionCommand::Set {
1248                id: 7,
1249                setting: SessionSetting::ReasoningLevel {
1250                    level: agent_core::reasoning::ReasoningLevel::Ultra
1251                }
1252            }
1253        ));
1254
1255        let submit: SessionCommand = serde_json::from_str(r#"{"cmd":"submit","text":"hi"}"#).unwrap();
1256        match submit {
1257            SessionCommand::Submit { text, attachments } => {
1258                assert_eq!(text, "hi");
1259                assert!(attachments.is_empty());
1260            }
1261            other => panic!("unexpected {other:?}"),
1262        }
1263
1264        // Submit with content blocks round-trips.
1265        let blocks = vec![
1266            serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}}),
1267            serde_json::json!({"type":"text","text":"hello"}),
1268        ];
1269        let submit_with = SessionCommand::Submit {
1270            text: "describe".into(),
1271            attachments: blocks.clone(),
1272        };
1273        let json = serde_json::to_string(&submit_with).unwrap();
1274        let back: SessionCommand = serde_json::from_str(&json).unwrap();
1275        match back {
1276            SessionCommand::Submit { text, attachments } => {
1277                assert_eq!(text, "describe");
1278                assert_eq!(attachments.len(), 2);
1279                assert_eq!(attachments[0]["type"], "image");
1280                assert_eq!(attachments[1]["type"], "text");
1281            }
1282            other => panic!("unexpected {other:?}"),
1283        }
1284    }
1285
1286    #[test]
1287    fn new_commands_round_trip() {
1288        let cmds = vec![
1289            SessionCommand::SubmitPrepared {
1290                messages: vec![std::sync::Arc::new(serde_json::json!({"role":"user","content":"x"}))],
1291                user_text: Some("t".into()),
1292            },
1293            SessionCommand::PluginCommand { id: 1, plugin: "p".into(), name: "n".into(), arg: "a".into() },
1294            SessionCommand::Resume { id: 2, query: "q".into() },
1295            SessionCommand::Checkpoint { reason: CheckpointReason::Reload },
1296            SessionCommand::KeepWarm { on: true },
1297            SessionCommand::Query { id: 3, query: SessionQuery::ContextReport },
1298            SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: "start -- hi".into() },
1299        ];
1300        for cmd in cmds {
1301            let json = serde_json::to_string(&cmd).unwrap();
1302            let back: SessionCommand = serde_json::from_str(&json).unwrap();
1303            assert_eq!(serde_json::to_string(&back).unwrap(), json);
1304        }
1305        let cp: SessionCommand = serde_json::from_str(r#"{"cmd":"checkpoint","reason":"host_request"}"#).unwrap();
1306        assert!(matches!(cp, SessionCommand::Checkpoint { reason: CheckpointReason::HostRequest }));
1307    }
1308
1309    #[test]
1310    fn command_debug_redacts_bodies_without_lengths() {
1311        let secret = "hunter2-very-secret";
1312        let cmds = vec![
1313            SessionCommand::Submit { text: secret.into(), attachments: vec![] },
1314            SessionCommand::Steer { text: secret.into() },
1315            SessionCommand::SubmitPrepared {
1316                messages: vec![std::sync::Arc::new(serde_json::json!({"content": secret}))],
1317                user_text: Some(secret.into()),
1318            },
1319            SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1320            SessionCommand::Resume { id: 1, query: secret.into() },
1321            SessionCommand::DriverStart { plugin: "p".into(), command: "auto".into(), arg: secret.into() },
1322        ];
1323        for cmd in cmds {
1324            let d = format!("{cmd:?}");
1325            assert!(!d.contains(secret), "{d}");
1326            assert!(d.contains("<redacted>"), "{d}");
1327            assert!(!d.contains("chars"), "{d}");
1328            assert!(!d.contains(&secret.len().to_string()), "{d}");
1329        }
1330        let d = format!("{:?}", SessionCommand::Set { id: 4, setting: SessionSetting::ReloadPrompt });
1331        assert_eq!(d, "Set { id: 4, setting: ReloadPrompt }");
1332
1333        // Submit debug shows count+kinds, never base64.
1334        let b64_secret = "aHVudGVyMi12ZXJ5LXNlY3JldA==";
1335        let cmd = SessionCommand::Submit {
1336            text: "x".into(),
1337            attachments: vec![
1338                serde_json::json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":b64_secret}}),
1339                serde_json::json!({"type":"document","title":"x.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBER"}}),
1340            ],
1341        };
1342        let d = format!("{cmd:?}");
1343        assert!(d.contains(r#"2:["image", "document"]"#), "{d}");
1344        assert!(!d.contains(b64_secret), "base64 leaked: {d}");
1345    }
1346
1347    #[test]
1348    fn session_config_new_fields_default() {
1349        let cfg: SessionConfig = serde_json::from_str("{}").unwrap();
1350        assert_eq!(cfg.compaction_policy, CompactionPolicyWire::InPlace);
1351        assert!(cfg.await_extensions);
1352        assert!(!cfg.keep_warm);
1353        let cfg: SessionConfig =
1354            serde_json::from_str(r#"{"compaction_policy":"linked_successor","await_extensions":false}"#).unwrap();
1355        assert_eq!(cfg.compaction_policy, CompactionPolicyWire::LinkedSuccessor);
1356        assert!(!cfg.await_extensions);
1357    }
1358
1359    #[test]
1360    fn session_lifecycle_u8_round_trip() {
1361        for l in [SessionLifecycle::Live, SessionLifecycle::Parking, SessionLifecycle::Parked, SessionLifecycle::Ending] {
1362            assert_eq!(SessionLifecycle::from_u8(l as u8), l);
1363        }
1364        assert_eq!(serde_json::to_string(&SessionLifecycle::Parked).unwrap(), r#""parked""#);
1365    }
1366
1367    #[test]
1368    fn host_event_is_never_serialised() {
1369        let cmd = SessionCommand::HostEvent(HostEvent::LoaderProgress(
1370            crate::extensions::loader::ExtensionLoaderEvent::Started,
1371        ));
1372        assert!(serde_json::to_string(&cmd).is_err());
1373    }
1374}
1375
1376/// `Option<Option<String>>` on the wire without collapsing `Some(None)`:
1377/// absent → `None`; `{"latest": true}` → `Some(None)`; `"id"` → `Some(Some(id))`.
1378/// Older daemons/clients that sent a bare string or `null` still decode.
1379mod double_option {
1380    use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
1381
1382    #[derive(Serialize, Deserialize)]
1383    #[serde(untagged)]
1384    enum Wire {
1385        Latest { latest: bool },
1386        Id(String),
1387    }
1388
1389    pub fn serialize<S: Serializer>(v: &Option<Option<String>>, s: S) -> Result<S::Ok, S::Error> {
1390        match v {
1391            None => s.serialize_none(),
1392            Some(None) => Wire::Latest { latest: true }.serialize(s),
1393            Some(Some(id)) => Wire::Id(id.clone()).serialize(s),
1394        }
1395    }
1396
1397    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<String>>, D::Error> {
1398        match Option::<Wire>::deserialize(d)? {
1399            None => Ok(None),
1400            Some(Wire::Latest { latest: true }) => Ok(Some(None)),
1401            Some(Wire::Latest { latest: false }) => Err(de::Error::custom("continue_session: latest must be true")),
1402            Some(Wire::Id(id)) => Ok(Some(Some(id))),
1403        }
1404    }
1405
1406    #[cfg(test)]
1407    mod tests {
1408        use super::super::SessionConfig;
1409
1410        fn rt(v: Option<Option<String>>) -> Option<Option<String>> {
1411            let cfg = SessionConfig { continue_session: v, ..Default::default() };
1412            let j = serde_json::to_string(&cfg).unwrap();
1413            serde_json::from_str::<SessionConfig>(&j).unwrap().continue_session
1414        }
1415
1416        #[test]
1417        fn continue_session_round_trips_all_three_states() {
1418            assert_eq!(rt(None), None);
1419            assert_eq!(rt(Some(None)), Some(None), "bare --continue must survive the wire");
1420            assert_eq!(rt(Some(Some("abc".into()))), Some(Some("abc".into())));
1421        }
1422
1423        #[test]
1424        fn continue_session_accepts_legacy_encodings() {
1425            let old_null: SessionConfig = serde_json::from_str(r#"{"continue_session":null}"#).unwrap();
1426            assert_eq!(old_null.continue_session, None);
1427            let old_id: SessionConfig = serde_json::from_str(r#"{"continue_session":"xyz"}"#).unwrap();
1428            assert_eq!(old_id.continue_session, Some(Some("xyz".into())));
1429            let absent: SessionConfig = serde_json::from_str(r#"{}"#).unwrap();
1430            assert_eq!(absent.continue_session, None);
1431        }
1432    }
1433}