1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
32pub enum WireEvent {
33 Status(String),
35 ComposerSet(String),
38 ComposerClear,
40 ViewportReset,
44 HistoryInvalidated,
47 OpenLoginPopup,
50 Gate(Option<WireGateState>),
52 Stream(Option<(u64, WireStreamEvent)>),
56 Models(Option<WireModelsResult>),
59 Title(Option<(String, String, String)>),
61 Memory(Option<(String, Vec<WireMemoryOp>)>),
64 Compact(Option<(String, String, i64, u64)>),
67 SkillInstall(Option<Result<String, String>>),
70 Ocr(Option<(String, String, WireOcrUpdate)>),
73 Embed(Option<WireEmbedMsg>),
75 OcrPull(Option<Result<String, String>>),
77 Research(Option<WireResearchMsg>),
79 ResearchTopic(Option<Result<String, String>>),
82 UpdateCheck(Option<String>),
85 Login(Option<WireLoginMsg>),
87 Swarm(Option<WireSwarmMsg>),
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub enum WireStreamEvent {
96 Token(String),
98 Reasoning(String),
100 Usage(WireUsage),
103 Status(String),
105 ToolCall {
107 name: String,
108 arguments: String,
109 result: String,
110 },
111 Done,
113 Error(String),
115}
116
117#[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 pub cache_read_tokens: u64,
126 pub cache_creation_tokens: u64,
128 pub cost: Option<f64>,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct WireGateState {
137 pub session_id: String,
139 pub phase: WireSurveyPhase,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub enum WireSurveyPhase {
147 Clarify { round: u8 },
149 Approve { rework: bool },
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub enum WireMemoryOp {
157 Add(String),
158 Update(usize, String),
159 Delete(usize),
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
164pub enum WireOcrUpdate {
165 Stage(String),
168 Progress(usize, usize, usize),
170 Done(Result<(String, Vec<(usize, String)>), String>),
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub enum WireResearchUpdate {
179 Stage { label: String, detail: String },
182 SurveyReady { questions: Vec<String>, round: u8 },
185 PlanReady {
189 questions: Vec<WirePlanQuestion>,
190 rework: bool,
191 },
192 Done(Result<String, String>),
194}
195
196#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub enum WireLoginMsg {
208 Status(String),
209 Done(Result<(), String>),
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub enum WireSwarmUpdate {
217 RosterSuggested(Vec<WirePersona>),
220 Progress(String),
222 Reply {
224 persona: String,
225 model: String,
226 content: String,
227 },
228 PersonaJoined(WirePersona),
231 Synthesis(String),
233 Error(String),
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct WirePersona {
239 pub name: String,
240 pub model: String,
241 pub blurb: String,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct WireModel {
248 pub id: String,
251 pub name: String,
252 pub reasoning_efforts: Vec<WireReasoningEffort>,
255 pub context_length: Option<u64>,
257 pub supports_images: bool,
259 pub supports_image_generation: bool,
261 pub supports_video_generation: bool,
263 pub backend: WireBackendTag,
265 pub pricing: Option<WireModelPricing>,
267}
268
269#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284pub enum WireBackendTag {
285 OpenRouter,
286 OpenAi,
287 OpencodeGo,
288 Codex,
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
293pub struct WireModelPricing {
294 pub prompt: f64,
295 pub completion: f64,
296 pub cache_read: Option<f64>,
299 pub cache_write: Option<f64>,
301}
302
303pub type WireModelsResult = Result<Vec<WireModel>, String>;
305
306pub type WireEmbedMsg = (String, String, Result<Vec<(i64, Vec<f32>)>, String>);
309
310pub type WireResearchMsg = (String, String, String, WireResearchUpdate);
313
314pub 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 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
512pub(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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}