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 } => 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 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
511pub(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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}