git.iko.soy/opencode-android
on master help sign in
1051 lines 40 kB history raw
1//! The Crux `App`: model, events, effects, update logic and view.
2//!
3//! Streaming works like this: after a successful connect the shell opens the
4//! `/event` SSE stream and forwards every decoded event as
5//! [`Event::ServerEvent`]. The core parses those and mutates the chat
6//! incrementally — creating the assistant message on `message.updated`, growing
7//! its text on `message.part.updated`, and clearing the "generating" flag on
8//! `session.idle`. User messages are shown optimistically the moment they're
9//! sent, so the stream only ever *adds* assistant content.
11use base64::Engine;
12use crux_core::{
13 macros::effect,
14 render::{render, RenderOperation},
15 App, Command,
16};
17use crux_http::command::{Http, RequestBuilder};
18use crux_http::protocol::HttpRequest;
19use serde::{Deserialize, Serialize};
21use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
23/// Result of an HTTP effect handed back to the core.
24pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
26// ─── Events ──────────────────────────────────────────────────────────────────
28pub enum Event {
29 // Lifecycle / connect
30 Start,
31 ServerUrlChanged(String),
32 UsernameChanged(String),
33 PasswordChanged(String),
34 Connect,
36 // Sessions
37 LoadSessions,
38 SelectSession(String),
39 CreateSession,
40 DeleteSession(String),
42 // Chat
43 LoadMessages(String),
44 DraftChanged(String),
45 SendMessage(String),
46 CancelGeneration,
48 // A raw event line from the shell's `/event` SSE subscription.
49 ServerEvent(String),
51 // Navigation / misc
52 NavigateToSessions,
53 NavigateToConnect,
54 DismissError,
56 // ── Internal: results of HTTP effects (never sent by the shell) ──────────
57 HealthProbed(HttpResult),
58 SessionsLoaded(HttpResult),
59 SessionCreated(HttpResult),
60 MessagesLoaded(String, HttpResult),
61 PromptSent(HttpResult),
62 SessionDeleted(HttpResult),
63 Aborted(HttpResult),
66// ─── Effects ─────────────────────────────────────────────────────────────────
68#[effect]
69#[derive(Debug)]
70pub enum Effect {
71 Render(RenderOperation),
72 Http(HttpRequest),
75// ─── Model ───────────────────────────────────────────────────────────────────
77#[derive(Default)]
78pub struct Model {
79 server_url: String,
80 username: String,
81 password: String,
82 /// The server answered 401 to an unauthenticated probe — show credentials.
83 auth_required: bool,
84 /// Credentials were accepted; attach the auth header to every request.
85 authed: bool,
86 connected: bool,
87 loading: bool,
88 error: Option<String>,
89 screen: Screen,
91 sessions: Vec<SessionView>,
92 current_session_id: Option<String>,
93 current_session_title: String,
95 messages: Vec<MsgState>,
96 draft: String,
97 generating: bool,
99 /// Monotonic counter for optimistic (client-side) user-message ids.
100 local_seq: u64,
103impl Model {
104 fn message_mut(&mut self, id: &str) -> Option<&mut MsgState> {
105 self.messages.iter_mut().find(|m| m.id == id)
106 }
108 fn has_credentials(&self) -> bool {
109 !self.username.is_empty() || !self.password.is_empty()
110 }
112 fn is_current(&self, session_id: &str) -> bool {
113 self.current_session_id.as_deref() == Some(session_id)
114 }
117/// Internal per-message state. Parts are tracked by id so streaming updates can
118/// upsert them; the [`MessageView`] handed to the shell is derived from these.
119struct MsgState {
120 id: String,
121 role: String,
122 time: u64,
123 status: MessageStatus,
124 streaming: bool,
125 text_parts: Vec<(String, String)>,
126 reasoning_parts: Vec<(String, String)>,
127 tool_parts: Vec<(String, ToolView)>,
130impl MsgState {
131 fn new(id: String, role: String, time: u64) -> Self {
132 Self {
133 id,
134 role,
135 time,
136 status: MessageStatus::Sent,
137 streaming: false,
138 text_parts: Vec::new(),
139 reasoning_parts: Vec::new(),
140 tool_parts: Vec::new(),
141 }
142 }
144 fn upsert_part(parts: &mut Vec<(String, String)>, id: &str, text: String) {
145 if let Some(slot) = parts.iter_mut().find(|(pid, _)| pid == id) {
146 slot.1 = text;
147 } else {
148 parts.push((id.to_string(), text));
149 }
150 }
152 fn upsert_tool(&mut self, id: &str, view: ToolView) {
153 if let Some(slot) = self.tool_parts.iter_mut().find(|(pid, _)| pid == id) {
154 slot.1 = view;
155 } else {
156 self.tool_parts.push((id.to_string(), view));
157 }
158 }
160 fn remove_part(&mut self, part_id: &str) {
161 self.text_parts.retain(|(pid, _)| pid != part_id);
162 self.reasoning_parts.retain(|(pid, _)| pid != part_id);
163 self.tool_parts.retain(|(pid, _)| pid != part_id);
164 }
166 fn to_view(&self) -> MessageView {
167 let join = |parts: &[(String, String)]| {
168 parts.iter().map(|(_, t)| t.as_str()).collect::<String>()
169 };
170 let reasoning = join(&self.reasoning_parts);
171 MessageView {
172 id: self.id.clone(),
173 role: self.role.clone(),
174 text: join(&self.text_parts),
175 reasoning: (!reasoning.is_empty()).then_some(reasoning),
176 tools: self.tool_parts.iter().map(|(_, t)| t.clone()).collect(),
177 time: self.time,
178 status: self.status.clone(),
179 streaming: self.streaming,
180 }
181 }
184// ─── View model ──────────────────────────────────────────────────────────────
186#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
187pub enum Screen {
188 #[default]
189 Connect,
190 Sessions,
191 Chat,
194#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195pub struct SessionView {
196 pub id: String,
197 pub title: String,
200#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
201pub enum MessageStatus {
202 #[default]
203 Sent,
204 Pending,
205 Failed,
208#[derive(Serialize, Deserialize, Clone, Debug, Default)]
209pub struct ToolView {
210 pub name: String,
211 pub status: String,
212 pub title: Option<String>,
215#[derive(Serialize, Deserialize, Clone, Debug, Default)]
216pub struct MessageView {
217 pub id: String,
218 pub role: String,
219 pub text: String,
220 pub reasoning: Option<String>,
221 pub tools: Vec<ToolView>,
222 pub time: u64,
223 pub status: MessageStatus,
224 pub streaming: bool,
227#[derive(Serialize, Deserialize, Clone, Debug, Default)]
228pub struct ViewModel {
229 pub screen: Screen,
230 pub server_url: String,
231 pub username: String,
232 pub password: String,
233 pub auth_required: bool,
234 pub connected: bool,
235 pub loading: bool,
236 pub error: Option<String>,
237 pub sessions: Vec<SessionView>,
238 pub current_session_id: Option<String>,
239 pub current_session_title: String,
240 pub messages: Vec<MessageView>,
241 pub draft: String,
242 pub generating: bool,
245// ─── App ─────────────────────────────────────────────────────────────────────
247#[derive(Default)]
248pub struct OpencodeApp;
250impl OpencodeApp {
251 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252 let url = format!("{}{path}", model.server_url);
253 self.with_auth(Http::get(&url).expect_string(), model)
254 }
256 fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
257 let url = format!("{}{path}", model.server_url);
258 let bytes = serde_json::to_vec(body).unwrap_or_default();
259 let req = Http::post(&url)
260 .expect_string()
261 .header("Content-Type", "application/json")
262 .body_bytes(bytes);
263 self.with_auth(req, model)
264 }
266 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267 let url = format!("{}{path}", model.server_url);
268 self.with_auth(Http::delete(&url).expect_string(), model)
269 }
271 /// Attach an RFC 7617 Basic-Auth header when the model carries credentials.
272 fn with_auth<E>(&self, req: RequestBuilder<Effect, Event, E>, model: &Model) -> RequestBuilder<Effect, Event, E>
273 where
274 E: 'static,
275 {
276 if model.has_credentials() {
277 let creds = format!("{}:{}", model.username, model.password);
278 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279 req.header("Authorization", format!("Basic {encoded}"))
280 } else {
281 req
282 }
283 }
286impl App for OpencodeApp {
287 type Event = Event;
288 type Model = Model;
289 type ViewModel = ViewModel;
290 type Effect = Effect;
292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
293 match event {
294 // ── Lifecycle / connect ─────────────────────────────────────────
295 Event::Start => render(),
297 // Store the field verbatim while typing; normalizing here would fight
298 // the user's edits (e.g. eat the "://" as they type it). The URL is
299 // normalized once, on Connect.
300 // Clear any prior error as soon as the user edits a field, so a stale
301 // message (e.g. "Invalid credentials") doesn't linger while they correct it.
302 Event::ServerUrlChanged(url) => {
303 model.server_url = url;
304 if model.error.is_some() {
305 model.error = None;
306 }
307 render()
308 }
309 Event::UsernameChanged(u) => {
310 model.username = u;
311 if model.error.is_some() {
312 model.error = None;
313 }
314 render()
315 }
316 Event::PasswordChanged(p) => {
317 model.password = p;
318 if model.error.is_some() {
319 model.error = None;
320 }
321 render()
322 }
324 Event::Connect => {
325 model.server_url = normalize_url(&model.server_url);
326 if model.server_url.is_empty() {
327 model.error = Some("Enter a server URL".to_string());
328 return render();
329 }
330 model.loading = true;
331 model.error = None;
332 model.auth_required = false;
333 render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
334 }
336 Event::HealthProbed(result) => self.on_health_probed(model, result),
338 // ── Sessions ────────────────────────────────────────────────────
339 Event::LoadSessions => {
340 model.loading = true;
341 render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
342 }
344 Event::SessionsLoaded(result) => {
345 model.loading = false;
346 match ok_body(result) {
347 Ok(body) => {
348 model.sessions = protocol::parse_sessions(&body)
349 .into_iter()
350 .map(|s| SessionView {
351 id: s.id,
352 title: display_title(&s.title),
353 })
354 .collect();
355 // Impose a stable alphabetical order so the list doesn't shuffle
356 // with whatever order the server happens to return sessions in.
357 model.sessions.sort_by_key(|s| s.title.to_lowercase());
358 }
359 Err(e) => model.error = Some(e),
360 }
361 render()
362 }
364 Event::SelectSession(id) => {
365 model.current_session_title = model
366 .sessions
367 .iter()
368 .find(|s| s.id == id)
369 .map(|s| s.title.clone())
370 .unwrap_or_default();
371 model.current_session_id = Some(id.clone());
372 model.messages.clear();
373 model.screen = Screen::Chat;
374 model.loading = true;
375 model.error = None;
376 render().and(Command::event(Event::LoadMessages(id)))
377 }
379 Event::CreateSession => {
380 model.loading = true;
381 render().and(
382 self.post_json(model, "/session", &serde_json::json!({}))
383 .build()
384 .then_send(Event::SessionCreated),
385 )
386 }
388 Event::SessionCreated(result) => {
389 model.loading = false;
390 match ok_body(result) {
391 Ok(body) => {
392 if let Some(s) = protocol::parse_session(&body) {
393 let view = SessionView {
394 id: s.id.clone(),
395 title: display_title(&s.title),
396 };
397 if !model.sessions.iter().any(|e| e.id == view.id) {
398 model.sessions.insert(0, view);
399 }
400 return render().and(Command::event(Event::SelectSession(s.id)));
401 }
402 model.error = Some("Malformed session response".to_string());
403 }
404 Err(e) => model.error = Some(e),
405 }
406 render()
407 }
409 Event::DeleteSession(id) => {
410 model.sessions.retain(|s| s.id != id);
411 let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
412 if model.is_current(&id) {
413 model.current_session_id = None;
414 model.messages.clear();
415 model.screen = Screen::Sessions;
416 }
417 cmd = render().and(cmd);
418 cmd
419 }
421 Event::SessionDeleted(result) => {
422 if let Err(e) = ok_body(result) {
423 model.error = Some(e);
424 }
425 render()
426 }
428 // ── Chat ────────────────────────────────────────────────────────
429 Event::LoadMessages(id) => {
430 render().and(
431 self.get(model, &format!("/session/{id}/message"))
432 .build()
433 .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
434 )
435 }
437 Event::MessagesLoaded(session_id, result) => {
438 if !model.is_current(&session_id) {
439 return render();
440 }
441 model.loading = false;
442 match ok_body(result) {
443 Ok(body) => {
444 model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
445 model.generating = model.messages.iter().any(|m| m.streaming);
446 }
447 Err(e) => model.error = Some(e),
448 }
449 render()
450 }
452 Event::DraftChanged(text) => {
453 model.draft = text;
454 render()
455 }
457 Event::SendMessage(text) => {
458 let text = text.trim().to_string();
459 let Some(session_id) = model.current_session_id.clone() else {
460 return render();
461 };
462 if text.is_empty() {
463 return render();
464 }
465 model.local_seq += 1;
466 let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
467 user.status = MessageStatus::Pending;
468 MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
469 model.messages.push(user);
470 model.draft.clear();
471 model.generating = true;
472 model.error = None;
474 let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
475 render().and(
476 self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
477 .build()
478 .then_send(Event::PromptSent),
479 )
480 }
482 Event::PromptSent(result) => {
483 match ok_body(result) {
484 Ok(_) => set_last_pending(model, MessageStatus::Sent),
485 Err(e) => {
486 set_last_pending(model, MessageStatus::Failed);
487 model.generating = false;
488 model.error = Some(e);
489 }
490 }
491 render()
492 }
494 Event::CancelGeneration => {
495 model.generating = false;
496 match model.current_session_id.clone() {
497 Some(id) => render().and(
498 self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
499 .build()
500 .then_send(Event::Aborted),
501 ),
502 None => render(),
503 }
504 }
506 Event::Aborted(_) => render(),
508 // ── SSE ─────────────────────────────────────────────────────────
509 Event::ServerEvent(json) => {
510 if let Some(event) = protocol::parse_event(&json) {
511 apply_server_event(model, event);
512 }
513 render()
514 }
516 // ── Navigation / misc ───────────────────────────────────────────
517 Event::NavigateToSessions => {
518 model.screen = Screen::Sessions;
519 model.current_session_id = None;
520 model.messages.clear();
521 model.generating = false;
522 render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
523 }
525 Event::NavigateToConnect => {
526 model.screen = Screen::Connect;
527 model.connected = false;
528 model.sessions.clear();
529 model.messages.clear();
530 model.current_session_id = None;
531 render()
532 }
534 Event::DismissError => {
535 model.error = None;
536 render()
537 }
538 }
539 }
541 fn view(&self, model: &Model) -> ViewModel {
542 ViewModel {
543 screen: model.screen.clone(),
544 server_url: model.server_url.clone(),
545 username: model.username.clone(),
546 password: model.password.clone(),
547 auth_required: model.auth_required,
548 connected: model.connected,
549 loading: model.loading,
550 error: model.error.clone(),
551 sessions: model.sessions.clone(),
552 current_session_id: model.current_session_id.clone(),
553 current_session_title: model.current_session_title.clone(),
554 messages: model.messages.iter().map(MsgState::to_view).collect(),
555 draft: model.draft.clone(),
556 generating: model.generating,
557 }
558 }
561impl OpencodeApp {
562 fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
563 let has_creds = model.has_credentials();
564 match result {
565 Ok(response) => {
566 let status = response.status().as_u16();
567 if status == 401 {
568 model.loading = false;
569 if has_creds {
570 model.error = Some("Invalid credentials".to_string());
571 } else {
572 model.auth_required = true;
573 }
574 return render();
575 }
576 if (200..300).contains(&status) {
577 if has_creds {
578 model.authed = true;
579 }
580 model.connected = true;
581 model.auth_required = false;
582 model.error = None;
583 model.screen = Screen::Sessions;
584 model.loading = true;
585 return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
586 }
587 model.loading = false;
588 model.error = Some(format!("Server returned status {status}"));
589 render()
590 }
591 Err(e) => {
592 model.loading = false;
593 model.error = Some(format!("Connection failed: {e}"));
594 render()
595 }
596 }
597 }
600// ─── SSE application ─────────────────────────────────────────────────────────
602fn apply_server_event(model: &mut Model, event: ServerEvent) {
603 match event {
604 // Only assistant messages are created from the stream — the user's own
605 // message is already shown optimistically, so ignoring the server's copy
606 // avoids a duplicate bubble.
607 ServerEvent::MessageUpdated { properties } => {
608 let info = properties.info;
609 if info.role != "assistant" || !model.is_current(&info.session_id) {
610 return;
611 }
612 let streaming = info.time.completed.is_none() && info.error.is_none();
613 if let Some(msg) = model.message_mut(&info.id) {
614 msg.streaming = streaming;
615 if msg.time == 0 {
616 msg.time = info.time.created;
617 }
618 } else {
619 let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
620 msg.streaming = streaming;
621 model.messages.push(msg);
622 }
623 if let Some(err) = info.error {
624 model.error = Some(err.message());
625 model.generating = false;
626 }
627 }
629 // Apply only to a message we already track (an assistant message from the
630 // event above). Parts for unknown ids are ignored — see module docs.
631 ServerEvent::MessagePartUpdated { properties } => {
632 let part = properties.part;
633 let delta = properties.delta;
634 let msg_id = part.message_id().to_string();
635 let Some(msg) = model.message_mut(&msg_id) else {
636 return;
637 };
638 match part {
639 WirePart::Text { id, text, synthetic, .. } => {
640 if synthetic {
641 return;
642 }
643 let value = pick_text(text, &delta, &msg.text_parts, &id);
644 MsgState::upsert_part(&mut msg.text_parts, &id, value);
645 }
646 WirePart::Reasoning { id, text, .. } => {
647 let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
648 MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
649 }
650 WirePart::Tool { id, tool, state, .. } => {
651 let sv = protocol::tool_state_view(&state);
652 msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
653 }
654 WirePart::Other => {}
655 }
656 }
658 ServerEvent::MessagePartRemoved { properties } => {
659 if let Some(msg) = model.message_mut(&properties.message_id) {
660 msg.remove_part(&properties.part_id);
661 }
662 }
664 ServerEvent::MessageRemoved { properties } => {
665 model.messages.retain(|m| m.id != properties.message_id);
666 }
668 ServerEvent::SessionIdle { properties } => {
669 if model.is_current(&properties.session_id) {
670 model.generating = false;
671 for m in &mut model.messages {
672 m.streaming = false;
673 }
674 }
675 }
677 ServerEvent::SessionError { properties } => {
678 let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
679 if scoped {
680 if let Some(err) = properties.error {
681 model.error = Some(err.message());
682 }
683 model.generating = false;
684 }
685 }
687 ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
688 let s = properties.info;
689 let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
690 if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
691 existing.title = view.title.clone();
692 } else {
693 model.sessions.insert(0, view);
694 }
695 if model.is_current(&s.id) {
696 model.current_session_title = display_title(&s.title);
697 }
698 }
700 ServerEvent::SessionDeleted { properties } => {
701 let id = properties.session_id.or(properties.info.map(|i| i.id));
702 if let Some(id) = id {
703 model.sessions.retain(|s| s.id != id);
704 if model.is_current(&id) {
705 model.current_session_id = None;
706 model.messages.clear();
707 model.screen = Screen::Sessions;
708 }
709 }
710 }
712 ServerEvent::Other => {}
713 }
716// ─── Helpers ─────────────────────────────────────────────────────────────────
718/// `part.text` is the full current text when present; otherwise fall back to
719/// accumulating the incremental `delta` onto whatever we already have.
720fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
721 if !text.is_empty() {
722 return text;
723 }
724 let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
725 match delta {
726 Some(d) => format!("{prior}{d}"),
727 None => prior,
728 }
731fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
732 let info = &env.info;
733 let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
734 msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
735 for part in &env.parts {
736 match part {
737 WirePart::Text { id, text, synthetic, .. } => {
738 if !synthetic {
739 MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
740 }
741 }
742 WirePart::Reasoning { id, text, .. } => {
743 MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
744 }
745 WirePart::Tool { id, tool, state, .. } => {
746 let sv = protocol::tool_state_view(state);
747 msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
748 }
749 WirePart::Other => {}
750 }
751 }
752 msg
755fn set_last_pending(model: &mut Model, status: MessageStatus) {
756 if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
757 msg.status = status;
758 }
761/// Trim whitespace and any trailing `/` so paths concatenate cleanly, and
762/// default to an `http://` scheme when the user didn't type one (e.g.
763/// "192.168.1.10:4096") — otherwise the shell's URL parser rejects it.
764fn normalize_url(url: &str) -> String {
765 let trimmed = url.trim().trim_end_matches('/');
766 if trimmed.is_empty() {
767 String::new()
768 } else if trimmed.contains("://") {
769 trimmed.to_string()
770 } else {
771 format!("http://{trimmed}")
772 }
775fn display_title(title: &str) -> String {
776 let t = title.trim();
777 if t.is_empty() {
778 "Untitled".to_string()
779 } else {
780 t.to_string()
781 }
784/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
785fn ok_body(result: HttpResult) -> Result<String, String> {
786 match result {
787 Ok(mut response) => {
788 let status = response.status().as_u16();
789 if (200..300).contains(&status) {
790 Ok(response.take_body().unwrap_or_default())
791 } else {
792 Err(format!("Server returned status {status}"))
793 }
794 }
795 Err(e) => Err(format!("Request failed: {e}")),
796 }
799// ─── Tests ───────────────────────────────────────────────────────────────────
801#[cfg(test)]
802mod tests {
803 use super::*;
805 fn app() -> OpencodeApp {
806 OpencodeApp::default()
807 }
809 /// Put the model into a connected Chat view for the given session.
810 fn in_chat(session_id: &str) -> Model {
811 let mut model = Model::default();
812 model.server_url = "http://localhost:4096".to_string();
813 model.connected = true;
814 model.screen = Screen::Chat;
815 model.current_session_id = Some(session_id.to_string());
816 model
817 }
819 fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
820 let _ = app.update(Event::ServerEvent(json.to_string()), model);
821 }
823 #[test]
824 fn server_url_is_kept_raw_while_typing() {
825 let app = app();
826 let mut model = Model::default();
827 // Mid-scheme input must survive verbatim — normalizing per keystroke
828 // would eat the "://" the user is typing and make the field unusable.
829 let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
830 assert_eq!(app.view(&model).server_url, "http:/");
831 }
833 #[test]
834 fn connect_normalizes_scheme_less_url() {
835 let app = app();
836 let mut model = Model::default();
837 // A scheme-less host (what a user often types) must gain http:// on
838 // connect, or the shell's URL parser throws and the attempt crashes.
839 let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
840 assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
841 let _ = app.update(Event::Connect, &mut model);
842 assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
843 }
845 #[test]
846 fn connect_trims_and_keeps_explicit_scheme() {
847 let app = app();
848 let mut model = Model::default();
849 let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
850 let _ = app.update(Event::Connect, &mut model);
851 assert_eq!(app.view(&model).server_url, "https://host:4096");
852 }
854 #[test]
855 fn connect_requires_a_url() {
856 let app = app();
857 let mut model = Model::default();
858 let _ = app.update(Event::Connect, &mut model);
859 assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
860 }
862 #[test]
863 fn sending_adds_optimistic_user_message_and_sets_generating() {
864 let app = app();
865 let mut model = in_chat("ses_1");
866 let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
867 let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
869 let view = app.view(&model);
870 assert!(view.generating);
871 assert!(view.draft.is_empty());
872 assert_eq!(view.messages.len(), 1);
873 assert_eq!(view.messages[0].role, "user");
874 assert_eq!(view.messages[0].text, "hello there");
875 assert_eq!(view.messages[0].status, MessageStatus::Pending);
876 }
878 #[test]
879 fn empty_send_is_ignored() {
880 let app = app();
881 let mut model = in_chat("ses_1");
882 let _ = app.update(Event::SendMessage(" ".into()), &mut model);
883 assert!(app.view(&model).messages.is_empty());
884 assert!(!app.view(&model).generating);
885 }
887 #[test]
888 fn assistant_reply_streams_in_from_sse() {
889 let app = app();
890 let mut model = in_chat("ses_1");
892 // Assistant message shell arrives first.
893 feed(&app, &mut model, serde_json::json!({
894 "type": "message.updated",
895 "properties": { "info": {
896 "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
897 "time": { "created": 100 }
898 }}
899 }));
900 assert_eq!(app.view(&model).messages.len(), 1);
901 assert!(app.view(&model).messages[0].streaming);
903 // Two text deltas for the same part accumulate into full text.
904 feed(&app, &mut model, serde_json::json!({
905 "type": "message.part.updated",
906 "properties": { "part": {
907 "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
908 "type": "text", "text": "Hello"
909 }}
910 }));
911 feed(&app, &mut model, serde_json::json!({
912 "type": "message.part.updated",
913 "properties": { "part": {
914 "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
915 "type": "text", "text": "Hello, world"
916 }}
917 }));
919 let view = app.view(&model);
920 assert_eq!(view.messages[0].role, "assistant");
921 assert_eq!(view.messages[0].text, "Hello, world");
922 }
924 #[test]
925 fn text_delta_accumulates_when_full_text_absent() {
926 let app = app();
927 let mut model = in_chat("ses_1");
928 feed(&app, &mut model, serde_json::json!({
929 "type": "message.updated",
930 "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
931 }));
932 for delta in ["Wor", "ld"] {
933 feed(&app, &mut model, serde_json::json!({
934 "type": "message.part.updated",
935 "properties": {
936 "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
937 "delta": delta
938 }
939 }));
940 }
941 assert_eq!(app.view(&model).messages[0].text, "World");
942 }
944 #[test]
945 fn user_message_from_sse_is_not_duplicated() {
946 let app = app();
947 let mut model = in_chat("ses_1");
948 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
949 // Server echoes the user message on the stream — must be ignored.
950 feed(&app, &mut model, serde_json::json!({
951 "type": "message.updated",
952 "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
953 }));
954 let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
955 assert_eq!(users, 1);
956 }
958 #[test]
959 fn session_idle_clears_generating() {
960 let app = app();
961 let mut model = in_chat("ses_1");
962 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
963 assert!(app.view(&model).generating);
964 feed(&app, &mut model, serde_json::json!({
965 "type": "session.idle", "properties": { "sessionID": "ses_1" }
966 }));
967 assert!(!app.view(&model).generating);
968 }
970 #[test]
971 fn idle_for_other_session_is_ignored() {
972 let app = app();
973 let mut model = in_chat("ses_1");
974 let _ = app.update(Event::SendMessage("hi".into()), &mut model);
975 feed(&app, &mut model, serde_json::json!({
976 "type": "session.idle", "properties": { "sessionID": "ses_other" }
977 }));
978 assert!(app.view(&model).generating);
979 }
981 #[test]
982 fn reasoning_and_tool_parts_are_surfaced() {
983 let app = app();
984 let mut model = in_chat("ses_1");
985 feed(&app, &mut model, serde_json::json!({
986 "type": "message.updated",
987 "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
988 }));
989 feed(&app, &mut model, serde_json::json!({
990 "type": "message.part.updated",
991 "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
992 }));
993 feed(&app, &mut model, serde_json::json!({
994 "type": "message.part.updated",
995 "properties": { "part": {
996 "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
997 "state": { "status": "completed", "title": "ls -la" }
998 }}
999 }));
1000 let msg = &app.view(&model).messages[0];
1001 assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
1002 assert_eq!(msg.tools.len(), 1);
1003 assert_eq!(msg.tools[0].name, "bash");
1004 assert_eq!(msg.tools[0].status, "completed");
1005 assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
1008 #[test]
1009 fn session_created_event_updates_list() {
1010 let app = app();
1011 let mut model = in_chat("ses_1");
1012 feed(&app, &mut model, serde_json::json!({
1013 "type": "session.created",
1014 "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
1015 }));
1016 assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
1019 #[test]
1020 fn unknown_event_types_are_ignored() {
1021 let app = app();
1022 let mut model = in_chat("ses_1");
1023 feed(&app, &mut model, serde_json::json!({
1024 "type": "pty.created", "properties": { "whatever": true }
1025 }));
1026 feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
1027 // No panic, no state change.
1028 assert!(app.view(&model).messages.is_empty());
1031 #[test]
1032 fn messages_loaded_parses_info_and_parts() {
1033 let app = app();
1034 let mut model = in_chat("ses_1");
1035 let body = serde_json::json!([
1036 { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
1037 "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
1038 { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
1039 "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
1040 ]).to_string();
1041 let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
1042 let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
1044 let view = app.view(&model);
1045 assert_eq!(view.messages.len(), 2);
1046 assert_eq!(view.messages[0].text, "Question?");
1047 assert_eq!(view.messages[1].text, "Answer.");
1048 assert!(!view.messages[1].streaming);
1049 assert!(!view.generating);