notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 8df9d2571280054a991c1f025d839dfcc4913f3c
parent b49690db04e161518ba456ca5521d0d7b54315ed
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 15:17:49 -0800

dave: handle codex error events to prevent redispatch loop

codex/event/error and error notifications were unhandled, so when
Codex returned an error without tokens the turn completed
"successfully" and the pending user message triggered an infinite
redispatch loop. Now these events send DaveApiResponse::Failed,
which pushes Message::Error into chat and breaks the loop.

Adds 4 tests: 3 for codex error event handling and 1 for the
session-level error-prevents-redispatch invariant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/backend/codex.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 25+++++++++++++++++++++++++
2 files changed, 95 insertions(+), 0 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/codex.rs b/crates/notedeck_dave/src/backend/codex.rs @@ -515,6 +515,22 @@ fn handle_codex_message( return HandleResult::TurnDone; } + "codex/event/error" | "error" => { + let err_msg = msg + .params + .as_ref() + .and_then(|p| p.get("message").and_then(|m| m.as_str())) + .or_else(|| { + msg.params + .as_ref() + .and_then(|p| p.get("error").and_then(|e| e.as_str())) + }) + .unwrap_or("Codex error"); + tracing::warn!("Codex error: {}", err_msg); + let _ = response_tx.send(DaveApiResponse::Failed(err_msg.to_string())); + ctx.request_repaint(); + } + other => { tracing::debug!("Unhandled codex notification: {}", other); } @@ -1298,6 +1314,60 @@ mod tests { } #[test] + fn test_handle_codex_event_error_sends_failed() { + let (tx, rx) = mpsc::channel(); + let ctx = egui::Context::default(); + let mut subagents = Vec::new(); + + let msg = notification( + "codex/event/error", + json!({ "message": "context window exceeded" }), + ); + let result = handle_codex_message(msg, &tx, &ctx, &mut subagents); + assert!(matches!(result, HandleResult::Continue)); + + let response = rx.try_recv().unwrap(); + match response { + DaveApiResponse::Failed(err) => assert_eq!(err, "context window exceeded"), + other => panic!("Expected Failed, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn test_handle_error_notification_sends_failed() { + let (tx, rx) = mpsc::channel(); + let ctx = egui::Context::default(); + let mut subagents = Vec::new(); + + let msg = notification("error", json!({ "message": "something broke" })); + let result = handle_codex_message(msg, &tx, &ctx, &mut subagents); + assert!(matches!(result, HandleResult::Continue)); + + let response = rx.try_recv().unwrap(); + match response { + DaveApiResponse::Failed(err) => assert_eq!(err, "something broke"), + other => panic!("Expected Failed, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn test_handle_error_without_message_uses_default() { + let (tx, rx) = mpsc::channel(); + let ctx = egui::Context::default(); + let mut subagents = Vec::new(); + + let msg = notification("codex/event/error", json!({})); + let result = handle_codex_message(msg, &tx, &ctx, &mut subagents); + assert!(matches!(result, HandleResult::Continue)); + + let response = rx.try_recv().unwrap(); + match response { + DaveApiResponse::Failed(err) => assert_eq!(err, "Codex error"), + other => panic!("Expected Failed, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] fn test_handle_subagent_started() { let (tx, rx) = mpsc::channel(); let ctx = egui::Context::default(); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -1480,6 +1480,31 @@ mod tests { ); } + /// When a stream ends with an error (no tokens produced), the + /// Error message should prevent infinite redispatch. + #[test] + fn error_prevents_redispatch_loop() { + let mut session = test_session(); + + session.chat.push(Message::User("hello".into())); + session.dispatched_user_count = 1; + let tx = make_streaming(&mut session); + + // Error arrives (no tokens were sent) + session + .chat + .push(Message::Error("context window exceeded".into())); + + // Stream ends + drop(tx); + session.incoming_tokens = None; + + assert!( + !session.needs_redispatch_after_stream_end(), + "error should prevent redispatch" + ); + } + /// Verify chat ordering when queued messages arrive before any /// tokens, and after tokens, across a full batch lifecycle. #[test]