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