1use std::path::PathBuf;
24
25use serde::{Deserialize, Serialize};
26
27use super::types::*;
28use super::view::RuntimeView;
29use crate::{AgentEvent, LlmEvent, SessionEvent, StreamEvent, TurnError, TurnOutcome};
30
31pub const PROTOCOL_VERSION: u32 = 3;
48pub const PROTOCOL_MIN: u32 = PROTOCOL_VERSION;
50pub const PROTOCOL_MAX: u32 = PROTOCOL_VERSION;
51pub const RPC_MAX_FRAME_BYTES: usize = agent_core::core::rpc_dispatch::MAX_FRAME_BYTES;
53pub const DAEMON_MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
56pub const MAX_FRAME_BYTES: usize = DAEMON_MAX_FRAME_BYTES;
58pub const RESERVED_QUERY_ID_BASE: u64 = 1 << 63;
61pub const DIGEST_RESYNC_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE;
63pub const IDLE_PROBE_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE + 1;
65pub const CHECKPOINT_QUERY_ID: u64 = RESERVED_QUERY_ID_BASE + 2;
68pub const VIEW_CHANGED_SETTING_ID: u64 = RESERVED_QUERY_ID_BASE + 3;
73pub const CLOSE_QUERY_ID_BASE: u64 = RESERVED_QUERY_ID_BASE + (1 << 32);
78
79pub fn binary_version() -> String {
81 env!("CARGO_PKG_VERSION").to_string()
82}
83
84static BUILD_ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
85
86pub fn set_build_id(id: &str) {
92 let _ = BUILD_ID.set(id.trim().to_string());
93}
94
95pub fn build_id() -> &'static str {
99 BUILD_ID.get().map(String::as_str).unwrap_or("")
100}
101
102#[derive(Serialize, Deserialize)]
106#[serde(tag = "type", rename_all = "snake_case")]
107pub enum ClientFrame {
108 Hello(Hello),
109 Ping,
111 Sessions,
112 Shutdown {
113 #[serde(default)]
114 force: bool,
115 },
116 Attach(Attach),
118 Cmd {
119 session_id: SessionId,
120 cmd: SessionCommand,
121 },
122 Reload {
126 #[serde(default)]
127 now: bool,
128 #[serde(default)]
129 drain_secs: Option<u64>,
130 #[serde(default)]
131 exe: Option<PathBuf>,
132 },
133 Purge,
136 Close {
143 session_id: SessionId,
144 #[serde(default)]
145 force: bool,
146 #[serde(default)]
147 attached: bool,
148 },
149 Pin {
155 session_id: SessionId,
156 on: bool,
157 },
158 AuthState(agent_core::auth::AuthStateQuery),
164 ReloadExtension {
173 extension: String,
174 },
175 Bye,
176}
177
178impl std::fmt::Debug for ClientFrame {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 Self::Hello(h) => f.debug_tuple("Hello").field(h).finish(),
184 Self::Ping => f.write_str("Ping"),
185 Self::Sessions => f.write_str("Sessions"),
186 Self::Shutdown { force } => f.debug_struct("Shutdown").field("force", force).finish(),
187 Self::Attach(a) => f.debug_tuple("Attach").field(a).finish(),
188 Self::Cmd { session_id, cmd } => {
189 f.debug_struct("Cmd").field("session_id", session_id).field("cmd", cmd).finish()
190 }
191 Self::Reload { now, drain_secs, exe } => f
192 .debug_struct("Reload")
193 .field("now", now)
194 .field("drain_secs", drain_secs)
195 .field("exe", exe)
196 .finish(),
197 Self::Purge => f.write_str("Purge"),
198 Self::Close { session_id, force, attached } => f
199 .debug_struct("Close")
200 .field("session_id", session_id)
201 .field("force", force)
202 .field("attached", attached)
203 .finish(),
204 Self::Pin { session_id, on } => f
205 .debug_struct("Pin")
206 .field("session_id", session_id)
207 .field("on", on)
208 .finish(),
209 Self::AuthState(query) => f.debug_tuple("AuthState").field(query).finish(),
210 Self::ReloadExtension { extension } => f
211 .debug_struct("ReloadExtension")
212 .field("extension", extension)
213 .finish(),
214 Self::Bye => f.write_str("Bye"),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct Hello {
221 pub protocol_version: u32,
222 pub client: ClientMeta,
223 pub cwd: PathBuf,
224 pub client_version: String,
225 #[serde(default)]
229 pub env: Option<super::types::SessionEnv>,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub env_stripped: Vec<String>,
234 #[serde(default)]
236 pub reconnect_of: Option<ClientReconnect>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct ClientReconnect {
243 pub previous_client: ClientId,
244 pub session_id: SessionId,
245 pub was_owner: bool,
246 pub generation: u64,
247}
248
249impl Hello {
250 pub fn new(kind: ClientKind) -> Self {
251 let (env, env_stripped) = super::types::capture_client_env();
252 Self {
253 protocol_version: PROTOCOL_VERSION,
254 client: ClientMeta {
255 terminal: std::env::var("TERM").ok(),
256 ..ClientMeta::new(kind)
257 },
258 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
259 client_version: binary_version(),
260 env: Some(env),
261 env_stripped,
262 reconnect_of: None,
263 }
264 }
265
266 pub fn with_name(mut self, name: Option<String>) -> Self {
268 self.client.name = name;
269 self
270 }
271
272 pub fn with_history(mut self, history: HistoryMode) -> Self {
274 self.client.history = history;
275 self
276 }
277
278 pub fn with_tail_items(mut self, tail_items: usize) -> Self {
280 self.client.tail_items = tail_items;
281 self
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(tag = "attach", rename_all = "snake_case")]
287pub enum Attach {
288 Existing {
289 session_id: SessionId,
290 #[serde(default = "mirror")]
291 mode: AttachMode,
292 },
293 Create {
294 #[serde(default)]
295 config: SessionConfig,
296 #[serde(default = "mirror")]
297 mode: AttachMode,
298 },
299}
300
301fn mirror() -> AttachMode {
302 AttachMode::Mirror
303}
304
305#[derive(Debug, Serialize, Deserialize)]
308#[serde(tag = "type", rename_all = "snake_case")]
309#[allow(clippy::large_enum_variant)]
310pub enum DaemonFrame {
311 Welcome(Welcome),
312 Refused {
314 reason: RefuseReason,
315 message: String,
316 },
317 Pong {
318 pid: u32,
319 uptime_s: u64,
320 sessions: usize,
321 },
322 SessionList {
323 sessions: Vec<SessionMeta>,
324 },
325 Attached(AttachedWire),
326 Event(WireEnvelope),
327 Error {
328 session_id: Option<SessionId>,
329 message: String,
330 },
331 Closed {
334 session_id: SessionId,
335 text: String,
336 },
337 Pinned {
339 session_id: SessionId,
340 on: bool,
341 },
342 ExtensionReloaded {
344 report: crate::extensions::manager::ExtensionReloadReport,
345 },
346 ExtensionReloadFailed {
351 extension: String,
352 message: String,
353 #[serde(default)]
354 unloaded: bool,
355 },
356 Bye {
358 #[serde(default)]
359 reason: Option<ByeReason>,
360 },
361 AuthState {
364 report: agent_core::auth::AuthStateReport,
365 },
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369#[serde(tag = "bye", rename_all = "snake_case")]
370pub enum ByeReason {
371 Reloading { generation: u64, retry_after_ms: u64 },
372 Shutdown,
373 Detached,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(tag = "reason", rename_all = "snake_case")]
378pub enum RefuseReason {
379 Version {
380 daemon_version: u32,
381 min: u32,
382 max: u32,
383 },
384 Protocol,
385 Busy,
386 UnknownSession,
387 Config,
388 ReloadRefused { why: String },
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct Welcome {
395 pub protocol_version: u32,
396 pub daemon_version: String,
397 pub pid: u32,
398 pub profile: Option<String>,
399 pub sessions: Vec<SessionMeta>,
400 pub progressive_tool_disclosure: bool,
401 #[serde(default = "one")]
403 pub generation: u64,
404 #[serde(default)]
408 pub daemon_build: String,
409}
410
411fn one() -> u64 {
412 1
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct WireEnvelope {
417 pub session_id: SessionId,
418 pub seq: u64,
419 pub ts: chrono::DateTime<chrono::Utc>,
420 pub event: WireSessionEvent,
421}
422
423impl From<Envelope> for WireEnvelope {
424 fn from(e: Envelope) -> Self {
425 Self {
426 session_id: e.session_id,
427 seq: e.seq,
428 ts: e.ts,
429 event: e.event.into(),
430 }
431 }
432}
433
434impl From<WireEnvelope> for Envelope {
435 fn from(e: WireEnvelope) -> Self {
436 Self {
437 session_id: e.session_id,
438 seq: e.seq,
439 ts: e.ts,
440 event: e.event.into(),
441 }
442 }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct AttachedWire {
448 pub client: ClientId,
449 pub meta: SessionMeta,
450 pub view: RuntimeView,
451 pub conversation: ConversationSnapshot,
452 pub streaming: bool,
453 pub replay: Vec<WireEnvelope>,
454 pub pending_prompts: Vec<PromptRequest>,
455 pub clients: Vec<(ClientId, ClientKind)>,
456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub roster: Vec<super::types::ClientInfo>,
459 #[serde(default = "ClientInput::wire_default")]
461 pub client_input: ClientInput,
462 #[serde(default)]
463 pub input_owner: Option<ClientId>,
464 #[serde(default)]
465 pub display_tail: Option<crate::session::display::DisplayTail>,
466}
467
468impl AttachedWire {
469 pub fn new(client: ClientId, s: AttachSnapshot) -> Self {
470 Self {
471 client,
472 meta: s.meta,
473 view: s.view,
474 conversation: s.conversation,
475 streaming: s.streaming,
476 replay: s.replay.into_iter().map(Into::into).collect(),
477 pending_prompts: s.pending_prompts,
478 clients: s.clients,
479 roster: s.roster,
480 client_input: s.client_input,
481 input_owner: s.input_owner,
482 display_tail: s.display_tail,
483 }
484 }
485
486 pub fn into_snapshot(self) -> (ClientId, AttachSnapshot) {
487 (
488 self.client,
489 AttachSnapshot {
490 meta: self.meta,
491 view: self.view,
492 conversation: self.conversation,
493 streaming: self.streaming,
494 replay: self.replay.into_iter().map(Into::into).collect(),
495 pending_prompts: self.pending_prompts,
496 clients: self.clients,
497 roster: self.roster,
498 client_input: self.client_input,
499 input_owner: self.input_owner,
500 display_tail: self.display_tail,
501 },
502 )
503 }
504}
505
506#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512pub struct ConversationDigest {
513 #[serde(default)]
514 pub header: SessionHeader,
515 pub messages_len: usize,
516 pub messages_hash: u64,
517 pub tokens: ConversationTokens,
518 pub cost: f64,
519 #[serde(default)]
520 pub keepwarm_cost: f64,
521 #[serde(default)]
522 pub keepwarm_tokens: ConversationTokens,
523 pub abort_context: Option<String>,
524 pub queued_message: Option<String>,
525 pub pending_events_len: usize,
526 pub consecutive_auto_turns: u32,
527}
528
529pub fn messages_hash(messages: &[crate::SharedMessage]) -> u64 {
532 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
533 let mut feed = |b: u8| {
534 h ^= b as u64;
535 h = h.wrapping_mul(0x0000_0100_0000_01b3);
536 };
537 for m in messages {
538 if let Ok(bytes) = serde_json::to_vec(&**m) {
539 for b in bytes {
540 feed(b);
541 }
542 }
543 feed(0x1f);
544 }
545 h
546}
547
548impl ConversationDigest {
549 pub fn of(s: &ConversationSnapshot) -> Self {
550 Self {
551 header: s.header.clone(),
552 messages_len: s.api_messages.len(),
553 messages_hash: s.messages_hash_memo.hash(&s.api_messages),
554 tokens: s.tokens.clone(),
555 cost: s.cost,
556 keepwarm_cost: s.keepwarm_cost,
557 keepwarm_tokens: s.keepwarm_tokens.clone(),
558 abort_context: s.abort_context.clone(),
559 queued_message: s.queued_message.clone(),
560 pending_events_len: s.pending_events_len,
561 consecutive_auto_turns: s.consecutive_auto_turns,
562 }
563 }
564
565 pub fn matches(&self, messages: &[crate::SharedMessage]) -> bool {
566 self.messages_len == messages.len() && self.messages_hash == messages_hash(messages)
567 }
568
569 pub fn into_snapshot(self, api_messages: Vec<crate::SharedMessage>) -> ConversationSnapshot {
571 ConversationSnapshot {
572 header: self.header,
573 messages_len: self.messages_len,
574 api_messages,
575 tokens: self.tokens,
576 cost: self.cost,
577 keepwarm_cost: self.keepwarm_cost,
578 keepwarm_tokens: self.keepwarm_tokens,
579 abort_context: self.abort_context,
580 queued_message: self.queued_message,
581 pending_events_len: self.pending_events_len,
582 consecutive_auto_turns: self.consecutive_auto_turns,
583 messages_hash_memo: Default::default(),
584 }
585 }
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
591#[serde(tag = "ev", rename_all = "snake_case")]
592#[allow(clippy::large_enum_variant)] pub enum WireSessionEvent {
594 Stream { event: WireStreamEvent },
595 TurnStarted {
596 turn_baseline: usize,
597 trigger: TurnTrigger,
598 #[serde(default)]
599 user_text: Option<String>,
600 },
601 Conversation { digest: ConversationDigest },
603 Prompt { request: PromptRequest },
604 PromptResolved { prompt_id: u64 },
605 External { event: crate::events::types::Event },
606 AutoTurnCapReached { cap: u32 },
607 Idle,
608 Steered { text: String, delivered: bool },
609 Dequeued { text: String },
610 SystemNotice { text: String },
611 LoaderProgress { event: WireLoaderEvent },
612 ExtensionNotification { extension_id: String, method: String, params: serde_json::Value },
613 SettingChanged { applied: SettingApplied },
614 QueryResult { id: u64, value: serde_json::Value },
615 Attached { client: ClientId, snapshot: AttachedWire },
616 ClientJoined {
617 client: ClientId,
618 kind: ClientKind,
619 #[serde(default)]
621 name: String,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
624 mode: Option<AttachMode>,
625 },
626 ClientLeft { client: ClientId },
627 Ended { reason: EndReason },
628 Aborted { context_saved: bool },
629 Cleared { session_id: String },
630 CompactionStarted { source: String, disclosure: String },
631 CompactionApplied {
632 previous_session_id: String,
633 session_id: String,
634 chains_advanced: Vec<String>,
635 queued_restored: Option<String>,
636 msg_count: usize,
637 },
638 CompactionFailed { message: String, panicked: bool },
639 CompactionCancelled,
640 SubagentRows { rows: Vec<crate::runtime::subagent::SubagentDisplayRow> },
641 Resumed {
642 id: u64,
643 old_id: String,
644 new_id: String,
645 via: Option<String>,
646 clamp_notice: Option<String>,
647 },
648 InputOwnerChanged { from: Option<ClientId>, to: Option<ClientId>, reason: OwnerChangeReason },
649 Refused { client: ClientId, command: String, reason: String },
650 AttachRefused { message: String },
651 Lifecycle { lifecycle: SessionLifecycle },
652 Reloading { generation: u64, retry_after_ms: u64 },
653 DriverArmed {
655 plugin_id: String,
656 run_id: String,
657 models: Vec<crate::extensions::session_driver::Selection>,
658 selection: crate::extensions::session_driver::Selection,
659 #[serde(default)]
660 deadline_ms: Option<u64>,
661 #[serde(default)]
662 notice: String,
663 },
664 DriverRevoked {
666 reason: String,
667 #[serde(default)]
668 undelivered_steering: Vec<String>,
669 },
670 DriverTurnOutcome {
672 outcome: crate::extensions::session_driver::Outcome,
673 selection: crate::extensions::session_driver::Selection,
674 #[serde(default)]
675 feedback: Option<String>,
676 },
677 CostCapReached {
679 scope: String,
680 cost: f64,
681 cap: f64,
682 },
683 #[serde(other)]
685 Unknown,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize)]
689#[serde(tag = "kind", rename_all = "snake_case")]
690pub enum WireStreamEvent {
691 Llm(WireLlmEvent),
692 Session(WireSessionStreamEvent),
693 Agent(WireAgentEvent),
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697#[serde(tag = "llm", rename_all = "snake_case")]
698pub enum WireLlmEvent {
699 Thinking { text: String },
700 Text { text: String },
701 ToolUseStart { tool_name: String, tool_id: String },
702 ToolUseDelta { tool_id: String, delta: String },
703 ToolUse { tool_name: String, tool_id: String, input: serde_json::Value },
704 ToolResult { tool_id: String, result: String },
705 ToolResultDelta { tool_id: String, delta: String },
706 ResponseStart,
707 ResponseReset,
708 ToolQueued { tool_id: String },
713 ToolStarted { tool_id: String },
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize)]
717#[serde(tag = "session", rename_all = "snake_case")]
718pub enum WireSessionStreamEvent {
719 MessageHistory { messages: Vec<crate::SharedMessage> },
720 Usage {
721 input_tokens: u64,
722 output_tokens: u64,
723 cache_read_input_tokens: u64,
724 cache_creation_input_tokens: u64,
725 cache_creation_5m: Option<u64>,
726 cache_creation_1h: Option<u64>,
727 model: Option<String>,
728 },
729 Done,
730 Error { message: String, outcome: TurnOutcome },
731 Notice {
735 text: String,
736 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
737 retry: bool,
738 },
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
742#[serde(tag = "agent", rename_all = "snake_case")]
743pub enum WireAgentEvent {
744 SubagentStart { subagent_id: u64, agent_name: String, task_preview: String },
745 SubagentUpdate { subagent_id: u64, agent_name: String, status: String },
746 SubagentDone { subagent_id: u64, agent_name: String, result_preview: String, duration_secs: f64 },
747 SteeringDelivered { message: String },
748}
749
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct WireLoadFailure {
752 pub plugin: String,
753 pub manifest_path: Option<PathBuf>,
754 pub reason: String,
755 pub hint: String,
756}
757
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(tag = "loader", rename_all = "snake_case")]
760pub enum WireLoaderEvent {
761 Started,
762 Loaded { plugin: String, loaded: usize, failed: usize },
763 Failed { failure: WireLoadFailure, loaded: usize, failed: usize },
764 Finished { loaded: Vec<String>, failed: Vec<WireLoadFailure> },
765}
766
767type LoadFailure = crate::extensions::manager::ExtensionLoadFailure;
768type LoaderEvent = crate::extensions::loader::ExtensionLoaderEvent;
769
770impl From<LoadFailure> for WireLoadFailure {
771 fn from(f: LoadFailure) -> Self {
772 Self { plugin: f.plugin, manifest_path: f.manifest_path, reason: f.reason, hint: f.hint }
773 }
774}
775impl From<WireLoadFailure> for LoadFailure {
776 fn from(f: WireLoadFailure) -> Self {
777 Self { plugin: f.plugin, manifest_path: f.manifest_path, reason: f.reason, hint: f.hint }
778 }
779}
780impl From<LoaderEvent> for WireLoaderEvent {
781 fn from(e: LoaderEvent) -> Self {
782 match e {
783 LoaderEvent::Started => Self::Started,
784 LoaderEvent::Loaded { plugin, loaded, failed } => Self::Loaded { plugin, loaded, failed },
785 LoaderEvent::Failed { failure, loaded, failed } => {
786 Self::Failed { failure: failure.into(), loaded, failed }
787 }
788 LoaderEvent::Finished { loaded, failed } => Self::Finished {
789 loaded,
790 failed: failed.into_iter().map(Into::into).collect(),
791 },
792 }
793 }
794}
795impl From<WireLoaderEvent> for LoaderEvent {
796 fn from(e: WireLoaderEvent) -> Self {
797 match e {
798 WireLoaderEvent::Started => Self::Started,
799 WireLoaderEvent::Loaded { plugin, loaded, failed } => Self::Loaded { plugin, loaded, failed },
800 WireLoaderEvent::Failed { failure, loaded, failed } => {
801 Self::Failed { failure: failure.into(), loaded, failed }
802 }
803 WireLoaderEvent::Finished { loaded, failed } => Self::Finished {
804 loaded,
805 failed: failed.into_iter().map(Into::into).collect(),
806 },
807 }
808 }
809}
810
811impl From<StreamEvent> for WireStreamEvent {
812 fn from(e: StreamEvent) -> Self {
813 match e {
814 StreamEvent::Llm(l) => Self::Llm(match l {
815 LlmEvent::Thinking(text) => WireLlmEvent::Thinking { text },
816 LlmEvent::Text(text) => WireLlmEvent::Text { text },
817 LlmEvent::ToolUseStart { tool_name, tool_id } => WireLlmEvent::ToolUseStart { tool_name, tool_id },
818 LlmEvent::ToolUseDelta { tool_id, delta } => WireLlmEvent::ToolUseDelta { tool_id, delta },
819 LlmEvent::ToolUse { tool_name, tool_id, input } => WireLlmEvent::ToolUse { tool_name, tool_id, input },
820 LlmEvent::ToolResult { tool_id, result } => WireLlmEvent::ToolResult { tool_id, result },
821 LlmEvent::ToolResultDelta { tool_id, delta } => WireLlmEvent::ToolResultDelta { tool_id, delta },
822 LlmEvent::ResponseStart => WireLlmEvent::ResponseStart,
823 LlmEvent::ResponseReset => WireLlmEvent::ResponseReset,
824 LlmEvent::ToolQueued { tool_id } => WireLlmEvent::ToolQueued { tool_id },
825 LlmEvent::ToolStarted { tool_id } => WireLlmEvent::ToolStarted { tool_id },
826 }),
827 StreamEvent::Session(s) => Self::Session(match s {
828 SessionEvent::MessageHistory(messages) => WireSessionStreamEvent::MessageHistory { messages },
829 SessionEvent::Usage {
830 input_tokens,
831 output_tokens,
832 cache_read_input_tokens,
833 cache_creation_input_tokens,
834 cache_creation_5m,
835 cache_creation_1h,
836 model,
837 } => WireSessionStreamEvent::Usage {
838 input_tokens,
839 output_tokens,
840 cache_read_input_tokens,
841 cache_creation_input_tokens,
842 cache_creation_5m,
843 cache_creation_1h,
844 model,
845 },
846 SessionEvent::Done => WireSessionStreamEvent::Done,
847 SessionEvent::Error(TurnError { message, outcome }) => WireSessionStreamEvent::Error { message, outcome },
848 SessionEvent::Notice(text) => WireSessionStreamEvent::Notice { text, retry: false },
849 SessionEvent::Retry(text) => WireSessionStreamEvent::Notice { text, retry: true },
850 SessionEvent::ContextHeadCheckpoint { .. } => WireSessionStreamEvent::Done,
853 }),
854 StreamEvent::Agent(a) => Self::Agent(match a {
855 AgentEvent::SubagentStart { subagent_id, agent_name, task_preview } => {
856 WireAgentEvent::SubagentStart { subagent_id, agent_name, task_preview }
857 }
858 AgentEvent::SubagentUpdate { subagent_id, agent_name, status } => {
859 WireAgentEvent::SubagentUpdate { subagent_id, agent_name, status }
860 }
861 AgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs } => {
862 WireAgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs }
863 }
864 AgentEvent::SteeringDelivered { message } => WireAgentEvent::SteeringDelivered { message },
865 }),
866 }
867 }
868}
869
870impl From<WireStreamEvent> for StreamEvent {
871 fn from(e: WireStreamEvent) -> Self {
872 match e {
873 WireStreamEvent::Llm(l) => Self::Llm(match l {
874 WireLlmEvent::Thinking { text } => LlmEvent::Thinking(text),
875 WireLlmEvent::Text { text } => LlmEvent::Text(text),
876 WireLlmEvent::ToolUseStart { tool_name, tool_id } => LlmEvent::ToolUseStart { tool_name, tool_id },
877 WireLlmEvent::ToolUseDelta { tool_id, delta } => LlmEvent::ToolUseDelta { tool_id, delta },
878 WireLlmEvent::ToolUse { tool_name, tool_id, input } => LlmEvent::ToolUse { tool_name, tool_id, input },
879 WireLlmEvent::ToolResult { tool_id, result } => LlmEvent::ToolResult { tool_id, result },
880 WireLlmEvent::ToolResultDelta { tool_id, delta } => LlmEvent::ToolResultDelta { tool_id, delta },
881 WireLlmEvent::ResponseStart => LlmEvent::ResponseStart,
882 WireLlmEvent::ResponseReset => LlmEvent::ResponseReset,
883 WireLlmEvent::ToolQueued { tool_id } => LlmEvent::ToolQueued { tool_id },
884 WireLlmEvent::ToolStarted { tool_id } => LlmEvent::ToolStarted { tool_id },
885 }),
886 WireStreamEvent::Session(s) => Self::Session(match s {
887 WireSessionStreamEvent::MessageHistory { messages } => SessionEvent::MessageHistory(messages),
888 WireSessionStreamEvent::Usage {
889 input_tokens,
890 output_tokens,
891 cache_read_input_tokens,
892 cache_creation_input_tokens,
893 cache_creation_5m,
894 cache_creation_1h,
895 model,
896 } => SessionEvent::Usage {
897 input_tokens,
898 output_tokens,
899 cache_read_input_tokens,
900 cache_creation_input_tokens,
901 cache_creation_5m,
902 cache_creation_1h,
903 model,
904 },
905 WireSessionStreamEvent::Done => SessionEvent::Done,
906 WireSessionStreamEvent::Error { message, outcome } => SessionEvent::Error(TurnError { message, outcome }),
907 WireSessionStreamEvent::Notice { text, retry: false } => SessionEvent::Notice(text),
908 WireSessionStreamEvent::Notice { text, retry: true } => SessionEvent::Retry(text),
909 }),
910 WireStreamEvent::Agent(a) => Self::Agent(match a {
911 WireAgentEvent::SubagentStart { subagent_id, agent_name, task_preview } => {
912 AgentEvent::SubagentStart { subagent_id, agent_name, task_preview }
913 }
914 WireAgentEvent::SubagentUpdate { subagent_id, agent_name, status } => {
915 AgentEvent::SubagentUpdate { subagent_id, agent_name, status }
916 }
917 WireAgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs } => {
918 AgentEvent::SubagentDone { subagent_id, agent_name, result_preview, duration_secs }
919 }
920 WireAgentEvent::SteeringDelivered { message } => AgentEvent::SteeringDelivered { message },
921 }),
922 }
923 }
924}
925
926impl From<SessionEventWire> for WireSessionEvent {
927 fn from(e: SessionEventWire) -> Self {
928 use SessionEventWire as S;
929 match e {
930 S::Stream(ev) => Self::Stream { event: ev.into() },
931 S::TurnStarted { turn_baseline, trigger, user_text } => {
932 Self::TurnStarted { turn_baseline, trigger, user_text }
933 }
934 S::Conversation(snapshot) => Self::Conversation { digest: ConversationDigest::of(&snapshot) },
935 S::Prompt(request) => Self::Prompt { request },
936 S::PromptResolved { prompt_id } => Self::PromptResolved { prompt_id },
937 S::External(event) => Self::External { event },
938 S::AutoTurnCapReached { cap } => Self::AutoTurnCapReached { cap },
939 S::Idle => Self::Idle,
940 S::Steered { text, delivered } => Self::Steered { text, delivered },
941 S::Dequeued { text } => Self::Dequeued { text },
942 S::SystemNotice(text) => Self::SystemNotice { text },
943 S::LoaderProgress(ev) => Self::LoaderProgress { event: ev.into() },
944 S::ExtensionNotification { extension_id, method, params } => {
945 Self::ExtensionNotification { extension_id, method, params }
946 }
947 S::SettingChanged(applied) => Self::SettingChanged { applied },
948 S::QueryResult { id, value } => Self::QueryResult { id, value },
949 S::Attached { client, snapshot } => Self::Attached { client, snapshot: AttachedWire::new(client, snapshot) },
950 S::ClientJoined { client, kind, name, mode } => Self::ClientJoined { client, kind, name, mode: Some(mode) },
951 S::ClientLeft { client } => Self::ClientLeft { client },
952 S::Ended { reason } => Self::Ended { reason },
953 S::Aborted { context_saved } => Self::Aborted { context_saved },
954 S::Cleared { session_id } => Self::Cleared { session_id },
955 S::CompactionStarted { source, disclosure } => Self::CompactionStarted { source, disclosure },
956 S::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count } => {
957 Self::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count }
958 }
959 S::CompactionFailed { message, panicked } => Self::CompactionFailed { message, panicked },
960 S::CompactionCancelled => Self::CompactionCancelled,
961 S::SubagentRows(rows) => Self::SubagentRows { rows },
962 S::Resumed { id, old_id, new_id, via, clamp_notice } => {
963 Self::Resumed { id, old_id, new_id, via, clamp_notice }
964 }
965 S::InputOwnerChanged { from, to, reason } => Self::InputOwnerChanged { from, to, reason },
966 S::Refused { client, command, reason } => Self::Refused { client, command, reason },
967 S::AttachRefused { message } => Self::AttachRefused { message },
968 S::Lifecycle(lifecycle) => Self::Lifecycle { lifecycle },
969 S::Reloading { generation, retry_after_ms } => Self::Reloading { generation, retry_after_ms },
970 S::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice } => {
971 Self::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice }
972 }
973 S::DriverRevoked { reason, undelivered_steering } => {
974 Self::DriverRevoked { reason, undelivered_steering }
975 }
976 S::DriverTurnOutcome { outcome, selection, feedback } => {
977 Self::DriverTurnOutcome { outcome, selection, feedback }
978 }
979 S::CostCapReached { scope, cost, cap } => {
980 Self::CostCapReached { scope, cost, cap }
981 }
982 }
983 }
984}
985
986impl From<WireSessionEvent> for SessionEventWire {
987 fn from(e: WireSessionEvent) -> Self {
988 use WireSessionEvent as W;
989 match e {
990 W::Stream { event } => Self::Stream(event.into()),
991 W::TurnStarted { turn_baseline, trigger, user_text } => {
992 Self::TurnStarted { turn_baseline, trigger, user_text }
993 }
994 W::Conversation { digest } => Self::Conversation(digest.into_snapshot(Vec::new())),
997 W::Prompt { request } => Self::Prompt(request),
998 W::PromptResolved { prompt_id } => Self::PromptResolved { prompt_id },
999 W::External { event } => Self::External(event),
1000 W::AutoTurnCapReached { cap } => Self::AutoTurnCapReached { cap },
1001 W::Idle => Self::Idle,
1002 W::Steered { text, delivered } => Self::Steered { text, delivered },
1003 W::Dequeued { text } => Self::Dequeued { text },
1004 W::SystemNotice { text } => Self::SystemNotice(text),
1005 W::LoaderProgress { event } => Self::LoaderProgress(event.into()),
1006 W::ExtensionNotification { extension_id, method, params } => {
1007 Self::ExtensionNotification { extension_id, method, params }
1008 }
1009 W::SettingChanged { applied } => Self::SettingChanged(applied),
1010 W::QueryResult { id, value } => Self::QueryResult { id, value },
1011 W::Attached { snapshot, .. } => {
1012 let (client, snapshot) = snapshot.into_snapshot();
1013 Self::Attached { client, snapshot }
1014 }
1015 W::ClientJoined { client, kind, name, mode } => {
1016 Self::ClientJoined { client, kind, name, mode: mode.unwrap_or(AttachMode::Mirror) }
1017 }
1018 W::ClientLeft { client } => Self::ClientLeft { client },
1019 W::Ended { reason } => Self::Ended { reason },
1020 W::Aborted { context_saved } => Self::Aborted { context_saved },
1021 W::Cleared { session_id } => Self::Cleared { session_id },
1022 W::CompactionStarted { source, disclosure } => Self::CompactionStarted { source, disclosure },
1023 W::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count } => {
1024 Self::CompactionApplied { previous_session_id, session_id, chains_advanced, queued_restored, msg_count }
1025 }
1026 W::CompactionFailed { message, panicked } => Self::CompactionFailed { message, panicked },
1027 W::CompactionCancelled => Self::CompactionCancelled,
1028 W::SubagentRows { rows } => Self::SubagentRows(rows),
1029 W::Resumed { id, old_id, new_id, via, clamp_notice } => {
1030 Self::Resumed { id, old_id, new_id, via, clamp_notice }
1031 }
1032 W::InputOwnerChanged { from, to, reason } => Self::InputOwnerChanged { from, to, reason },
1033 W::Refused { client, command, reason } => Self::Refused { client, command, reason },
1034 W::AttachRefused { message } => Self::AttachRefused { message },
1035 W::Lifecycle { lifecycle } => Self::Lifecycle(lifecycle),
1036 W::Reloading { generation, retry_after_ms } => Self::Reloading { generation, retry_after_ms },
1037 W::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice } => {
1038 Self::DriverArmed { plugin_id, run_id, models, selection, deadline_ms, notice }
1039 }
1040 W::DriverRevoked { reason, undelivered_steering } => {
1041 Self::DriverRevoked { reason, undelivered_steering }
1042 }
1043 W::DriverTurnOutcome { outcome, selection, feedback } => {
1044 Self::DriverTurnOutcome { outcome, selection, feedback }
1045 }
1046 W::CostCapReached { scope, cost, cap } => {
1047 Self::CostCapReached { scope, cost, cap }
1048 }
1049 W::Unknown => Self::SystemNotice("unknown event from a newer daemon (ignored)".into()),
1050 }
1051 }
1052}
1053
1054pub fn legacy_shared(caps: ClientCapabilities, client_input: ClientInput) -> bool {
1065 client_input == ClientInput::Shared && !caps.owner_reason_input
1066}
1067
1068pub fn client_decodes(caps: ClientCapabilities, event: &SessionEventWire) -> bool {
1077 match event {
1078 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { .. } | LlmEvent::ToolStarted { .. })) => {
1079 caps.tool_queue
1080 }
1081 _ => true,
1082 }
1083}
1084
1085pub fn frame_limit_msg() -> String {
1089 format!("frame exceeds {} MiB limit", DAEMON_MAX_FRAME_BYTES / (1024 * 1024))
1090}
1091
1092pub fn encode_line<T: Serialize>(frame: &T) -> Result<String, String> {
1095 let mut s = serde_json::to_string(frame).map_err(|e| e.to_string())?;
1096 if s.len() + 1 > DAEMON_MAX_FRAME_BYTES {
1097 return Err(frame_limit_msg());
1098 }
1099 s.push('\n');
1100 Ok(s)
1101}
1102
1103pub fn decode_line<'a, T: Deserialize<'a>>(line: &'a str) -> Result<T, String> {
1105 if line.len() > DAEMON_MAX_FRAME_BYTES {
1106 return Err(frame_limit_msg());
1107 }
1108 serde_json::from_str(line).map_err(|e| e.to_string())
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use crate::BudgetDimension;
1115 use std::sync::Arc;
1116
1117 fn view() -> RuntimeView {
1118 crate::session::handle::echo::view_for()
1119 }
1120
1121 fn auth_state_report() -> agent_core::auth::AuthStateReport {
1123 use agent_core::auth::auth_state::{AccountState, CooldownReason, CooldownState, IdentityKind, ObservedOutcome, SelectionState};
1124 use agent_core::auth::quota_policy::{
1125 AccountFailure, AccountFailureKind, ObservationFailure, SelectionFailure, SelectionFailureKind,
1126 };
1127 let usage_429 = ObservationFailure { kind: AccountFailureKind::UsageRateLimited, http_status: Some(429), retry_after_s: Some(120) };
1128 agent_core::auth::AuthStateReport {
1129 schema_version: agent_core::auth::AUTH_STATE_SCHEMA_VERSION,
1130 live: true,
1131 broker_active: true,
1132 generated_at_ms: 1_800_000_000_000,
1133 store: "/scratch/auth.json".into(),
1134 accounts: vec![AccountState {
1135 provider: "anthropic".into(),
1136 label: "a".into(),
1137 identity: IdentityKind::Stable,
1138 access_expires_ms: Some(1_800_000_600_000),
1139 cooldown: Some(CooldownState { until_ms: 1_800_003_600_000, reason: CooldownReason::AnthropicQuotaExhausted }),
1140 last_usage: Some(ObservedOutcome { at_ms: 1_799_999_990_000, failure: Some(usage_429.clone()) }),
1141 last_usage_ok_ms: Some(1_799_999_000_000),
1142 last_refresh: Some(ObservedOutcome { at_ms: 1_799_999_500_000, failure: None }),
1143 }],
1144 selections: vec![SelectionState {
1145 provider: "anthropic".into(),
1146 model: Some("claude-x".into()),
1147 at_ms: 1_799_999_990_000,
1148 chosen: None,
1149 failure: Some(SelectionFailure {
1150 kind: SelectionFailureKind::UsageReadFailed,
1151 accounts: vec![AccountFailure {
1152 account: "a".into(),
1153 kind: AccountFailureKind::UsageRateLimited,
1154 http_status: Some(429),
1155 retry_after_s: Some(120),
1156 until_ms: None,
1157 cooling_down: false,
1158 }],
1159 earliest_reset_ms: None,
1160 usage_read_retry_ms: None,
1161 }),
1162 vend_failure: None,
1163 }],
1164 cleared: vec!["anthropic:b".into()],
1165 }
1166 }
1167
1168 fn meta() -> SessionMeta {
1169 crate::session::handle::echo::meta_for(&SessionId::from("wire-1"))
1170 }
1171
1172 fn prompt() -> PromptRequest {
1173 PromptRequest {
1174 id: 3,
1175 kind: PromptKind::Secret,
1176 title: "sudo".into(),
1177 prompt: "password".into(),
1178 raised_at: chrono::Utc::now(),
1179 confirm_scope: None,
1180 }
1181 }
1182
1183 fn conv() -> ConversationSnapshot {
1184 ConversationSnapshot {
1185 header: SessionHeader {
1186 id: "wire-1".into(),
1187 title: "t".into(),
1188 name: None,
1189 model: "m".into(),
1190 thinking_level: "off".into(),
1191 system_prompt: Some("sys".into()),
1192 created_at: chrono::Utc::now(),
1193 updated_at: chrono::Utc::now(),
1194 parent_session: Some("p".into()),
1195 },
1196 api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":"hi"}))],
1197 messages_len: 1,
1198 tokens: ConversationTokens { input: 1, output: 2, cache_read: 3, cache_creation: 4 },
1199 cost: 0.5,
1200 keepwarm_cost: 0.125,
1201 keepwarm_tokens: ConversationTokens { input: 0, output: 0, cache_read: 9, cache_creation: 0 },
1202 abort_context: Some("ctx".into()),
1203 queued_message: None,
1204 pending_events_len: 2,
1205 consecutive_auto_turns: 1,
1206 messages_hash_memo: Default::default(),
1207 }
1208 }
1209
1210 #[test]
1214 fn conversation_digest_hash_is_computed_once_and_never_stale() {
1215 let a = conv();
1216 let b = a.clone();
1217 assert_eq!(ConversationDigest::of(&a), ConversationDigest::of(&b));
1218 assert!(a.messages_hash_memo.0.lock().unwrap().is_some(), "memo shared");
1219 assert_eq!(
1220 ConversationDigest::of(&b).messages_hash,
1221 messages_hash(&a.api_messages)
1222 );
1223 let mut c = a.clone();
1224 c.api_messages = Vec::new();
1225 assert_eq!(ConversationDigest::of(&c).messages_hash, messages_hash(&[]));
1226 let mut d = a.clone();
1227 d.api_messages.push(Arc::new(serde_json::json!({"role":"assistant","content":"yo"})));
1228 assert_eq!(
1229 ConversationDigest::of(&d).messages_hash,
1230 messages_hash(&d.api_messages)
1231 );
1232 }
1233
1234 fn env(event: SessionEventWire) -> Envelope {
1235 Envelope { session_id: SessionId::from("wire-1"), seq: 9, ts: chrono::Utc::now(), event }
1236 }
1237
1238 fn stream_fixtures() -> Vec<StreamEvent> {
1239 vec![
1240 StreamEvent::Llm(LlmEvent::Thinking("t".into())),
1241 StreamEvent::Llm(LlmEvent::Text("x\ny".into())),
1242 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name: "bash".into(), tool_id: "id1".into() }),
1243 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id: "id1".into(), delta: "{\"c".into() }),
1244 StreamEvent::Llm(LlmEvent::ToolUse {
1245 tool_name: "bash".into(),
1246 tool_id: "id1".into(),
1247 input: serde_json::json!({"command":"pwd"}),
1248 }),
1249 StreamEvent::Llm(LlmEvent::ToolResult { tool_id: "id1".into(), result: "/tmp".into() }),
1250 StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id: "id1".into(), delta: "/t".into() }),
1251 StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "id2".into() }),
1252 StreamEvent::Llm(LlmEvent::ToolStarted { tool_id: "id2".into() }),
1253 StreamEvent::Session(SessionEvent::MessageHistory(vec![Arc::new(serde_json::json!({"role":"user","content":"a"}))])),
1254 StreamEvent::Session(SessionEvent::Usage {
1255 input_tokens: 1,
1256 output_tokens: 2,
1257 cache_read_input_tokens: 3,
1258 cache_creation_input_tokens: 4,
1259 cache_creation_5m: Some(5),
1260 cache_creation_1h: None,
1261 model: Some("m".into()),
1262 }),
1263 StreamEvent::Session(SessionEvent::Done),
1264 StreamEvent::Session(SessionEvent::Error(TurnError::provider("boom", "auth_error", "turn-1-0"))),
1265 StreamEvent::Session(SessionEvent::Error(TurnError::budget(BudgetDimension::ToolCalls))),
1266 StreamEvent::Session(SessionEvent::Error(TurnError { message: "c".into(), outcome: TurnOutcome::Canceled })),
1267 StreamEvent::Session(SessionEvent::Notice("n".into())),
1268 StreamEvent::Session(SessionEvent::Retry("r".into())),
1269 StreamEvent::Agent(AgentEvent::SubagentStart { subagent_id: 1, agent_name: "a".into(), task_preview: "p".into() }),
1270 StreamEvent::Agent(AgentEvent::SubagentUpdate { subagent_id: 1, agent_name: "a".into(), status: "s".into() }),
1271 StreamEvent::Agent(AgentEvent::SubagentDone {
1272 subagent_id: 1,
1273 agent_name: "a".into(),
1274 result_preview: "r".into(),
1275 duration_secs: 1.5,
1276 }),
1277 StreamEvent::Agent(AgentEvent::SteeringDelivered { message: "m".into() }),
1278 ]
1279 }
1280
1281 #[test]
1285 fn a_retry_is_a_flagged_notice_on_the_wire() {
1286 let retry = StreamEvent::Session(SessionEvent::Retry("⚠ Rate limited — retrying (1/5)".into()));
1287 let json = serde_json::to_value(WireStreamEvent::from(retry)).unwrap();
1288 assert_eq!(json["session"], "notice", "{json}");
1289 assert_eq!(json["retry"], true, "{json}");
1290 let back: WireStreamEvent = serde_json::from_value(json).unwrap();
1291 assert!(
1292 matches!(StreamEvent::from(back), StreamEvent::Session(SessionEvent::Retry(t)) if t.starts_with("⚠ Rate limited")),
1293 "a retry stays a retry"
1294 );
1295 let plain = StreamEvent::Session(SessionEvent::Notice("n".into()));
1296 let json = serde_json::to_value(WireStreamEvent::from(plain)).unwrap();
1297 assert!(json.get("retry").is_none(), "a plain notice is unchanged: {json}");
1298 let old: WireStreamEvent =
1300 serde_json::from_value(serde_json::json!({"kind": "session", "session": "notice", "text": "x"})).unwrap();
1301 assert!(matches!(StreamEvent::from(old), StreamEvent::Session(SessionEvent::Notice(t)) if t == "x"));
1302 }
1303
1304 fn fixtures() -> Vec<SessionEventWire> {
1305 use SessionEventWire as S;
1306 let loader = crate::extensions::loader::ExtensionLoaderEvent::Finished {
1307 loaded: vec!["p".into()],
1308 failed: vec![LoadFailure {
1309 plugin: "bad".into(),
1310 manifest_path: Some(PathBuf::from("/x/manifest.json")),
1311 reason: "oops".into(),
1312 hint: "fix".into(),
1313 }],
1314 };
1315 let mut v: Vec<S> = stream_fixtures().into_iter().map(S::Stream).collect();
1316 v.extend([
1317 S::TurnStarted { turn_baseline: 4, trigger: TurnTrigger::EventAuto, user_text: None },
1318 S::TurnStarted { turn_baseline: 5, trigger: TurnTrigger::QueuedAuto, user_text: Some("q".into()) },
1319 S::TurnStarted { turn_baseline: 6, trigger: TurnTrigger::DriverAuto, user_text: None },
1320 S::Conversation(conv()),
1321 S::Prompt(prompt()),
1322 S::PromptResolved { prompt_id: 3 },
1323 S::External(crate::events::types::Event::simple("cli", "hello", None)),
1324 S::AutoTurnCapReached { cap: 5 },
1325 S::Idle,
1326 S::Steered { text: "s".into(), delivered: true },
1327 S::Dequeued { text: "d".into() },
1328 S::SystemNotice("sys".into()),
1329 S::LoaderProgress(loader),
1330 S::ExtensionNotification { extension_id: "e".into(), method: "widget.upsert".into(), params: serde_json::json!({"a":1}) },
1331 S::SettingChanged(SettingApplied {
1332 id: 3,
1333 setting: "model".into(),
1334 ok: true,
1335 message: None,
1336 view: view(),
1337 clamp: Some(ReasoningClampWire { from: "ultra".into(), to: "high".into() }),
1338 }),
1339 S::QueryResult { id: 1, value: serde_json::json!([1, 2]) },
1340 S::Attached {
1341 client: ClientId(2),
1342 snapshot: AttachSnapshot {
1343 meta: meta(),
1344 view: view(),
1345 conversation: conv(),
1346 streaming: true,
1347 replay: vec![env(S::Stream(StreamEvent::Llm(LlmEvent::Text("partial".into()))))],
1348 pending_prompts: vec![prompt()],
1349 clients: vec![(ClientId(1), ClientKind::Tui)],
1350 roster: Vec::new(),
1351 client_input: ClientInput::Shared,
1352 input_owner: Some(ClientId(1)),
1353 display_tail: None,
1354 },
1355 },
1356 S::ClientJoined { client: ClientId(1), kind: ClientKind::Attach, name: "discord".into(), mode: AttachMode::Observe },
1357 S::ClientLeft { client: ClientId(1) },
1358 S::Ended { reason: EndReason::HostShutdown },
1359 S::Aborted { context_saved: true },
1360 S::Cleared { session_id: "new-1".into() },
1361 S::CompactionStarted { source: "manual".into(), disclosure: "disc".into() },
1362 S::CompactionApplied {
1363 previous_session_id: "old".into(),
1364 session_id: "new".into(),
1365 chains_advanced: vec!["c1".into()],
1366 queued_restored: Some("q".into()),
1367 msg_count: 12,
1368 },
1369 S::CompactionFailed { message: "boom".into(), panicked: false },
1370 S::CompactionCancelled,
1371 S::SubagentRows(vec![crate::runtime::subagent::SubagentDisplayRow {
1372 subagent_id: 1,
1373 agent_name: "a".into(),
1374 status: crate::runtime::subagent::SubagentStatus::Failed("x".into()),
1375 cancel_requested: false,
1376 elapsed_secs: 1.25,
1377 finished_elapsed: Some(std::time::Duration::from_millis(1250)),
1378 step: "$ cargo test".into(),
1379 tools: 3,
1380 handle_id: "sa_1".into(),
1381 model: "provider/model-a".into(),
1382 }]),
1383 S::Resumed { id: 2, old_id: "o".into(), new_id: "n".into(), via: Some("name".into()), clamp_notice: None },
1384 S::InputOwnerChanged { from: Some(ClientId(1)), to: Some(ClientId(2)), reason: OwnerChangeReason::Takeover },
1385 S::Refused { client: ClientId(2), command: "submit".into(), reason: "input owned by client #1".into() },
1386 S::AttachRefused { message: "parked".into() },
1387 S::Lifecycle(SessionLifecycle::Parked),
1388 S::Reloading { generation: 2, retry_after_ms: 500 },
1389 S::DriverArmed {
1390 plugin_id: "autonomous".into(),
1391 run_id: "run-1".into(),
1392 models: vec![
1393 crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1394 crate::extensions::session_driver::Selection { model: "m2".into(), effort: "low".into() },
1395 ],
1396 selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1397 deadline_ms: Some(60_000),
1398 notice: "armed".into(),
1399 },
1400 S::DriverRevoked { reason: "canceled".into(), undelivered_steering: vec!["steer1".into()] },
1401 S::DriverTurnOutcome {
1402 outcome: crate::extensions::session_driver::Outcome::Success,
1403 selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1404 feedback: Some("changed".into()),
1405 },
1406 ]);
1407 v
1408 }
1409
1410 fn queued_frame() -> String {
1411 let ev = env(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "toolu_2".into() })));
1412 encode_line(&DaemonFrame::Event(ev.into())).unwrap()
1413 }
1414
1415 #[test]
1423 fn an_unknown_nested_llm_variant_fails_the_whole_frame() {
1424 let line = queued_frame();
1425 assert!(line.contains(r#""llm":"tool_queued""#), "{line}");
1426 let as_old = line.replace(r#""llm":"tool_queued""#, r#""llm":"tool_from_the_future""#);
1427 let err = decode_line::<DaemonFrame>(as_old.trim_end()).unwrap_err();
1428 assert!(err.contains("unknown variant `tool_from_the_future`"), "{err}");
1429 let attached = AttachedWire::new(
1431 ClientId(1),
1432 AttachSnapshot {
1433 meta: meta(),
1434 view: view(),
1435 conversation: conv(),
1436 streaming: true,
1437 replay: vec![env(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { tool_id: "toolu_2".into() })))],
1438 pending_prompts: vec![],
1439 clients: vec![],
1440 roster: Vec::new(),
1441 client_input: ClientInput::Shared,
1442 input_owner: None,
1443 display_tail: None,
1444 },
1445 );
1446 let line = encode_line(&DaemonFrame::Attached(attached)).unwrap();
1447 let as_old = line.replace(r#""llm":"tool_queued""#, r#""llm":"tool_from_the_future""#);
1448 assert!(decode_line::<DaemonFrame>(as_old.trim_end()).is_err());
1449 let top_frame = queued_frame().replace(r#""ev":"stream""#, r#""ev":"zz_new""#);
1451 match decode_line::<DaemonFrame>(top_frame.trim_end()).unwrap() {
1452 DaemonFrame::Event(w) => match Envelope::from(w).event {
1453 SessionEventWire::SystemNotice(t) => assert!(t.contains("newer daemon"), "{t}"),
1454 other => panic!("{other:?}"),
1455 },
1456 other => panic!("{other:?}"),
1457 }
1458 }
1459
1460 #[test]
1465 fn client_input_absent_means_exclusive_and_legacy_clients_are_presented_owners() {
1466 let snap = |client_input| AttachSnapshot {
1467 meta: meta(),
1468 view: view(),
1469 conversation: conv(),
1470 streaming: false,
1471 replay: vec![],
1472 pending_prompts: vec![],
1473 clients: vec![],
1474 roster: Vec::new(),
1475 client_input,
1476 input_owner: Some(ClientId(1)),
1477 display_tail: None,
1478 };
1479 let line = encode_line(&DaemonFrame::Attached(AttachedWire::new(ClientId(2), snap(ClientInput::Shared)))).unwrap();
1480 assert!(line.contains(r#""client_input":"shared""#), "{line}");
1481 let decoded = |line: &str| match decode_line::<DaemonFrame>(line.trim_end()).unwrap() {
1482 DaemonFrame::Attached(a) => a.into_snapshot().1,
1483 other => panic!("{other:?}"),
1484 };
1485 assert_eq!(decoded(&line).client_input, ClientInput::Shared);
1486 let old = line.replace(r#""client_input":"shared","#, "");
1487 assert!(!old.contains("client_input"), "{old}");
1488 let s = decoded(&old);
1489 assert_eq!(s.client_input, ClientInput::Exclusive);
1490 assert!(s.input_owned_elsewhere(ClientId(2)).is_some(), "an older daemon's non-owner is read-only");
1491
1492 let (old_caps, new_caps) = (ClientCapabilities::default(), ClientCapabilities::current());
1493 assert!(legacy_shared(old_caps, ClientInput::Shared));
1494 assert!(!legacy_shared(new_caps, ClientInput::Shared));
1495 assert!(!legacy_shared(old_caps, ClientInput::Exclusive), "exclusive is the rule it knows");
1496 }
1497
1498 #[test]
1502 fn queue_signals_reach_only_clients_that_opted_in() {
1503 let old = ClientCapabilities::default();
1504 let new = ClientCapabilities::current();
1505 for ev in fixtures() {
1506 let queue = matches!(
1507 ev,
1508 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::ToolQueued { .. } | LlmEvent::ToolStarted { .. }))
1509 );
1510 assert_eq!(client_decodes(old, &ev), !queue, "old client: {ev:?}");
1511 assert!(client_decodes(new, &ev), "new client: {ev:?}");
1512 }
1513 }
1514
1515 #[test]
1519 fn client_capabilities_default_off_and_old_daemons_ignore_them() {
1520 let legacy = r#"{"kind":"tui","terminal":null,"instance":"i"}"#;
1521 let m: ClientMeta = serde_json::from_str(legacy).unwrap();
1522 assert_eq!(m.capabilities, ClientCapabilities::default());
1523 assert!(!m.capabilities.tool_queue);
1524
1525 let h = Hello::new(ClientKind::Tui);
1526 assert!(h.client.capabilities.tool_queue);
1527 let line = encode_line(&ClientFrame::Hello(h)).unwrap();
1528 assert!(line.contains(r#""capabilities":{"tool_queue":true,"owner_reason_input":true}"#), "{line}");
1529 match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1530 ClientFrame::Hello(h) => assert!(h.client.capabilities.tool_queue),
1531 other => panic!("{other:?}"),
1532 }
1533
1534 #[derive(Deserialize)]
1536 #[allow(dead_code)]
1537 struct OldClientMeta {
1538 kind: ClientKind,
1539 terminal: Option<String>,
1540 instance: String,
1541 #[serde(default)]
1542 history: HistoryMode,
1543 #[serde(default)]
1544 tail_items: usize,
1545 }
1546 let json = serde_json::to_string(&ClientMeta::new(ClientKind::Tui)).unwrap();
1547 let old: OldClientMeta = serde_json::from_str(&json).unwrap();
1548 assert_eq!(old.kind, ClientKind::Tui);
1549 }
1550
1551 #[test]
1556 fn client_names_are_additive_on_the_wire() {
1557 let old = r#"{"ev":"client_joined","client":4,"kind":"attach"}"#;
1558 let ev: SessionEventWire = serde_json::from_str::<WireSessionEvent>(old).unwrap().into();
1559 let mut roster = Roster::default();
1560 roster.apply(&ev);
1561 let c = roster.get(ClientId(4)).unwrap();
1562 assert_eq!((c.name.as_str(), c.mode), ("attach", AttachMode::Mirror));
1563
1564 let unnamed = encode_line(&ClientFrame::Hello(Hello::new(ClientKind::Tui))).unwrap();
1565 assert!(!unnamed.contains(r#""name""#), "{unnamed}");
1566 let named = Hello::new(ClientKind::Tui).with_name(Some("discord".into()));
1567 let line = encode_line(&ClientFrame::Hello(named)).unwrap();
1568 match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1569 ClientFrame::Hello(h) => assert_eq!(h.client.name.as_deref(), Some("discord")),
1570 other => panic!("{other:?}"),
1571 }
1572 }
1573
1574 #[test]
1577 fn the_watch_queries_tool_queue_flag_defaults_off() {
1578 let old: SessionQuery =
1579 serde_json::from_str(r#"{"query":"subagent_watch","handle_id":"sa_1","since":4}"#).unwrap();
1580 assert!(matches!(old, SessionQuery::SubagentWatch { tool_queue: false, since: 4, .. }));
1581 let off = SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 4, tool_queue: false };
1582 assert!(!serde_json::to_string(&off).unwrap().contains("tool_queue"));
1583 let on = SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 4, tool_queue: true };
1584 let back: SessionQuery = serde_json::from_str(&serde_json::to_string(&on).unwrap()).unwrap();
1585 assert!(matches!(back, SessionQuery::SubagentWatch { tool_queue: true, .. }));
1586 }
1587
1588 #[test]
1589 fn wire_roundtrip_every_variant() {
1590 let all = fixtures();
1591 assert_eq!(all.len(), 36 + 21);
1593 for ev in all {
1594 let is_conv = matches!(ev, SessionEventWire::Conversation(_));
1595 let e = env(ev);
1596 let before = format!("{e:?}");
1597 let wire: WireEnvelope = e.into();
1598 let line = encode_line(&wire).unwrap();
1599 assert_eq!(line.matches('\n').count(), 1, "one line per frame");
1600 let back: WireEnvelope = decode_line(line.trim_end()).unwrap();
1601 let after = format!("{:?}", Envelope::from(back));
1602 if is_conv {
1603 assert!(!line.contains("api_messages"), "{line}");
1605 assert!(after.contains("api_messages: []"), "{after}");
1606 assert_eq!(before.replace("api_messages: [Object {\"content\": String(\"hi\"), \"role\": String(\"user\")}]", "api_messages: []"), after);
1607 } else {
1608 assert_eq!(before, after);
1609 }
1610 }
1611 }
1612
1613 #[test]
1614 fn hello_history_fields_default_and_roundtrip() {
1615 let legacy = r#"{"kind":"tui","terminal":null,"instance":"i"}"#;
1617 let m: ClientMeta = serde_json::from_str(legacy).unwrap();
1618 assert_eq!(m.history, HistoryMode::Full);
1619 assert_eq!(m.tail_items, DEFAULT_TAIL_ITEMS);
1620 let h = Hello::new(ClientKind::Tui).with_history(HistoryMode::Digest).with_tail_items(9);
1621 let line = encode_line(&ClientFrame::Hello(h)).unwrap();
1622 assert!(line.contains("\"history\":\"digest\""), "{line}");
1623 assert!(line.contains("\"tail_items\":9"), "{line}");
1624 match decode_line::<ClientFrame>(line.trim_end()).unwrap() {
1625 ClientFrame::Hello(h) => {
1626 assert_eq!(h.client.history, HistoryMode::Digest);
1627 assert_eq!(h.client.tail_items, 9);
1628 }
1629 other => panic!("{other:?}"),
1630 }
1631 let d = ConversationDigest::of(&conv());
1633 let snap = d.into_snapshot(Vec::new());
1634 assert_eq!(snap.messages_len, 1);
1635 assert!(snap.api_messages.is_empty());
1636 let legacy: ConversationSnapshot = serde_json::from_str(r#"{"api_messages":[],"tokens":{"input":0,"output":0,"cache_read":0,"cache_creation":0},"cost":0.0,"abort_context":null,"queued_message":null,"pending_events_len":0,"consecutive_auto_turns":0}"#).unwrap();
1637 assert_eq!(legacy.messages_len, 0);
1638 }
1639
1640 #[test]
1641 fn conversation_digest_roundtrips_and_detects_drift() {
1642 let c = conv();
1643 let d = ConversationDigest::of(&c);
1644 assert_eq!(d.messages_len, 1);
1645 assert!(d.matches(&c.api_messages));
1646 assert!(!d.matches(&[]));
1647 let mut other = c.api_messages.clone();
1648 other.push(Arc::new(serde_json::json!({"role":"assistant","content":"x"})));
1649 assert!(!d.matches(&other));
1650 let back = d.clone().into_snapshot(c.api_messages.clone());
1651 assert_eq!(ConversationDigest::of(&back), d);
1652 assert_eq!(messages_hash(&[]), 0xcbf2_9ce4_8422_2325);
1654 assert_ne!(messages_hash(&c.api_messages), messages_hash(&[]));
1655 }
1656
1657 #[test]
1658 fn unknown_event_variant_tolerated() {
1659 let v: WireSessionEvent = serde_json::from_str(r#"{"ev":"from_the_future","x":1}"#).unwrap();
1660 assert!(matches!(v, WireSessionEvent::Unknown));
1661 assert!(matches!(SessionEventWire::from(v), SessionEventWire::SystemNotice(_)));
1662 }
1663
1664 #[test]
1669 fn an_unknown_command_or_query_names_its_tag_in_the_decode_error() {
1670 let frames = [
1671 (
1672 ClientFrame::Cmd {
1673 session_id: "s".into(),
1674 cmd: SessionCommand::Query {
1675 id: 1,
1676 query: SessionQuery::SubagentWatch { handle_id: "sa_1".into(), since: 0, tool_queue: false },
1677 },
1678 },
1679 "subagent_watch",
1680 ),
1681 (
1682 ClientFrame::Cmd {
1683 session_id: "s".into(),
1684 cmd: SessionCommand::SubagentSteer { handle_id: "sa_1".into(), text: "x".into() },
1685 },
1686 "subagent_steer",
1687 ),
1688 ];
1689 for (frame, tag) in frames {
1690 let json = serde_json::to_string(&frame).unwrap();
1691 assert!(decode_line::<ClientFrame>(&json).is_ok());
1692 let old = json.replace(&format!("\"{tag}\""), "\"zz_from_the_future\"");
1694 assert_ne!(old, json, "{tag} is serialized as a quoted tag");
1695 let err = decode_line::<ClientFrame>(&old).unwrap_err();
1696 assert!(err.contains("unknown variant `zz_from_the_future`"), "{err}");
1697 }
1698 }
1699
1700 #[test]
1701 fn driver_armed_round_trips_and_old_client_skips() {
1702 let armed = WireSessionEvent::DriverArmed {
1704 plugin_id: "autonomous".into(),
1705 run_id: "run-42".into(),
1706 models: vec![
1707 crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1708 ],
1709 selection: crate::extensions::session_driver::Selection { model: "m1".into(), effort: "high".into() },
1710 deadline_ms: Some(120_000),
1711 notice: "armed and ready".into(),
1712 };
1713 let json = serde_json::to_string(&armed).unwrap();
1714 let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1716 assert!(matches!(&back, WireSessionEvent::DriverArmed { plugin_id, .. } if plugin_id == "autonomous"));
1717 let old_json = json.replace(r#""ev":"driver_armed""#, r#""ev":"v2_unknown_event""#);
1719 let old: WireSessionEvent = serde_json::from_str(&old_json).unwrap();
1720 assert!(matches!(old, WireSessionEvent::Unknown));
1721 }
1722
1723 #[test]
1724 fn driver_revoked_and_outcome_round_trip() {
1725 let revoked = WireSessionEvent::DriverRevoked {
1726 reason: "canceled".into(),
1727 undelivered_steering: vec!["steer1".into(), "steer2".into()],
1728 };
1729 let json = serde_json::to_string(&revoked).unwrap();
1730 let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1731 assert!(matches!(&back, WireSessionEvent::DriverRevoked { reason, undelivered_steering }
1732 if reason == "canceled" && undelivered_steering.len() == 2));
1733
1734 let outcome = WireSessionEvent::DriverTurnOutcome {
1735 outcome: crate::extensions::session_driver::Outcome::Success,
1736 selection: crate::extensions::session_driver::Selection { model: "m".into(), effort: "high".into() },
1737 feedback: Some("changed".into()),
1738 };
1739 let json = serde_json::to_string(&outcome).unwrap();
1740 let back: WireSessionEvent = serde_json::from_str(&json).unwrap();
1741 assert!(matches!(&back, WireSessionEvent::DriverTurnOutcome { feedback: Some(f), .. } if f == "changed"));
1742 }
1743
1744 #[test]
1745 fn driver_revoked_defaults_tolerate_missing_fields() {
1746 let minimal = r#"{"ev":"driver_revoked","reason":"timeout"}"#;
1748 let v: WireSessionEvent = serde_json::from_str(minimal).unwrap();
1749 assert!(matches!(&v, WireSessionEvent::DriverRevoked { reason, undelivered_steering }
1750 if reason == "timeout" && undelivered_steering.is_empty()));
1751 }
1752
1753 #[test]
1754 fn client_frames_roundtrip_and_daemon_frames_roundtrip() {
1755 let frames = vec![
1756 ClientFrame::Hello(Hello::new(ClientKind::Attach)),
1757 ClientFrame::Hello(Hello::new(ClientKind::Tui).with_history(HistoryMode::Digest).with_tail_items(7)),
1758 ClientFrame::Ping,
1759 ClientFrame::Sessions,
1760 ClientFrame::Shutdown { force: true },
1761 ClientFrame::Attach(Attach::Existing { session_id: "s".into(), mode: AttachMode::Mirror }),
1762 ClientFrame::Attach(Attach::Create { config: SessionConfig::default(), mode: AttachMode::Takeover }),
1763 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Submit { text: "hi".into(), attachments: vec![] } },
1764 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Answer { prompt_id: 1, value: Some("pw".into()) } },
1765 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::EngineCommand { id: 4, name: "model".into(), arg: "x".into() } },
1766 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Query { id: 5, query: SessionQuery::Status } },
1767 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Set { id: 6, setting: SessionSetting::ReloadPrompt } },
1768 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Checkpoint { reason: CheckpointReason::Reload } },
1769 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::KeepWarm { on: true } },
1770 ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::DriverStart {
1771 plugin: "autonomous".into(), command: "auto".into(), arg: "start -- do stuff".into(),
1772 }},
1773 ClientFrame::Reload { now: true, drain_secs: Some(3), exe: Some(PathBuf::from("/bin/synaps")) },
1774 ClientFrame::Purge,
1775 ClientFrame::Close { session_id: "s".into(), force: true, attached: true },
1776 ClientFrame::Close { session_id: "s".into(), force: false, attached: false },
1777 ClientFrame::Pin { session_id: "s".into(), on: true },
1778 ClientFrame::Pin { session_id: "s".into(), on: false },
1779 ClientFrame::AuthState(agent_core::auth::AuthStateQuery::default()),
1780 ClientFrame::AuthState(agent_core::auth::AuthStateQuery {
1781 provider: Some("anthropic".into()),
1782 account: Some("b".into()),
1783 clear: true,
1784 }),
1785 ClientFrame::ReloadExtension { extension: "subconscious".into() },
1786 ClientFrame::Bye,
1787 ];
1788 for f in frames {
1789 let line = encode_line(&f).unwrap();
1790 let back: ClientFrame = decode_line(line.trim_end()).unwrap();
1791 assert_eq!(serde_json::to_string(&back).unwrap(), line.trim_end());
1792 }
1793 let frames = vec![
1794 DaemonFrame::Welcome(Welcome {
1795 protocol_version: PROTOCOL_VERSION,
1796 daemon_version: "0.9.0".into(),
1797 pid: 1,
1798 profile: None,
1799 sessions: vec![meta()],
1800 progressive_tool_disclosure: true,
1801 generation: 1,
1802 daemon_build: "abc1234".into(),
1803 }),
1804 DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version: 2, min: 2, max: 2 }, message: "no".into() },
1805 DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why: "older".into() }, message: "no".into() },
1806 DaemonFrame::Pong { pid: 1, uptime_s: 2, sessions: 0 },
1807 DaemonFrame::SessionList { sessions: vec![meta()] },
1808 DaemonFrame::Attached(AttachedWire::new(
1809 ClientId(1),
1810 AttachSnapshot {
1811 meta: meta(),
1812 view: view(),
1813 conversation: conv(),
1814 streaming: false,
1815 replay: vec![],
1816 pending_prompts: vec![],
1817 clients: vec![],
1818 roster: Vec::new(),
1819 client_input: ClientInput::Shared,
1820 input_owner: None,
1821 display_tail: None,
1822 },
1823 )),
1824 DaemonFrame::Event(env(SessionEventWire::SystemNotice("x".into())).into()),
1825 DaemonFrame::Error { session_id: None, message: "e".into() },
1826 DaemonFrame::Closed { session_id: "s".into(), text: "closed".into() },
1827 DaemonFrame::Pinned { session_id: "s".into(), on: true },
1828 DaemonFrame::Pinned { session_id: "s".into(), on: false },
1829 DaemonFrame::ExtensionReloaded {
1830 report: crate::extensions::manager::ExtensionReloadReport {
1831 extension: "subconscious".into(),
1832 was_loaded: true,
1833 deferred: false,
1834 tools: vec!["subconscious:day_note".into()],
1835 removed_tools: vec!["subconscious:old".into()],
1836 plugin_dir: PathBuf::from("/p/subconscious"),
1837 },
1838 },
1839 DaemonFrame::ExtensionReloadFailed { extension: "x".into(), message: "boom".into(), unloaded: true },
1840 DaemonFrame::Bye { reason: None },
1841 DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) },
1842 DaemonFrame::Bye { reason: Some(ByeReason::Shutdown) },
1843 DaemonFrame::AuthState { report: auth_state_report() },
1844 ];
1845 for f in frames {
1846 let line = encode_line(&f).unwrap();
1847 let back: DaemonFrame = decode_line(line.trim_end()).unwrap();
1848 assert_eq!(serde_json::to_string(&back).unwrap(), line.trim_end());
1849 }
1850 }
1851
1852 #[test]
1853 fn hello_must_be_first_is_a_type_level_fact_and_version_is_exact() {
1854 let h = Hello::new(ClientKind::Test);
1855 assert_eq!(h.protocol_version, PROTOCOL_VERSION);
1856 assert_eq!(PROTOCOL_MIN, PROTOCOL_MAX);
1857 let json = serde_json::to_string(&ClientFrame::Hello(h)).unwrap();
1858 assert!(json.starts_with(r#"{"type":"hello""#));
1859 assert_eq!(PROTOCOL_VERSION, 3);
1860 let h: Hello = serde_json::from_str(
1862 r#"{"protocol_version":1,"client":{"kind":"tui","terminal":null,"instance":"i"},"cwd":"/","client_version":"0"}"#,
1863 )
1864 .unwrap();
1865 assert!(h.reconnect_of.is_none());
1866 assert!(h.env.is_none());
1868 let h2 = Hello::new(ClientKind::Test);
1870 assert!(h2.env.is_some(), "Hello::new must capture client env");
1871 let b: DaemonFrame = serde_json::from_str(r#"{"type":"bye"}"#).unwrap();
1872 assert!(matches!(b, DaemonFrame::Bye { reason: None }));
1873 }
1874
1875 #[test]
1876 fn close_defaults_force_off_and_older_daemons_reject_it_as_an_unknown_variant() {
1877 let f: ClientFrame = decode_line(r#"{"type":"close","session_id":"s"}"#).unwrap();
1878 assert!(matches!(f, ClientFrame::Close { ref session_id, force: false, attached: false } if session_id.as_str() == "s"));
1879 #[derive(Debug, Deserialize)]
1882 #[serde(tag = "type", rename_all = "snake_case")]
1883 enum OldClientFrame {
1884 Ping,
1885 Purge,
1886 }
1887 let err = decode_line::<OldClientFrame>(r#"{"type":"close","session_id":"s","force":true}"#).unwrap_err();
1888 assert!(err.contains("unknown variant `close`"), "{err}");
1889 }
1890
1891 #[test]
1892 fn pin_requires_on_and_older_daemons_reject_it_as_an_unknown_variant() {
1893 let f: ClientFrame = decode_line(r#"{"type":"pin","session_id":"s","on":false}"#).unwrap();
1894 assert!(matches!(f, ClientFrame::Pin { ref session_id, on: false } if session_id.as_str() == "s"));
1895 assert!(decode_line::<ClientFrame>(r#"{"type":"pin","session_id":"s"}"#).is_err());
1896 #[derive(Debug, Deserialize)]
1897 #[serde(tag = "type", rename_all = "snake_case")]
1898 enum OldClientFrame {
1899 Ping,
1900 Purge,
1901 }
1902 let err = decode_line::<OldClientFrame>(r#"{"type":"pin","session_id":"s","on":true}"#).unwrap_err();
1903 assert!(err.contains("unknown variant `pin`"), "{err}");
1904 }
1905
1906 #[test]
1907 fn reload_extension_requires_a_name_and_older_daemons_reject_it_as_an_unknown_variant() {
1908 let f: ClientFrame = decode_line(r#"{"type":"reload_extension","extension":"axel"}"#).unwrap();
1909 assert!(matches!(f, ClientFrame::ReloadExtension { ref extension } if extension == "axel"));
1910 assert!(decode_line::<ClientFrame>(r#"{"type":"reload_extension"}"#).is_err());
1911 #[derive(Debug, Deserialize)]
1914 #[serde(tag = "type", rename_all = "snake_case")]
1915 enum OldClientFrame {
1916 Ping,
1917 Purge,
1918 }
1919 let err = decode_line::<OldClientFrame>(r#"{"type":"reload_extension","extension":"axel"}"#).unwrap_err();
1920 assert!(err.contains("unknown variant `reload_extension`"), "{err}");
1921 let d: DaemonFrame = serde_json::from_str(r#"{"type":"extension_reload_failed","extension":"x","message":"m"}"#).unwrap();
1923 assert!(matches!(d, DaemonFrame::ExtensionReloadFailed { unloaded: false, .. }));
1924 }
1925
1926 #[test]
1927 fn a_session_meta_without_pinned_decodes_as_not_pinned() {
1928 let mut v = serde_json::to_value(meta()).unwrap();
1929 v.as_object_mut().unwrap().remove("pinned");
1930 let m: SessionMeta = serde_json::from_value(v).unwrap();
1931 assert!(!m.pinned);
1932 let mut pinned = meta();
1933 pinned.pinned = true;
1934 let back: SessionMeta = serde_json::from_value(serde_json::to_value(&pinned).unwrap()).unwrap();
1935 assert!(back.pinned);
1936 }
1937
1938 #[test]
1939 fn debug_redacts_answer_and_submit_bodies() {
1940 let secret = "hunter2-very-secret";
1941 let f = ClientFrame::Cmd {
1942 session_id: "s".into(),
1943 cmd: SessionCommand::Answer { prompt_id: 1, value: Some(secret.into()) },
1944 };
1945 let d = format!("{f:?}");
1946 assert!(!d.contains(secret), "{d}");
1947 assert!(d.contains("redacted"));
1948 let f = ClientFrame::Cmd {
1949 session_id: "s".into(),
1950 cmd: SessionCommand::Submit { text: "my private prompt".into(), attachments: vec![] },
1951 };
1952 let d = format!("{f:?}");
1953 assert!(!d.contains("private"), "{d}");
1954 let f = ClientFrame::Cmd { session_id: "s".into(), cmd: SessionCommand::Steer { text: "steer me".into() } };
1955 let d = format!("{f:?}");
1956 assert!(!d.contains("steer me"));
1957 assert!(!d.contains("chars"), "{d}");
1959 assert!(!d.contains('8'), "{d}");
1960 }
1961
1962 #[test]
1963 fn oversize_frame_rejected_both_directions() {
1964 const { assert!(DAEMON_MAX_FRAME_BYTES > RPC_MAX_FRAME_BYTES) };
1965 let big = "x".repeat(DAEMON_MAX_FRAME_BYTES + 1);
1966 assert!(decode_line::<ClientFrame>(&big)
1967 .unwrap_err()
1968 .contains("64 MiB"));
1969 let f = DaemonFrame::Error { session_id: None, message: big };
1971 assert!(encode_line(&f).unwrap_err().contains("64 MiB"));
1972 let f = DaemonFrame::Error { session_id: None, message: "y".repeat(RPC_MAX_FRAME_BYTES * 2) };
1974 let line = encode_line(&f).unwrap();
1975 assert!(decode_line::<DaemonFrame>(line.trim_end()).is_ok());
1976 }
1977
1978 #[test]
1979 fn build_id_is_set_once_by_the_binary() {
1980 set_build_id(" abc1234-dirty \n");
1982 assert_eq!(build_id(), "abc1234-dirty");
1983 set_build_id("later");
1984 assert_eq!(build_id(), "abc1234-dirty", "first call wins");
1985 }
1986
1987 #[test]
1988 fn no_secret_bearing_fields_on_handshake_frames() {
1989 let w = serde_json::to_string(&Welcome {
1991 protocol_version: 1,
1992 daemon_version: "v".into(),
1993 pid: 1,
1994 profile: Some("p".into()),
1995 sessions: vec![meta()],
1996 progressive_tool_disclosure: false,
1997 generation: 1,
1998 daemon_build: String::new(),
1999 })
2000 .unwrap()
2001 .to_lowercase();
2002 for bad in ["token", "secret", "api_key", "apikey", "password", "credential"] {
2003 assert!(!w.contains(bad), "{bad} in Welcome");
2004 }
2005 }
2006}
2007
2008#[cfg(test)]
2009mod float_roundtrip_tests {
2010 use super::*;
2011
2012 #[test]
2016 fn conversation_digest_cost_survives_the_wire_exactly() {
2017 let cost = (10f64 / 1_000_000.0) * 3.0 + (5f64 / 1_000_000.0) * 15.0
2018 + ((10f64 / 1_000_000.0) * 3.0 + (1f64 / 1_000_000.0) * 15.0);
2019 let snap = ConversationSnapshot { cost, ..Default::default() };
2020 let d = ConversationDigest::of(&snap);
2021 let line = encode_line(&d).unwrap();
2022 let back: ConversationDigest = decode_line(line.trim_end()).unwrap();
2023 assert_eq!(back.cost.to_bits(), cost.to_bits(), "{line}");
2024 assert_eq!(format!("{:.4}", back.cost), format!("{:.4}", cost));
2025 }
2026}