Skip to main content

nexus_core/host/
wire.rs

1//! Wire types for the Phase 4 `nexus host` `HTTP`/`SSE` API.
2//!
3//! The daemon never leaks domain internals onto the wire: [`AppEvent`]
4//! nests provider ([`StreamEvent`]), research, swarm, and file types that
5//! carry no serde and are free to change shape as the domain evolves.
6//! Everything the host emits is instead this module's [`WireEvent`] — a
7//! serde mirror of the domain event with a stable `JSON` shape. `From`
8//! impls convert in one direction only (domain → wire); the host never
9//! parses events back into domain types.
10//!
11//! [`AppCommand`](crate::app::AppCommand) is the one exception: it is a
12//! plain enum of strings, bools, and optionals with no internals, so the
13//! command seam itself carries the serde derives and `POST /v1/command`
14//! ships it directly.
15
16use serde::{Deserialize, Serialize};
17
18use crate::app::{
19    AppEvent, GateState, LoginMsg, MemoryOp, OcrUpdate, PlanQuestion, ResearchUpdate, SurveyPhase,
20    SwarmUpdate,
21};
22use crate::db::Persona;
23use crate::provider::{BackendTag, Model, ModelPricing, ReasoningEffort, StreamEvent, Usage};
24
25/// One event on the host's `/v1/events` `SSE` feed — the wire mirror of
26/// [`AppEvent`], with every variant mapping 1:1 onto the domain event.
27/// Frames serialize as `{"type": "<snake_case variant>", "payload": …}`;
28/// unit variants carry no `payload` key. A `None` payload means that
29/// source's channel closed (its background task ended).
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
32pub enum WireEvent {
33    /// A one-line status update from a domain path.
34    Status(String),
35    /// The composer should be replaced with this text (e.g. a send-failure
36    /// path restoring the user's message).
37    ComposerSet(String),
38    /// The composer should be cleared.
39    ComposerClear,
40    /// The view should reset its viewport state (scroll, selection,
41    /// pinning baseline) — pushed wherever domain code switches sessions,
42    /// starts a stream, or otherwise invalidates the rendered conversation.
43    ViewportReset,
44    /// The wrapped-history render cache must be rebuilt (in-place message
45    /// edits would otherwise leave stale wrapped content).
46    HistoryInvalidated,
47    /// A domain path fell back to "no backend configured" and wants the
48    /// login selector shown.
49    OpenLoginPopup,
50    /// A survey/plan gate armed (`Some`) or cleared (`None`).
51    Gate(Option<WireGateState>),
52    /// One chat-frame delta: (task id, stream event) — or `None` when the
53    /// task's channel closed. Carries every frame the stream view renders,
54    /// so the `SSE` bridge needs no separate chat pump.
55    Stream(Option<(u64, WireStreamEvent)>),
56    /// The model-catalog fetch outcome: the merged per-backend list, or an
57    /// error string.
58    Models(Option<WireModelsResult>),
59    /// A generated session topic: (session id, title, slug).
60    Title(Option<(String, String, String)>),
61    /// Extracted memory ops for a space, tagged with the space name so a
62    /// meanwhile space-switch can discard stale results.
63    Memory(Option<(String, Vec<WireMemoryOp>)>),
64    /// A compaction digest: (session id, digest, messages covered,
65    /// pre-compaction %).
66    Compact(Option<(String, String, i64, u64)>),
67    /// `/skills` install outcome: skill name on success, error message on
68    /// failure.
69    SkillInstall(Option<Result<String, String>>),
70    /// A per-page progress or final `OCR` result for one scanned `PDF`, or
71    /// `None` when the batch's channel closed.
72    Ocr(Option<(String, String, WireOcrUpdate)>),
73    /// One file's chunk-embedding job finished (or the channel closed).
74    Embed(Option<WireEmbedMsg>),
75    /// A local-`OCR`-model pull finished: model name or error.
76    OcrPull(Option<Result<String, String>>),
77    /// A deep-research pipeline update, or `None` when its channel closed.
78    Research(Option<WireResearchMsg>),
79    /// `/research` with no topic: a distilled topic from recent chat, or an
80    /// error.
81    ResearchTopic(Option<Result<String, String>>),
82    /// Startup update check: newest published version, or `None` when the
83    /// check failed (offline, index hiccup) — silently ignored by clients.
84    UpdateCheck(Option<String>),
85    /// Codex subscription login status or final result.
86    Login(Option<WireLoginMsg>),
87    /// A `/swarm` turn update, or `None` when its channel closed.
88    Swarm(Option<WireSwarmMsg>),
89}
90
91/// The wire mirror of provider [`StreamEvent`] — one chat-frame delta.
92/// Token/reasoning/status are incremental chunks; `ToolCall` is a completed
93/// tool run with its result.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub enum WireStreamEvent {
96    /// A chunk of the visible answer.
97    Token(String),
98    /// A chunk of the model's reasoning/thinking.
99    Reasoning(String),
100    /// Exact token counts (arrives near end of stream when usage accounting
101    /// is on).
102    Usage(WireUsage),
103    /// A tool is about to run (e.g. "Searching the web…").
104    Status(String),
105    /// A tool finished: shown (and persisted) as its own transcript block.
106    ToolCall {
107        name: String,
108        arguments: String,
109        result: String,
110    },
111    /// The stream finished cleanly.
112    Done,
113    /// The stream failed.
114    Error(String),
115}
116
117/// The wire mirror of provider [`Usage`] — exact token accounting reported
118/// at end of stream.
119#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
120pub struct WireUsage {
121    pub prompt_tokens: u64,
122    pub completion_tokens: u64,
123    pub total_tokens: u64,
124    /// Prompt tokens served from the provider's prompt cache (cache reads).
125    pub cache_read_tokens: u64,
126    /// Prompt tokens written into the cache on this request (cache writes).
127    pub cache_creation_tokens: u64,
128    /// Provider-reported request cost in `USD`; `None` when the provider
129    /// omits cost.
130    pub cost: Option<f64>,
131}
132
133/// The wire mirror of [`GateState`] — which session a parked survey/plan
134/// gate is waiting on, and which phase.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct WireGateState {
137    /// The session the reply must come from.
138    pub session_id: String,
139    /// Which phase is waiting (drives the prompt shown).
140    pub phase: WireSurveyPhase,
141}
142
143/// What a parked survey gate is waiting for — drives which phase's reply is
144/// routed.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub enum WireSurveyPhase {
147    /// A clarifying-question round (1-based).
148    Clarify { round: u8 },
149    /// Approval of a presented artifact; `rework` is true on a
150    /// re-presentation after the user's edits were folded in.
151    Approve { rework: bool },
152}
153
154/// One memory-extraction op, as emitted by the memory model.
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum WireMemoryOp {
157    Add(String),
158    Update(usize, String),
159    Delete(usize),
160}
161
162/// A message from the background `OCR` batch about one file.
163#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub enum WireOcrUpdate {
165    /// A human-readable phase ("rendering pages…") shown while nothing is
166    /// countable yet.
167    Stage(String),
168    /// (pages done, total pages, pages failed so far).
169    Progress(usize, usize, usize),
170    /// Final outcome: (extracted text, per-page errors as (index, reason)),
171    /// or a whole-document error message.
172    Done(Result<(String, Vec<(usize, String)>), String>),
173}
174
175/// A deep-research pipeline update: one stage tick, a parked gate, or the
176/// final result.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub enum WireResearchUpdate {
179    /// Successive updates within one stage share a `label` so the client
180    /// replaces one row in place instead of appending per tick.
181    Stage { label: String, detail: String },
182    /// The scoping agent's clarifying questions; the pipeline is parked
183    /// awaiting a chat reply. `round` is 1-based.
184    SurveyReady { questions: Vec<String>, round: u8 },
185    /// The planner finished; the pipeline is parked awaiting a chat reply.
186    /// `rework` is true on a re-presentation after the user's edits were
187    /// folded in.
188    PlanReady {
189        questions: Vec<WirePlanQuestion>,
190        rework: bool,
191    },
192    /// Final outcome: the report, or a whole-pipeline error.
193    Done(Result<String, String>),
194}
195
196/// One planner sub-question handed to a searcher agent as its prompt.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct WirePlanQuestion {
199    pub question: String,
200    pub why: String,
201    pub angles: Vec<String>,
202    pub sources: Vec<String>,
203}
204
205/// Codex subscription login status or final result.
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub enum WireLoginMsg {
208    Status(String),
209    /// Login succeeded or failed. Credentials are consumed by the local app
210    /// and are deliberately never serialized onto the host event stream.
211    Done(Result<(), String>),
212}
213
214/// A `/swarm` turn update.
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub enum WireSwarmUpdate {
217    /// The roster was empty, so one was suggested — persist it before the
218    /// conversation's first turn.
219    RosterSuggested(Vec<WirePersona>),
220    /// Status-line progress text.
221    Progress(String),
222    /// One persona's reply for the turn just run.
223    Reply {
224        persona: String,
225        model: String,
226        content: String,
227    },
228    /// The moderator decided the conversation needed a new voice — persist
229    /// it to the roster so it shows up in the popup too.
230    PersonaJoined(WirePersona),
231    /// The turn's final synthesis reply — the canonical assistant message.
232    Synthesis(String),
233    Error(String),
234}
235
236/// One row of a session's `/swarm` roster: a model + a personality blurb.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct WirePersona {
239    pub name: String,
240    pub model: String,
241    pub blurb: String,
242}
243
244/// The wire mirror of provider [`Model`] — one routable model from the
245/// merged per-backend catalog.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct WireModel {
248    /// Backend-qualified public id (`openrouter:…`, `openai:…`, etc.).
249    /// Internal favorites/current-model storage may retain legacy bare ids.
250    pub id: String,
251    pub name: String,
252    /// The reasoning-effort values this model accepts, in cycle order.
253    /// Empty = the model has no reasoning/thinking mode at all.
254    pub reasoning_efforts: Vec<WireReasoningEffort>,
255    /// Context window size in tokens, if the provider reports it.
256    pub context_length: Option<u64>,
257    /// Whether the model accepts image input.
258    pub supports_images: bool,
259    /// Whether the model generates image output.
260    pub supports_image_generation: bool,
261    /// Whether the model generates video output.
262    pub supports_video_generation: bool,
263    /// Which backend this model came from — the gateway's routing key.
264    pub backend: WireBackendTag,
265    /// `USD` per 1M tokens from the catalog; `None` = cost unknown.
266    pub pricing: Option<WireModelPricing>,
267}
268
269/// The reasoning-effort values a model accepts, in `Ctrl`+`T` cycle order.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271pub enum WireReasoningEffort {
272    None,
273    Minimal,
274    Low,
275    Medium,
276    High,
277    XHigh,
278    Max,
279}
280
281/// Which backend a model routes through — the wire mirror of
282/// [`BackendTag`].
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284pub enum WireBackendTag {
285    OpenRouter,
286    OpenAi,
287    OpencodeGo,
288    Codex,
289}
290
291/// `USD` per 1M tokens from the catalog.
292#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
293pub struct WireModelPricing {
294    pub prompt: f64,
295    pub completion: f64,
296    /// Discounted cache-read price; `None` = cache reads use the regular
297    /// prompt price.
298    pub cache_read: Option<f64>,
299    /// Cache-write price; `None` = writes use the regular prompt price.
300    pub cache_write: Option<f64>,
301}
302
303/// The wire mirror of `app::ModelsResult` — the catalog fetch outcome.
304pub type WireModelsResult = Result<Vec<WireModel>, String>;
305
306/// The wire mirror of `app::EmbedMsg`: (space id, file id, (seq, vector)
307/// pairs or error). Plain data, so it passes through unmapped.
308pub type WireEmbedMsg = (String, String, Result<Vec<(i64, Vec<f32>)>, String>);
309
310/// The wire mirror of `app::ResearchMsg`: (session id, space id, space
311/// name, stage update or final result).
312pub type WireResearchMsg = (String, String, String, WireResearchUpdate);
313
314/// The wire mirror of `app::SwarmMsg`: (session id, update).
315pub type WireSwarmMsg = (String, WireSwarmUpdate);
316
317impl From<AppEvent> for WireEvent {
318    fn from(ev: AppEvent) -> Self {
319        match ev {
320            AppEvent::Status(s) => Self::Status(s),
321            AppEvent::ComposerSet(s) => Self::ComposerSet(s),
322            AppEvent::ComposerClear => Self::ComposerClear,
323            AppEvent::ViewportReset => Self::ViewportReset,
324            AppEvent::HistoryInvalidated => Self::HistoryInvalidated,
325            AppEvent::OpenLoginPopup => Self::OpenLoginPopup,
326            AppEvent::Gate(g) => Self::Gate(g.map(WireGateState::from)),
327            AppEvent::Stream(s) => {
328                Self::Stream(s.map(|(task_id, event)| (task_id, WireStreamEvent::from(event))))
329            }
330            AppEvent::Models(m) => Self::Models(
331                m.map(|r| r.map(|models| models.into_iter().map(WireModel::from).collect())),
332            ),
333            AppEvent::Title(t) => Self::Title(t),
334            AppEvent::Memory(m) => Self::Memory(
335                m.map(|(space, ops)| (space, ops.into_iter().map(WireMemoryOp::from).collect())),
336            ),
337            AppEvent::Compact(c) => Self::Compact(c),
338            AppEvent::SkillInstall(s) => Self::SkillInstall(s),
339            AppEvent::Ocr(o) => Self::Ocr(
340                o.map(|(file, session, update)| (file, session, WireOcrUpdate::from(update))),
341            ),
342            AppEvent::Embed(e) => Self::Embed(e),
343            AppEvent::OcrPull(p) => Self::OcrPull(p),
344            AppEvent::Research(r) => {
345                Self::Research(r.map(|(session_id, space_id, space_name, update)| {
346                    (
347                        session_id,
348                        space_id,
349                        space_name,
350                        WireResearchUpdate::from(update),
351                    )
352                }))
353            }
354            AppEvent::ResearchTopic(t) => Self::ResearchTopic(t),
355            AppEvent::UpdateCheck(u) => Self::UpdateCheck(u),
356            AppEvent::Login(l) => Self::Login(l.map(WireLoginMsg::from)),
357            AppEvent::Swarm(s) => Self::Swarm(
358                s.map(|(session_id, update)| (session_id, WireSwarmUpdate::from(update))),
359            ),
360        }
361    }
362}
363
364impl From<StreamEvent> for WireStreamEvent {
365    fn from(ev: StreamEvent) -> Self {
366        match ev {
367            StreamEvent::Token(t) => Self::Token(t),
368            StreamEvent::Reasoning(r) => Self::Reasoning(r),
369            StreamEvent::Usage(u) => Self::Usage(u.into()),
370            StreamEvent::Status(s) => Self::Status(s),
371            StreamEvent::ToolCall {
372                name,
373                arguments,
374                result,
375            } => Self::ToolCall {
376                name,
377                arguments,
378                result,
379            },
380            StreamEvent::Done => Self::Done,
381            StreamEvent::Error(e) => Self::Error(e),
382        }
383    }
384}
385
386impl From<Usage> for WireUsage {
387    fn from(u: Usage) -> Self {
388        Self {
389            prompt_tokens: u.prompt_tokens,
390            completion_tokens: u.completion_tokens,
391            total_tokens: u.total_tokens,
392            cache_read_tokens: u.cache_read_tokens,
393            cache_creation_tokens: u.cache_creation_tokens,
394            cost: u.cost,
395        }
396    }
397}
398
399impl From<GateState> for WireGateState {
400    fn from(g: GateState) -> Self {
401        Self {
402            session_id: g.session_id,
403            phase: g.phase.into(),
404        }
405    }
406}
407
408impl From<SurveyPhase> for WireSurveyPhase {
409    fn from(p: SurveyPhase) -> Self {
410        match p {
411            SurveyPhase::Clarify { round } => Self::Clarify { round },
412            SurveyPhase::Approve { rework } => Self::Approve { rework },
413        }
414    }
415}
416
417impl From<MemoryOp> for WireMemoryOp {
418    fn from(op: MemoryOp) -> Self {
419        match op {
420            MemoryOp::Add(s) => Self::Add(s),
421            MemoryOp::Update(i, s) => Self::Update(i, s),
422            MemoryOp::Delete(i) => Self::Delete(i),
423        }
424    }
425}
426
427impl From<OcrUpdate> for WireOcrUpdate {
428    fn from(u: OcrUpdate) -> Self {
429        match u {
430            OcrUpdate::Stage(s) => Self::Stage(s),
431            OcrUpdate::Progress(done, total, failed) => Self::Progress(done, total, failed),
432            OcrUpdate::Done(d) => Self::Done(d),
433        }
434    }
435}
436
437impl From<ResearchUpdate> for WireResearchUpdate {
438    fn from(u: ResearchUpdate) -> Self {
439        match u {
440            ResearchUpdate::Stage { label, detail } => Self::Stage { label, detail },
441            ResearchUpdate::SurveyReady { questions, round } => {
442                Self::SurveyReady { questions, round }
443            }
444            ResearchUpdate::PlanReady { questions, rework } => Self::PlanReady {
445                questions: questions.into_iter().map(WirePlanQuestion::from).collect(),
446                rework,
447            },
448            ResearchUpdate::Done(d) => Self::Done(d),
449        }
450    }
451}
452
453impl From<PlanQuestion> for WirePlanQuestion {
454    fn from(q: PlanQuestion) -> Self {
455        Self {
456            question: q.question,
457            why: q.why,
458            angles: q.angles,
459            sources: q.sources,
460        }
461    }
462}
463
464impl From<LoginMsg> for WireLoginMsg {
465    fn from(m: LoginMsg) -> Self {
466        match m {
467            // Device codes and prefilled URLs are short-lived credentials;
468            // the local UI receives them directly, never the host wire.
469            LoginMsg::Status(_) => Self::Status("codex login in progress".into()),
470            LoginMsg::Done(d) => Self::Done(match d {
471                Ok(_) => Ok(()),
472                Err(_) => Err("codex login failed".into()),
473            }),
474        }
475    }
476}
477
478impl From<SwarmUpdate> for WireSwarmUpdate {
479    fn from(u: SwarmUpdate) -> Self {
480        match u {
481            SwarmUpdate::RosterSuggested(p) => {
482                Self::RosterSuggested(p.into_iter().map(WirePersona::from).collect())
483            }
484            SwarmUpdate::Progress(s) => Self::Progress(s),
485            SwarmUpdate::Reply {
486                persona,
487                model,
488                content,
489            } => Self::Reply {
490                persona,
491                model,
492                content,
493            },
494            SwarmUpdate::PersonaJoined(p) => Self::PersonaJoined(p.into()),
495            SwarmUpdate::Synthesis(s) => Self::Synthesis(s),
496            SwarmUpdate::Error(e) => Self::Error(e),
497        }
498    }
499}
500
501impl From<Persona> for WirePersona {
502    fn from(p: Persona) -> Self {
503        Self {
504            name: p.name,
505            model: p.model,
506            blurb: p.blurb,
507        }
508    }
509}
510
511/// The model id exposed to OpenAI-wire clients. Internal model preferences
512/// retain `OpenRouter`'s historical bare ids; the public API must distinguish
513/// identical raw ids from different backends.
514pub(crate) fn public_model_id(model: &Model) -> String {
515    let prefix = model.backend.wire_prefix();
516    if model.id.starts_with(prefix) {
517        model.id.clone()
518    } else {
519        format!("{prefix}{}", model.id)
520    }
521}
522
523impl From<Model> for WireModel {
524    fn from(m: Model) -> Self {
525        Self {
526            id: public_model_id(&m),
527            name: m.name,
528            reasoning_efforts: m
529                .reasoning_efforts
530                .into_iter()
531                .map(WireReasoningEffort::from)
532                .collect(),
533            context_length: m.context_length,
534            supports_images: m.supports_images,
535            supports_image_generation: m.supports_image_generation,
536            supports_video_generation: m.supports_video_generation,
537            backend: m.backend.into(),
538            pricing: m.pricing.map(WireModelPricing::from),
539        }
540    }
541}
542
543impl From<ReasoningEffort> for WireReasoningEffort {
544    fn from(e: ReasoningEffort) -> Self {
545        match e {
546            ReasoningEffort::None => Self::None,
547            ReasoningEffort::Minimal => Self::Minimal,
548            ReasoningEffort::Low => Self::Low,
549            ReasoningEffort::Medium => Self::Medium,
550            ReasoningEffort::High => Self::High,
551            ReasoningEffort::XHigh => Self::XHigh,
552            ReasoningEffort::Max => Self::Max,
553        }
554    }
555}
556
557impl From<BackendTag> for WireBackendTag {
558    fn from(t: BackendTag) -> Self {
559        match t {
560            BackendTag::OpenRouter => Self::OpenRouter,
561            BackendTag::OpenAi => Self::OpenAi,
562            BackendTag::OpencodeGo => Self::OpencodeGo,
563            BackendTag::Codex => Self::Codex,
564        }
565    }
566}
567
568impl From<ModelPricing> for WireModelPricing {
569    fn from(p: ModelPricing) -> Self {
570        Self {
571            prompt: p.prompt,
572            completion: p.completion,
573            cache_read: p.cache_read,
574            cache_write: p.cache_write,
575        }
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    /// The wire model used across the round-trip and `From` tests: one
584    /// OpenRouter model with pricing and reasoning efforts.
585    fn wire_model() -> WireModel {
586        WireModel {
587            id: "openrouter:anthropic/claude-sonnet-4".into(),
588            name: "Claude Sonnet 4".into(),
589            reasoning_efforts: vec![WireReasoningEffort::None, WireReasoningEffort::High],
590            context_length: Some(200_000),
591            supports_images: true,
592            supports_image_generation: false,
593            supports_video_generation: false,
594            backend: WireBackendTag::OpenRouter,
595            pricing: Some(WireModelPricing {
596                prompt: 3.0,
597                completion: 15.0,
598                cache_read: Some(0.3),
599                cache_write: Some(3.0),
600            }),
601        }
602    }
603
604    /// One `WireEvent` per variant (plus `Some`/`None` for the optional
605    /// payloads) must survive a `serde_json` round-trip unchanged.
606    #[test]
607    fn round_trips_every_wire_event_variant() {
608        let events = vec![
609            WireEvent::Status("ready".into()),
610            WireEvent::ComposerSet("hi".into()),
611            WireEvent::ComposerClear,
612            WireEvent::ViewportReset,
613            WireEvent::HistoryInvalidated,
614            WireEvent::OpenLoginPopup,
615            WireEvent::Gate(Some(WireGateState {
616                session_id: "s1".into(),
617                phase: WireSurveyPhase::Clarify { round: 1 },
618            })),
619            WireEvent::Gate(None),
620            WireEvent::Stream(Some((
621                7,
622                WireStreamEvent::ToolCall {
623                    name: "python".into(),
624                    arguments: "print(1)".into(),
625                    result: "1\n".into(),
626                },
627            ))),
628            WireEvent::Stream(None),
629            WireEvent::Models(Some(Ok(vec![wire_model()]))),
630            WireEvent::Models(Some(Err("no backend configured".into()))),
631            WireEvent::Models(None),
632            WireEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
633            WireEvent::Memory(Some((
634                "sp1".into(),
635                vec![
636                    WireMemoryOp::Add("alpha".into()),
637                    WireMemoryOp::Update(2, "beta".into()),
638                    WireMemoryOp::Delete(3),
639                ],
640            ))),
641            WireEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
642            WireEvent::SkillInstall(Some(Ok("python".into()))),
643            WireEvent::SkillInstall(Some(Err("no model".into()))),
644            WireEvent::Ocr(Some((
645                "f1".into(),
646                "s1".into(),
647                WireOcrUpdate::Progress(1, 3, 0),
648            ))),
649            WireEvent::Embed(Some((
650                "s1".into(),
651                "f1".into(),
652                Ok(vec![(0, vec![0.1, 0.2])]),
653            ))),
654            WireEvent::OcrPull(Some(Err("pull failed".into()))),
655            WireEvent::Research(Some((
656                "s1".into(),
657                "sp1".into(),
658                "Space".into(),
659                WireResearchUpdate::PlanReady {
660                    questions: vec![WirePlanQuestion {
661                        question: "q1".into(),
662                        why: "w1".into(),
663                        angles: vec!["a1".into()],
664                        sources: vec!["s1".into()],
665                    }],
666                    rework: true,
667                },
668            ))),
669            WireEvent::ResearchTopic(Some(Ok("topic".into()))),
670            WireEvent::UpdateCheck(Some("0.2.0".into())),
671            WireEvent::Login(Some(WireLoginMsg::Done(Ok(())))),
672            WireEvent::Swarm(Some((
673                "s1".into(),
674                WireSwarmUpdate::RosterSuggested(vec![WirePersona {
675                    name: "ada".into(),
676                    model: "m1".into(),
677                    blurb: "b".into(),
678                }]),
679            ))),
680        ];
681        for ev in events {
682            let json = serde_json::to_string(&ev).expect("serializes");
683            let back: WireEvent = serde_json::from_str(&json).expect("parses");
684            assert_eq!(back, ev, "round-trip failed for {json}");
685        }
686    }
687
688    /// A golden frame locks the `SSE` wire shape for Phase 5 clients: the
689    /// adjacently-tagged envelope, snake_case type names, and the exact
690    /// payload nesting.
691    #[test]
692    fn golden_wire_event_json() {
693        let ev = WireEvent::Stream(Some((
694            7,
695            WireStreamEvent::ToolCall {
696                name: "python".into(),
697                arguments: "print(1)".into(),
698                result: "1\n".into(),
699            },
700        )));
701        let json = serde_json::to_string(&ev).expect("serializes");
702        assert_eq!(
703            json,
704            r#"{"type":"stream","payload":[7,{"ToolCall":{"name":"python","arguments":"print(1)","result":"1\n"}}]}"#
705        );
706        assert_eq!(
707            serde_json::to_string(&WireEvent::ComposerClear).expect("serializes"),
708            r#"{"type":"composer_clear"}"#
709        );
710        assert_eq!(
711            serde_json::to_string(&WireEvent::Gate(None)).expect("serializes"),
712            r#"{"type":"gate","payload":null}"#
713        );
714    }
715
716    /// The plain variants map across unchanged; the optional ones carry
717    /// `None` straight through.
718    #[test]
719    fn from_app_event_maps_plain_variants() {
720        let pairs = [
721            (AppEvent::Status("s".into()), WireEvent::Status("s".into())),
722            (
723                AppEvent::ComposerSet("c".into()),
724                WireEvent::ComposerSet("c".into()),
725            ),
726            (AppEvent::ComposerClear, WireEvent::ComposerClear),
727            (AppEvent::ViewportReset, WireEvent::ViewportReset),
728            (AppEvent::HistoryInvalidated, WireEvent::HistoryInvalidated),
729            (AppEvent::OpenLoginPopup, WireEvent::OpenLoginPopup),
730            (
731                AppEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
732                WireEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
733            ),
734            (
735                AppEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
736                WireEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
737            ),
738            (
739                AppEvent::SkillInstall(Some(Err("no model".into()))),
740                WireEvent::SkillInstall(Some(Err("no model".into()))),
741            ),
742            (
743                AppEvent::OcrPull(Some(Ok("glm-ocr".into()))),
744                WireEvent::OcrPull(Some(Ok("glm-ocr".into()))),
745            ),
746            (
747                AppEvent::ResearchTopic(Some(Err("offline".into()))),
748                WireEvent::ResearchTopic(Some(Err("offline".into()))),
749            ),
750            (
751                AppEvent::UpdateCheck(Some("0.2.0".into())),
752                WireEvent::UpdateCheck(Some("0.2.0".into())),
753            ),
754        ];
755        for (app, wire) in pairs {
756            assert_eq!(WireEvent::from(app), wire);
757        }
758    }
759
760    /// A closed source channel arrives as `AppEvent` with a `None` payload
761    /// and must stay `None` on the wire.
762    #[test]
763    fn from_app_event_none_means_channel_closed() {
764        for ev in [
765            AppEvent::Gate(None),
766            AppEvent::Stream(None),
767            AppEvent::Models(None),
768            AppEvent::Title(None),
769            AppEvent::Memory(None),
770            AppEvent::Compact(None),
771            AppEvent::SkillInstall(None),
772            AppEvent::Ocr(None),
773            AppEvent::Embed(None),
774            AppEvent::OcrPull(None),
775            AppEvent::Research(None),
776            AppEvent::ResearchTopic(None),
777            AppEvent::UpdateCheck(None),
778            AppEvent::Login(None),
779            AppEvent::Swarm(None),
780        ] {
781            let wire = WireEvent::from(ev);
782            let json = serde_json::to_string(&wire).expect("serializes");
783            assert!(
784                json.contains("\"payload\":null"),
785                "expected null payload, got {json}"
786            );
787        }
788    }
789
790    /// The chat-frame carrier: `AppEvent::Stream` maps task id and event
791    /// through every `StreamEvent` shape, including the tool-call delta the
792    /// stream view renders as its own block.
793    #[test]
794    fn from_app_event_maps_stream_frames() {
795        let frames = [
796            (
797                StreamEvent::Token("hi".into()),
798                WireStreamEvent::Token("hi".into()),
799            ),
800            (
801                StreamEvent::Reasoning("think".into()),
802                WireStreamEvent::Reasoning("think".into()),
803            ),
804            (
805                StreamEvent::Usage(Usage {
806                    prompt_tokens: 10,
807                    completion_tokens: 5,
808                    total_tokens: 15,
809                    cache_read_tokens: 2,
810                    cache_creation_tokens: 1,
811                    cost: Some(0.0012),
812                }),
813                WireStreamEvent::Usage(WireUsage {
814                    prompt_tokens: 10,
815                    completion_tokens: 5,
816                    total_tokens: 15,
817                    cache_read_tokens: 2,
818                    cache_creation_tokens: 1,
819                    cost: Some(0.0012),
820                }),
821            ),
822            (
823                StreamEvent::Status("running python…".into()),
824                WireStreamEvent::Status("running python…".into()),
825            ),
826            (
827                StreamEvent::ToolCall {
828                    name: "python".into(),
829                    arguments: "print(1)".into(),
830                    result: "1".into(),
831                },
832                WireStreamEvent::ToolCall {
833                    name: "python".into(),
834                    arguments: "print(1)".into(),
835                    result: "1".into(),
836                },
837            ),
838            (StreamEvent::Done, WireStreamEvent::Done),
839            (
840                StreamEvent::Error("boom".into()),
841                WireStreamEvent::Error("boom".into()),
842            ),
843        ];
844        for (event, wire) in frames {
845            let app = AppEvent::Stream(Some((3, event)));
846            assert_eq!(WireEvent::from(app), WireEvent::Stream(Some((3, wire))));
847        }
848    }
849
850    /// Gate and model-catalog payloads map their nested types (survey
851    /// phase, reasoning efforts, pricing, backend tag) through.
852    #[test]
853    fn from_app_event_maps_gate_and_models() {
854        let app = AppEvent::Gate(Some(GateState {
855            session_id: "s1".into(),
856            phase: SurveyPhase::Approve { rework: true },
857        }));
858        assert_eq!(
859            WireEvent::from(app),
860            WireEvent::Gate(Some(WireGateState {
861                session_id: "s1".into(),
862                phase: WireSurveyPhase::Approve { rework: true },
863            }))
864        );
865
866        let model = Model {
867            id: "openrouter:anthropic/claude-sonnet-4".into(),
868            name: "Claude Sonnet 4".into(),
869            reasoning_efforts: vec![ReasoningEffort::None, ReasoningEffort::High],
870            context_length: Some(200_000),
871            supports_images: true,
872            supports_image_generation: false,
873            supports_video_generation: false,
874            backend: BackendTag::OpenRouter,
875            pricing: Some(ModelPricing {
876                prompt: 3.0,
877                completion: 15.0,
878                cache_read: Some(0.3),
879                cache_write: Some(3.0),
880            }),
881        };
882        let app = AppEvent::Models(Some(Ok(vec![model])));
883        assert_eq!(
884            WireEvent::from(app),
885            WireEvent::Models(Some(Ok(vec![wire_model()])))
886        );
887
888        let app = AppEvent::Models(Some(Err("no backend configured".into())));
889        assert_eq!(
890            WireEvent::from(app),
891            WireEvent::Models(Some(Err("no backend configured".into())))
892        );
893    }
894
895    /// Memory and `OCR` payloads map every op/update shape.
896    #[test]
897    fn from_app_event_maps_memory_and_ocr() {
898        let app = AppEvent::Memory(Some((
899            "sp1".into(),
900            vec![
901                MemoryOp::Add("alpha".into()),
902                MemoryOp::Update(2, "beta".into()),
903                MemoryOp::Delete(3),
904            ],
905        )));
906        assert_eq!(
907            WireEvent::from(app),
908            WireEvent::Memory(Some((
909                "sp1".into(),
910                vec![
911                    WireMemoryOp::Add("alpha".into()),
912                    WireMemoryOp::Update(2, "beta".into()),
913                    WireMemoryOp::Delete(3),
914                ],
915            )))
916        );
917
918        let app = AppEvent::Ocr(Some((
919            "f1".into(),
920            "s1".into(),
921            OcrUpdate::Done(Ok(("text".into(), vec![(2, "reason".into())]))),
922        )));
923        assert_eq!(
924            WireEvent::from(app),
925            WireEvent::Ocr(Some((
926                "f1".into(),
927                "s1".into(),
928                WireOcrUpdate::Done(Ok(("text".into(), vec![(2, "reason".into())]))),
929            )))
930        );
931    }
932
933    /// Research payloads map stage ticks, both parked gates, and the final
934    /// result — including the planner's question list.
935    #[test]
936    fn from_app_event_maps_research() {
937        let updates = [
938            (
939                ResearchUpdate::Stage {
940                    label: "survey".into(),
941                    detail: "asking…".into(),
942                },
943                WireResearchUpdate::Stage {
944                    label: "survey".into(),
945                    detail: "asking…".into(),
946                },
947            ),
948            (
949                ResearchUpdate::SurveyReady {
950                    questions: vec!["q1".into()],
951                    round: 1,
952                },
953                WireResearchUpdate::SurveyReady {
954                    questions: vec!["q1".into()],
955                    round: 1,
956                },
957            ),
958            (
959                ResearchUpdate::PlanReady {
960                    questions: vec![PlanQuestion {
961                        question: "q1".into(),
962                        why: "w1".into(),
963                        angles: vec!["a1".into()],
964                        sources: vec!["s1".into()],
965                    }],
966                    rework: false,
967                },
968                WireResearchUpdate::PlanReady {
969                    questions: vec![WirePlanQuestion {
970                        question: "q1".into(),
971                        why: "w1".into(),
972                        angles: vec!["a1".into()],
973                        sources: vec!["s1".into()],
974                    }],
975                    rework: false,
976                },
977            ),
978            (
979                ResearchUpdate::Done(Ok("report".into())),
980                WireResearchUpdate::Done(Ok("report".into())),
981            ),
982        ];
983        for (update, wire) in updates {
984            let app = AppEvent::Research(Some(("s1".into(), "sp1".into(), "Space".into(), update)));
985            assert_eq!(
986                WireEvent::from(app),
987                WireEvent::Research(Some(("s1".into(), "sp1".into(), "Space".into(), wire)))
988            );
989        }
990    }
991
992    /// Swarm and login payloads map their nested types (roster personas),
993    /// while login credentials are reduced to a success marker.
994    #[test]
995    fn from_app_event_maps_swarm_and_login() {
996        let app = AppEvent::Swarm(Some((
997            "s1".into(),
998            SwarmUpdate::RosterSuggested(vec![Persona {
999                name: "ada".into(),
1000                model: "m1".into(),
1001                blurb: "b".into(),
1002            }]),
1003        )));
1004        assert_eq!(
1005            WireEvent::from(app),
1006            WireEvent::Swarm(Some((
1007                "s1".into(),
1008                WireSwarmUpdate::RosterSuggested(vec![WirePersona {
1009                    name: "ada".into(),
1010                    model: "m1".into(),
1011                    blurb: "b".into(),
1012                }]),
1013            )))
1014        );
1015
1016        let app = AppEvent::Login(Some(LoginMsg::Done(Ok(crate::config::CodexCredentials {
1017            access: "access-secret".into(),
1018            refresh: "refresh-secret".into(),
1019            expires: 123,
1020            account_id: "acc".into(),
1021        }))));
1022        let wire = WireEvent::from(app);
1023        let json = serde_json::to_string(&wire).expect("login event serializes");
1024        assert!(!json.contains("access-secret"));
1025        assert!(!json.contains("refresh-secret"));
1026        assert_eq!(wire, WireEvent::Login(Some(WireLoginMsg::Done(Ok(())))));
1027    }
1028}