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                ..
376            } => Self::ToolCall {
377                name,
378                arguments,
379                result,
380            },
381            StreamEvent::Done => Self::Done,
382            StreamEvent::Error(e) => Self::Error(e),
383        }
384    }
385}
386
387impl From<Usage> for WireUsage {
388    fn from(u: Usage) -> Self {
389        Self {
390            prompt_tokens: u.prompt_tokens,
391            completion_tokens: u.completion_tokens,
392            total_tokens: u.total_tokens,
393            cache_read_tokens: u.cache_read_tokens,
394            cache_creation_tokens: u.cache_creation_tokens,
395            cost: u.cost,
396        }
397    }
398}
399
400impl From<GateState> for WireGateState {
401    fn from(g: GateState) -> Self {
402        Self {
403            session_id: g.session_id,
404            phase: g.phase.into(),
405        }
406    }
407}
408
409impl From<SurveyPhase> for WireSurveyPhase {
410    fn from(p: SurveyPhase) -> Self {
411        match p {
412            SurveyPhase::Clarify { round } => Self::Clarify { round },
413            SurveyPhase::Approve { rework } => Self::Approve { rework },
414        }
415    }
416}
417
418impl From<MemoryOp> for WireMemoryOp {
419    fn from(op: MemoryOp) -> Self {
420        match op {
421            MemoryOp::Add(s) => Self::Add(s),
422            MemoryOp::Update(i, s) => Self::Update(i, s),
423            MemoryOp::Delete(i) => Self::Delete(i),
424        }
425    }
426}
427
428impl From<OcrUpdate> for WireOcrUpdate {
429    fn from(u: OcrUpdate) -> Self {
430        match u {
431            OcrUpdate::Stage(s) => Self::Stage(s),
432            OcrUpdate::Progress(done, total, failed) => Self::Progress(done, total, failed),
433            OcrUpdate::Done(d) => Self::Done(d),
434        }
435    }
436}
437
438impl From<ResearchUpdate> for WireResearchUpdate {
439    fn from(u: ResearchUpdate) -> Self {
440        match u {
441            ResearchUpdate::Stage { label, detail } => Self::Stage { label, detail },
442            ResearchUpdate::SurveyReady { questions, round } => {
443                Self::SurveyReady { questions, round }
444            }
445            ResearchUpdate::PlanReady { questions, rework } => Self::PlanReady {
446                questions: questions.into_iter().map(WirePlanQuestion::from).collect(),
447                rework,
448            },
449            ResearchUpdate::Done(d) => Self::Done(d),
450        }
451    }
452}
453
454impl From<PlanQuestion> for WirePlanQuestion {
455    fn from(q: PlanQuestion) -> Self {
456        Self {
457            question: q.question,
458            why: q.why,
459            angles: q.angles,
460            sources: q.sources,
461        }
462    }
463}
464
465impl From<LoginMsg> for WireLoginMsg {
466    fn from(m: LoginMsg) -> Self {
467        match m {
468            // Device codes and prefilled URLs are short-lived credentials;
469            // the local UI receives them directly, never the host wire.
470            LoginMsg::Status(_) => Self::Status("codex login in progress".into()),
471            LoginMsg::Done(d) => Self::Done(match d {
472                Ok(_) => Ok(()),
473                Err(_) => Err("codex login failed".into()),
474            }),
475        }
476    }
477}
478
479impl From<SwarmUpdate> for WireSwarmUpdate {
480    fn from(u: SwarmUpdate) -> Self {
481        match u {
482            SwarmUpdate::RosterSuggested(p) => {
483                Self::RosterSuggested(p.into_iter().map(WirePersona::from).collect())
484            }
485            SwarmUpdate::Progress(s) => Self::Progress(s),
486            SwarmUpdate::Reply {
487                persona,
488                model,
489                content,
490            } => Self::Reply {
491                persona,
492                model,
493                content,
494            },
495            SwarmUpdate::PersonaJoined(p) => Self::PersonaJoined(p.into()),
496            SwarmUpdate::Synthesis(s) => Self::Synthesis(s),
497            SwarmUpdate::Error(e) => Self::Error(e),
498        }
499    }
500}
501
502impl From<Persona> for WirePersona {
503    fn from(p: Persona) -> Self {
504        Self {
505            name: p.name,
506            model: p.model,
507            blurb: p.blurb,
508        }
509    }
510}
511
512/// The model id exposed to OpenAI-wire clients. Internal model preferences
513/// retain `OpenRouter`'s historical bare ids; the public API must distinguish
514/// identical raw ids from different backends.
515pub(crate) fn public_model_id(model: &Model) -> String {
516    let prefix = model.backend.wire_prefix();
517    if model.id.starts_with(prefix) {
518        model.id.clone()
519    } else {
520        format!("{prefix}{}", model.id)
521    }
522}
523
524impl From<Model> for WireModel {
525    fn from(m: Model) -> Self {
526        Self {
527            id: public_model_id(&m),
528            name: m.name,
529            reasoning_efforts: m
530                .reasoning_efforts
531                .into_iter()
532                .map(WireReasoningEffort::from)
533                .collect(),
534            context_length: m.context_length,
535            supports_images: m.supports_images,
536            supports_image_generation: m.supports_image_generation,
537            supports_video_generation: m.supports_video_generation,
538            backend: m.backend.into(),
539            pricing: m.pricing.map(WireModelPricing::from),
540        }
541    }
542}
543
544impl From<ReasoningEffort> for WireReasoningEffort {
545    fn from(e: ReasoningEffort) -> Self {
546        match e {
547            ReasoningEffort::None => Self::None,
548            ReasoningEffort::Minimal => Self::Minimal,
549            ReasoningEffort::Low => Self::Low,
550            ReasoningEffort::Medium => Self::Medium,
551            ReasoningEffort::High => Self::High,
552            ReasoningEffort::XHigh => Self::XHigh,
553            ReasoningEffort::Max => Self::Max,
554        }
555    }
556}
557
558impl From<BackendTag> for WireBackendTag {
559    fn from(t: BackendTag) -> Self {
560        match t {
561            BackendTag::OpenRouter => Self::OpenRouter,
562            BackendTag::OpenAi => Self::OpenAi,
563            BackendTag::OpencodeGo => Self::OpencodeGo,
564            BackendTag::Codex => Self::Codex,
565        }
566    }
567}
568
569impl From<ModelPricing> for WireModelPricing {
570    fn from(p: ModelPricing) -> Self {
571        Self {
572            prompt: p.prompt,
573            completion: p.completion,
574            cache_read: p.cache_read,
575            cache_write: p.cache_write,
576        }
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583
584    /// The wire model used across the round-trip and `From` tests: one
585    /// OpenRouter model with pricing and reasoning efforts.
586    fn wire_model() -> WireModel {
587        WireModel {
588            id: "openrouter:anthropic/claude-sonnet-4".into(),
589            name: "Claude Sonnet 4".into(),
590            reasoning_efforts: vec![WireReasoningEffort::None, WireReasoningEffort::High],
591            context_length: Some(200_000),
592            supports_images: true,
593            supports_image_generation: false,
594            supports_video_generation: false,
595            backend: WireBackendTag::OpenRouter,
596            pricing: Some(WireModelPricing {
597                prompt: 3.0,
598                completion: 15.0,
599                cache_read: Some(0.3),
600                cache_write: Some(3.0),
601            }),
602        }
603    }
604
605    /// One `WireEvent` per variant (plus `Some`/`None` for the optional
606    /// payloads) must survive a `serde_json` round-trip unchanged.
607    #[test]
608    fn round_trips_every_wire_event_variant() {
609        let events = vec![
610            WireEvent::Status("ready".into()),
611            WireEvent::ComposerSet("hi".into()),
612            WireEvent::ComposerClear,
613            WireEvent::ViewportReset,
614            WireEvent::HistoryInvalidated,
615            WireEvent::OpenLoginPopup,
616            WireEvent::Gate(Some(WireGateState {
617                session_id: "s1".into(),
618                phase: WireSurveyPhase::Clarify { round: 1 },
619            })),
620            WireEvent::Gate(None),
621            WireEvent::Stream(Some((
622                7,
623                WireStreamEvent::ToolCall {
624                    name: "python".into(),
625                    arguments: "print(1)".into(),
626                    result: "1\n".into(),
627                },
628            ))),
629            WireEvent::Stream(None),
630            WireEvent::Models(Some(Ok(vec![wire_model()]))),
631            WireEvent::Models(Some(Err("no backend configured".into()))),
632            WireEvent::Models(None),
633            WireEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
634            WireEvent::Memory(Some((
635                "sp1".into(),
636                vec![
637                    WireMemoryOp::Add("alpha".into()),
638                    WireMemoryOp::Update(2, "beta".into()),
639                    WireMemoryOp::Delete(3),
640                ],
641            ))),
642            WireEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
643            WireEvent::SkillInstall(Some(Ok("python".into()))),
644            WireEvent::SkillInstall(Some(Err("no model".into()))),
645            WireEvent::Ocr(Some((
646                "f1".into(),
647                "s1".into(),
648                WireOcrUpdate::Progress(1, 3, 0),
649            ))),
650            WireEvent::Embed(Some((
651                "s1".into(),
652                "f1".into(),
653                Ok(vec![(0, vec![0.1, 0.2])]),
654            ))),
655            WireEvent::OcrPull(Some(Err("pull failed".into()))),
656            WireEvent::Research(Some((
657                "s1".into(),
658                "sp1".into(),
659                "Space".into(),
660                WireResearchUpdate::PlanReady {
661                    questions: vec![WirePlanQuestion {
662                        question: "q1".into(),
663                        why: "w1".into(),
664                        angles: vec!["a1".into()],
665                        sources: vec!["s1".into()],
666                    }],
667                    rework: true,
668                },
669            ))),
670            WireEvent::ResearchTopic(Some(Ok("topic".into()))),
671            WireEvent::UpdateCheck(Some("0.2.0".into())),
672            WireEvent::Login(Some(WireLoginMsg::Done(Ok(())))),
673            WireEvent::Swarm(Some((
674                "s1".into(),
675                WireSwarmUpdate::RosterSuggested(vec![WirePersona {
676                    name: "ada".into(),
677                    model: "m1".into(),
678                    blurb: "b".into(),
679                }]),
680            ))),
681        ];
682        for ev in events {
683            let json = serde_json::to_string(&ev).expect("serializes");
684            let back: WireEvent = serde_json::from_str(&json).expect("parses");
685            assert_eq!(back, ev, "round-trip failed for {json}");
686        }
687    }
688
689    /// A golden frame locks the `SSE` wire shape for Phase 5 clients: the
690    /// adjacently-tagged envelope, snake_case type names, and the exact
691    /// payload nesting.
692    #[test]
693    fn golden_wire_event_json() {
694        let ev = WireEvent::Stream(Some((
695            7,
696            WireStreamEvent::ToolCall {
697                name: "python".into(),
698                arguments: "print(1)".into(),
699                result: "1\n".into(),
700            },
701        )));
702        let json = serde_json::to_string(&ev).expect("serializes");
703        assert_eq!(
704            json,
705            r#"{"type":"stream","payload":[7,{"ToolCall":{"name":"python","arguments":"print(1)","result":"1\n"}}]}"#
706        );
707        assert_eq!(
708            serde_json::to_string(&WireEvent::ComposerClear).expect("serializes"),
709            r#"{"type":"composer_clear"}"#
710        );
711        assert_eq!(
712            serde_json::to_string(&WireEvent::Gate(None)).expect("serializes"),
713            r#"{"type":"gate","payload":null}"#
714        );
715    }
716
717    /// The plain variants map across unchanged; the optional ones carry
718    /// `None` straight through.
719    #[test]
720    fn from_app_event_maps_plain_variants() {
721        let pairs = [
722            (AppEvent::Status("s".into()), WireEvent::Status("s".into())),
723            (
724                AppEvent::ComposerSet("c".into()),
725                WireEvent::ComposerSet("c".into()),
726            ),
727            (AppEvent::ComposerClear, WireEvent::ComposerClear),
728            (AppEvent::ViewportReset, WireEvent::ViewportReset),
729            (AppEvent::HistoryInvalidated, WireEvent::HistoryInvalidated),
730            (AppEvent::OpenLoginPopup, WireEvent::OpenLoginPopup),
731            (
732                AppEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
733                WireEvent::Title(Some(("s1".into(), "Hello".into(), "hello".into()))),
734            ),
735            (
736                AppEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
737                WireEvent::Compact(Some(("s1".into(), "digest".into(), 12, 40))),
738            ),
739            (
740                AppEvent::SkillInstall(Some(Err("no model".into()))),
741                WireEvent::SkillInstall(Some(Err("no model".into()))),
742            ),
743            (
744                AppEvent::OcrPull(Some(Ok("glm-ocr".into()))),
745                WireEvent::OcrPull(Some(Ok("glm-ocr".into()))),
746            ),
747            (
748                AppEvent::ResearchTopic(Some(Err("offline".into()))),
749                WireEvent::ResearchTopic(Some(Err("offline".into()))),
750            ),
751            (
752                AppEvent::UpdateCheck(Some("0.2.0".into())),
753                WireEvent::UpdateCheck(Some("0.2.0".into())),
754            ),
755        ];
756        for (app, wire) in pairs {
757            assert_eq!(WireEvent::from(app), wire);
758        }
759    }
760
761    /// A closed source channel arrives as `AppEvent` with a `None` payload
762    /// and must stay `None` on the wire.
763    #[test]
764    fn from_app_event_none_means_channel_closed() {
765        for ev in [
766            AppEvent::Gate(None),
767            AppEvent::Stream(None),
768            AppEvent::Models(None),
769            AppEvent::Title(None),
770            AppEvent::Memory(None),
771            AppEvent::Compact(None),
772            AppEvent::SkillInstall(None),
773            AppEvent::Ocr(None),
774            AppEvent::Embed(None),
775            AppEvent::OcrPull(None),
776            AppEvent::Research(None),
777            AppEvent::ResearchTopic(None),
778            AppEvent::UpdateCheck(None),
779            AppEvent::Login(None),
780            AppEvent::Swarm(None),
781        ] {
782            let wire = WireEvent::from(ev);
783            let json = serde_json::to_string(&wire).expect("serializes");
784            assert!(
785                json.contains("\"payload\":null"),
786                "expected null payload, got {json}"
787            );
788        }
789    }
790
791    /// The chat-frame carrier: `AppEvent::Stream` maps task id and event
792    /// through every `StreamEvent` shape, including the tool-call delta the
793    /// stream view renders as its own block.
794    #[test]
795    fn from_app_event_maps_stream_frames() {
796        let frames = [
797            (
798                StreamEvent::Token("hi".into()),
799                WireStreamEvent::Token("hi".into()),
800            ),
801            (
802                StreamEvent::Reasoning("think".into()),
803                WireStreamEvent::Reasoning("think".into()),
804            ),
805            (
806                StreamEvent::Usage(Usage {
807                    prompt_tokens: 10,
808                    completion_tokens: 5,
809                    total_tokens: 15,
810                    cache_read_tokens: 2,
811                    cache_creation_tokens: 1,
812                    cost: Some(0.0012),
813                }),
814                WireStreamEvent::Usage(WireUsage {
815                    prompt_tokens: 10,
816                    completion_tokens: 5,
817                    total_tokens: 15,
818                    cache_read_tokens: 2,
819                    cache_creation_tokens: 1,
820                    cost: Some(0.0012),
821                }),
822            ),
823            (
824                StreamEvent::Status("running python…".into()),
825                WireStreamEvent::Status("running python…".into()),
826            ),
827            (
828                StreamEvent::ToolCall {
829                    id: "call_0".into(),
830                    reasoning: None,
831                    assistant_content: None,
832                    name: "python".into(),
833                    arguments: "print(1)".into(),
834                    result: "1".into(),
835                },
836                WireStreamEvent::ToolCall {
837                    name: "python".into(),
838                    arguments: "print(1)".into(),
839                    result: "1".into(),
840                },
841            ),
842            (StreamEvent::Done, WireStreamEvent::Done),
843            (
844                StreamEvent::Error("boom".into()),
845                WireStreamEvent::Error("boom".into()),
846            ),
847        ];
848        for (event, wire) in frames {
849            let app = AppEvent::Stream(Some((3, event)));
850            assert_eq!(WireEvent::from(app), WireEvent::Stream(Some((3, wire))));
851        }
852    }
853
854    /// Gate and model-catalog payloads map their nested types (survey
855    /// phase, reasoning efforts, pricing, backend tag) through.
856    #[test]
857    fn from_app_event_maps_gate_and_models() {
858        let app = AppEvent::Gate(Some(GateState {
859            session_id: "s1".into(),
860            phase: SurveyPhase::Approve { rework: true },
861        }));
862        assert_eq!(
863            WireEvent::from(app),
864            WireEvent::Gate(Some(WireGateState {
865                session_id: "s1".into(),
866                phase: WireSurveyPhase::Approve { rework: true },
867            }))
868        );
869
870        let model = Model {
871            id: "openrouter:anthropic/claude-sonnet-4".into(),
872            name: "Claude Sonnet 4".into(),
873            reasoning_efforts: vec![ReasoningEffort::None, ReasoningEffort::High],
874            context_length: Some(200_000),
875            supports_images: true,
876            supports_image_generation: false,
877            supports_video_generation: false,
878            backend: BackendTag::OpenRouter,
879            pricing: Some(ModelPricing {
880                prompt: 3.0,
881                completion: 15.0,
882                cache_read: Some(0.3),
883                cache_write: Some(3.0),
884            }),
885        };
886        let app = AppEvent::Models(Some(Ok(vec![model])));
887        assert_eq!(
888            WireEvent::from(app),
889            WireEvent::Models(Some(Ok(vec![wire_model()])))
890        );
891
892        let app = AppEvent::Models(Some(Err("no backend configured".into())));
893        assert_eq!(
894            WireEvent::from(app),
895            WireEvent::Models(Some(Err("no backend configured".into())))
896        );
897    }
898
899    /// Memory and `OCR` payloads map every op/update shape.
900    #[test]
901    fn from_app_event_maps_memory_and_ocr() {
902        let app = AppEvent::Memory(Some((
903            "sp1".into(),
904            vec![
905                MemoryOp::Add("alpha".into()),
906                MemoryOp::Update(2, "beta".into()),
907                MemoryOp::Delete(3),
908            ],
909        )));
910        assert_eq!(
911            WireEvent::from(app),
912            WireEvent::Memory(Some((
913                "sp1".into(),
914                vec![
915                    WireMemoryOp::Add("alpha".into()),
916                    WireMemoryOp::Update(2, "beta".into()),
917                    WireMemoryOp::Delete(3),
918                ],
919            )))
920        );
921
922        let app = AppEvent::Ocr(Some((
923            "f1".into(),
924            "s1".into(),
925            OcrUpdate::Done(Ok(("text".into(), vec![(2, "reason".into())]))),
926        )));
927        assert_eq!(
928            WireEvent::from(app),
929            WireEvent::Ocr(Some((
930                "f1".into(),
931                "s1".into(),
932                WireOcrUpdate::Done(Ok(("text".into(), vec![(2, "reason".into())]))),
933            )))
934        );
935    }
936
937    /// Research payloads map stage ticks, both parked gates, and the final
938    /// result — including the planner's question list.
939    #[test]
940    fn from_app_event_maps_research() {
941        let updates = [
942            (
943                ResearchUpdate::Stage {
944                    label: "survey".into(),
945                    detail: "asking…".into(),
946                },
947                WireResearchUpdate::Stage {
948                    label: "survey".into(),
949                    detail: "asking…".into(),
950                },
951            ),
952            (
953                ResearchUpdate::SurveyReady {
954                    questions: vec!["q1".into()],
955                    round: 1,
956                },
957                WireResearchUpdate::SurveyReady {
958                    questions: vec!["q1".into()],
959                    round: 1,
960                },
961            ),
962            (
963                ResearchUpdate::PlanReady {
964                    questions: vec![PlanQuestion {
965                        question: "q1".into(),
966                        why: "w1".into(),
967                        angles: vec!["a1".into()],
968                        sources: vec!["s1".into()],
969                    }],
970                    rework: false,
971                },
972                WireResearchUpdate::PlanReady {
973                    questions: vec![WirePlanQuestion {
974                        question: "q1".into(),
975                        why: "w1".into(),
976                        angles: vec!["a1".into()],
977                        sources: vec!["s1".into()],
978                    }],
979                    rework: false,
980                },
981            ),
982            (
983                ResearchUpdate::Done(Ok("report".into())),
984                WireResearchUpdate::Done(Ok("report".into())),
985            ),
986        ];
987        for (update, wire) in updates {
988            let app = AppEvent::Research(Some(("s1".into(), "sp1".into(), "Space".into(), update)));
989            assert_eq!(
990                WireEvent::from(app),
991                WireEvent::Research(Some(("s1".into(), "sp1".into(), "Space".into(), wire)))
992            );
993        }
994    }
995
996    /// Swarm and login payloads map their nested types (roster personas),
997    /// while login credentials are reduced to a success marker.
998    #[test]
999    fn from_app_event_maps_swarm_and_login() {
1000        let app = AppEvent::Swarm(Some((
1001            "s1".into(),
1002            SwarmUpdate::RosterSuggested(vec![Persona {
1003                name: "ada".into(),
1004                model: "m1".into(),
1005                blurb: "b".into(),
1006            }]),
1007        )));
1008        assert_eq!(
1009            WireEvent::from(app),
1010            WireEvent::Swarm(Some((
1011                "s1".into(),
1012                WireSwarmUpdate::RosterSuggested(vec![WirePersona {
1013                    name: "ada".into(),
1014                    model: "m1".into(),
1015                    blurb: "b".into(),
1016                }]),
1017            )))
1018        );
1019
1020        let app = AppEvent::Login(Some(LoginMsg::Done(Ok(crate::config::CodexCredentials {
1021            access: "access-secret".into(),
1022            refresh: "refresh-secret".into(),
1023            expires: 123,
1024            account_id: "acc".into(),
1025        }))));
1026        let wire = WireEvent::from(app);
1027        let json = serde_json::to_string(&wire).expect("login event serializes");
1028        assert!(!json.contains("access-secret"));
1029        assert!(!json.contains("refresh-secret"));
1030        assert_eq!(wire, WireEvent::Login(Some(WireLoginMsg::Done(Ok(())))));
1031    }
1032}