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