From be13f03c396b54b85b858bd023bf930b06164e33 Mon Sep 17 00:00:00 2001 From: ningyi-oai Date: Sat, 11 Apr 2026 00:23:50 -0700 Subject: [PATCH] Pass turn id with feedback uploads (#17314) ## Summary - Add an optional `tags` dictionary to feedback upload params. - Capture the active app-server turn id in the TUI and submit it as `tags.turn_id` with `/feedback` uploads. - Merge client-provided feedback tags into Sentry feedback tags while preserving reserved system fields like `thread_id`, `classification`, `cli_version`, `session_source`, and `reason`. ## Behavior / impact Existing feedback upload callers remain compatible because `tags` is optional and nullable. The wire shape is still a normal JSON object / TypeScript dictionary, so adding future feedback metadata will not require a new top-level protocol field each time. This change only adds feedback metadata for Codex CLI/TUI uploads; it does not affect existing pipelines, DAGs, exports, or downstream consumers unless they choose to read the new `turn_id` feedback tag. ## Tests - `cargo fmt -- --config imports_granularity=Item` passed; stable rustfmt warned that `imports_granularity` is nightly-only. - `cargo run -p codex-app-server-protocol --bin write_schema_fixtures` - `cargo test -p codex-feedback upload_tags_include_client_tags_and_preserve_reserved_fields` - `cargo test -p codex-app-server-protocol schema_fixtures_match_generated` - `cargo test -p codex-tui build_feedback_upload_params` - `cargo test -p codex-tui live_app_server_turn_started_sets_feedback_turn_id` - `cargo check -p codex-app-server --tests` - `git diff --check` --------- Co-authored-by: Codex --- .../schema/json/ClientRequest.json | 9 + .../codex_app_server_protocol.schemas.json | 9 + .../codex_app_server_protocol.v2.schemas.json | 9 + .../schema/json/v2/FeedbackUploadParams.json | 9 + .../typescript/v2/FeedbackUploadParams.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/codex_message_processor.rs | 17 +- codex-rs/feedback/src/lib.rs | 201 +++++++++++++----- codex-rs/tui/src/app.rs | 19 +- codex-rs/tui/src/app_event.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 27 ++- codex-rs/tui/src/chatwidget.rs | 12 +- .../tui/src/chatwidget/tests/app_server.rs | 37 ++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 14 files changed, 290 insertions(+), 65 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4a69b4d8c..76f7abdfd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -647,6 +647,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 95de3343a..2c575202e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7479,6 +7479,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 88df6dce4..56497c786 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4120,6 +4120,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 47b752b86..07c209860 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -22,6 +22,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index ebbe3b32d..86d9de2f0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e562981f0..4ce7e9c0b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2242,6 +2242,8 @@ pub struct FeedbackUploadParams { pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 530bc7093..53f4a876c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -243,6 +243,7 @@ use codex_features::FEATURES; use codex_features::Feature; use codex_features::Stage; use codex_feedback::CodexFeedback; +use codex_feedback::FeedbackUploadOptions; use codex_git_utils::git_diff_to_remote; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManager; @@ -7726,6 +7727,7 @@ impl CodexMessageProcessor { thread_id, include_logs, extra_log_files, + tags, } = params; let conversation_id = match thread_id.as_deref() { @@ -7853,14 +7855,15 @@ impl CodexMessageProcessor { let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { - snapshot.upload_feedback( - &classification, - reason.as_deref(), + snapshot.upload_feedback(FeedbackUploadOptions { + classification: &classification, + reason: reason.as_deref(), + tags: tags.as_ref(), include_logs, - &attachment_paths, - Some(session_source), - sqlite_feedback_logs, - ) + extra_attachment_paths: &attachment_paths, + session_source: Some(session_source), + logs_override: sqlite_feedback_logs, + }) }) .await; diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 712aeee21..ff6b79635 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -338,6 +338,16 @@ pub struct FeedbackSnapshot { pub thread_id: String, } +pub struct FeedbackUploadOptions<'a> { + pub classification: &'a str, + pub reason: Option<&'a str>, + pub tags: Option<&'a BTreeMap>, + pub include_logs: bool, + pub extra_attachment_paths: &'a [PathBuf], + pub session_source: Option, + pub logs_override: Option>, +} + impl FeedbackSnapshot { pub(crate) fn as_bytes(&self) -> &[u8] { &self.bytes @@ -369,16 +379,7 @@ impl FeedbackSnapshot { } /// Upload feedback to Sentry with optional attachments. - pub fn upload_feedback( - &self, - classification: &str, - reason: Option<&str>, - include_logs: bool, - extra_attachment_paths: &[PathBuf], - session_source: Option, - logs_override: Option>, - ) -> Result<()> { - use std::collections::BTreeMap; + pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> { use std::str::FromStr; use std::sync::Arc; @@ -398,13 +399,70 @@ impl FeedbackSnapshot { ..Default::default() }); + let tags = self.upload_tags( + options.classification, + options.reason, + options.tags, + options.session_source.as_ref(), + ); + + let level = match options.classification { + "bug" | "bad_result" | "safety_check" => Level::Error, + _ => Level::Info, + }; + + let mut envelope = Envelope::new(); + let title = format!( + "[{}]: Codex session {}", + display_classification(options.classification), + self.thread_id + ); + + let mut event = Event { + level, + message: Some(title.clone()), + tags, + ..Default::default() + }; + if let Some(r) = options.reason { + use sentry::protocol::Exception; + use sentry::protocol::Values; + + event.exception = Values::from(vec![Exception { + ty: title, + value: Some(r.to_string()), + ..Default::default() + }]); + } + envelope.add_item(EnvelopeItem::Event(event)); + + for attachment in self.feedback_attachments( + options.include_logs, + options.extra_attachment_paths, + options.logs_override, + ) { + envelope.add_item(EnvelopeItem::Attachment(attachment)); + } + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + Ok(()) + } + + fn upload_tags( + &self, + classification: &str, + reason: Option<&str>, + client_tags: Option<&BTreeMap>, + session_source: Option<&SessionSource>, + ) -> BTreeMap { let cli_version = env!("CARGO_PKG_VERSION"); let mut tags = BTreeMap::from([ (String::from("thread_id"), self.thread_id.to_string()), (String::from("classification"), classification.to_string()), (String::from("cli_version"), cli_version.to_string()), ]); - if let Some(source) = session_source.as_ref() { + if let Some(source) = session_source { tags.insert(String::from("session_source"), source.to_string()); } if let Some(r) = reason { @@ -418,6 +476,16 @@ impl FeedbackSnapshot { "session_source", "reason", ]; + if let Some(client_tags) = client_tags { + for (key, value) in client_tags { + if reserved.contains(&key.as_str()) { + continue; + } + if let Entry::Vacant(entry) = tags.entry(key.clone()) { + entry.insert(value.clone()); + } + } + } for (key, value) in &self.tags { if reserved.contains(&key.as_str()) { continue; @@ -427,45 +495,7 @@ impl FeedbackSnapshot { } } - let level = match classification { - "bug" | "bad_result" | "safety_check" => Level::Error, - _ => Level::Info, - }; - - let mut envelope = Envelope::new(); - let title = format!( - "[{}]: Codex session {}", - display_classification(classification), - self.thread_id - ); - - let mut event = Event { - level, - message: Some(title.clone()), - tags, - ..Default::default() - }; - if let Some(r) = reason { - use sentry::protocol::Exception; - use sentry::protocol::Values; - - event.exception = Values::from(vec![Exception { - ty: title, - value: Some(r.to_string()), - ..Default::default() - }]); - } - envelope.add_item(EnvelopeItem::Event(event)); - - for attachment in - self.feedback_attachments(include_logs, extra_attachment_paths, logs_override) - { - envelope.add_item(EnvelopeItem::Attachment(attachment)); - } - - client.send_envelope(envelope); - client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); - Ok(()) + tags } fn feedback_attachments( @@ -697,4 +727,75 @@ mod tests { assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]); fs::remove_file(extra_path).expect("extra attachment should be removed"); } + + #[test] + fn upload_tags_include_client_tags_and_preserve_reserved_fields() { + let mut tags = BTreeMap::new(); + tags.insert("thread_id".to_string(), "wrong-thread".to_string()); + tags.insert("turn_id".to_string(), "wrong-turn".to_string()); + tags.insert( + "classification".to_string(), + "wrong-classification".to_string(), + ); + tags.insert("cli_version".to_string(), "wrong-version".to_string()); + tags.insert("session_source".to_string(), "wrong-source".to_string()); + tags.insert("reason".to_string(), "wrong-reason".to_string()); + tags.insert("model".to_string(), "gpt-5".to_string()); + let snapshot = FeedbackSnapshot { + bytes: Vec::new(), + tags, + feedback_diagnostics: FeedbackDiagnostics::default(), + thread_id: "thread-123".to_string(), + }; + let mut client_tags = BTreeMap::new(); + client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string()); + client_tags.insert("turn_id".to_string(), "turn-456".to_string()); + client_tags.insert( + "classification".to_string(), + "wrong-client-classification".to_string(), + ); + client_tags.insert( + "cli_version".to_string(), + "wrong-client-version".to_string(), + ); + client_tags.insert( + "session_source".to_string(), + "wrong-client-source".to_string(), + ); + client_tags.insert("reason".to_string(), "wrong-client-reason".to_string()); + client_tags.insert("client_tag".to_string(), "from-client".to_string()); + + let upload_tags = snapshot.upload_tags( + "bug", + Some("actual reason"), + Some(&client_tags), + Some(&SessionSource::Cli), + ); + + assert_eq!( + upload_tags.get("thread_id").map(String::as_str), + Some("thread-123") + ); + assert_eq!( + upload_tags.get("turn_id").map(String::as_str), + Some("turn-456") + ); + assert_eq!( + upload_tags.get("classification").map(String::as_str), + Some("bug") + ); + assert_eq!( + upload_tags.get("session_source").map(String::as_str), + Some("cli") + ); + assert_eq!( + upload_tags.get("reason").map(String::as_str), + Some("actual reason") + ); + assert_eq!( + upload_tags.get("client_tag").map(String::as_str), + Some("from-client") + ); + assert_eq!(upload_tags.get("model").map(String::as_str), Some("gpt-5")); + } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 89280290b..dd0827822 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2020,6 +2020,7 @@ impl App { app_server: &AppServerSession, category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, ) { let request_handle = app_server.request_handle(); @@ -2035,6 +2036,7 @@ impl App { rollout_path, category, reason, + turn_id, include_logs, ); tokio::spawn(async move { @@ -4630,9 +4632,10 @@ impl App { AppEvent::SubmitFeedback { category, reason, + turn_id, include_logs, } => { - self.submit_feedback(app_server, category, reason, include_logs); + self.submit_feedback(app_server, category, reason, turn_id, include_logs); } AppEvent::FeedbackSubmitted { origin_thread_id, @@ -6249,6 +6252,7 @@ fn build_feedback_upload_params( rollout_path: Option, category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, ) -> FeedbackUploadParams { let extra_log_files = if include_logs { @@ -6256,12 +6260,14 @@ fn build_feedback_upload_params( } else { None }; + let tags = turn_id.map(|turn_id| BTreeMap::from([(String::from("turn_id"), turn_id)])); FeedbackUploadParams { classification: crate::bottom_pane::feedback_classification(category).to_string(), reason, thread_id: origin_thread_id.map(|thread_id| thread_id.to_string()), include_logs, extra_log_files, + tags, } } @@ -9644,12 +9650,21 @@ guardian_approval = true Some(rollout_path.clone()), FeedbackCategory::SafetyCheck, Some("needs follow-up".to_string()), + Some("turn-123".to_string()), /*include_logs*/ true, ); assert_eq!(params.classification, "safety_check"); assert_eq!(params.reason, Some("needs follow-up".to_string())); assert_eq!(params.thread_id, Some(thread_id.to_string())); + assert_eq!( + params + .tags + .as_ref() + .and_then(|tags| tags.get("turn_id")) + .map(String::as_str), + Some("turn-123") + ); assert_eq!(params.include_logs, true); assert_eq!(params.extra_log_files, Some(vec![rollout_path])); } @@ -9661,12 +9676,14 @@ guardian_approval = true Some(PathBuf::from("/tmp/rollout.jsonl")), FeedbackCategory::GoodResult, /*reason*/ None, + /*turn_id*/ None, /*include_logs*/ false, ); assert_eq!(params.classification, "good_result"); assert_eq!(params.reason, None); assert_eq!(params.thread_id, None); + assert_eq!(params.tags, None); assert_eq!(params.include_logs, false); assert_eq!(params.extra_log_files, None); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 78a252535..0e71e1a43 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -559,6 +559,7 @@ pub(crate) enum AppEvent { SubmitFeedback { category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, }, diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index b1889abca..f40a21cdf 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -45,6 +45,7 @@ pub(crate) enum FeedbackAudience { /// through the app-server-managed feedback flow. pub(crate) struct FeedbackNoteView { category: FeedbackCategory, + turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, @@ -57,11 +58,13 @@ pub(crate) struct FeedbackNoteView { impl FeedbackNoteView { pub(crate) fn new( category: FeedbackCategory, + turn_id: Option, app_event_tx: AppEventSender, include_logs: bool, ) -> Self { Self { category, + turn_id, app_event_tx, include_logs, textarea: TextArea::new(), @@ -76,6 +79,7 @@ impl FeedbackNoteView { self.app_event_tx.send(AppEvent::SubmitFeedback { category: self.category, reason, + turn_id: self.turn_id.clone(), include_logs: self.include_logs, }); self.complete = true; @@ -607,7 +611,9 @@ mod tests { fn make_view(category: FeedbackCategory) -> FeedbackNoteView { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - FeedbackNoteView::new(category, tx, /*include_logs*/ true) + FeedbackNoteView::new( + category, /*turn_id*/ None, tx, /*include_logs*/ true, + ) } #[test] @@ -649,7 +655,12 @@ mod tests { fn feedback_view_with_connectivity_diagnostics() { let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ false); + let view = FeedbackNoteView::new( + FeedbackCategory::Bug, + /*turn_id*/ None, + tx, + /*include_logs*/ false, + ); let rendered = render(&view, /*width*/ 60); insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); @@ -659,7 +670,12 @@ mod tests { fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ true); + let mut view = FeedbackNoteView::new( + FeedbackCategory::Bug, + Some("turn-123".to_string()), + tx, + /*include_logs*/ true, + ); view.textarea.insert_str(" something broke "); view.submit(); @@ -670,8 +686,9 @@ mod tests { AppEvent::SubmitFeedback { category: FeedbackCategory::Bug, reason: Some(reason), + turn_id: Some(turn_id), include_logs: true, - } if reason == "something broke" + } if reason == "something broke" && turn_id == "turn-123" )); assert_eq!(view.is_complete(), true); } @@ -682,6 +699,7 @@ mod tests { let tx = AppEventSender::new(tx_raw); let mut view = FeedbackNoteView::new( FeedbackCategory::GoodResult, + /*turn_id*/ None, tx, /*include_logs*/ false, ); @@ -694,6 +712,7 @@ mod tests { AppEvent::SubmitFeedback { category: FeedbackCategory::GoodResult, reason: None, + turn_id: None, include_logs: false, } )); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dfbe10fed..b8b5d9027 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -854,6 +854,7 @@ pub(crate) struct ChatWidget { pending_status_indicator_restore: bool, suppress_queue_autosend: bool, thread_id: Option, + last_turn_id: Option, thread_name: Option, forked_from: Option, frame_requester: FrameRequester, @@ -1961,6 +1962,7 @@ impl ChatWidget { self.set_skills(/*skills*/ None); self.session_network_proxy = event.network_proxy.clone(); self.thread_id = Some(event.session_id); + self.last_turn_id = None; self.thread_name = event.thread_name.clone(); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); @@ -2143,6 +2145,7 @@ impl ChatWidget { ) { let view = crate::bottom_pane::FeedbackNoteView::new( category, + self.last_turn_id.clone(), self.app_event_tx.clone(), include_logs, ); @@ -4827,6 +4830,7 @@ impl ChatWidget { pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + last_turn_id: None, thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), @@ -6501,7 +6505,8 @@ impl ChatWidget { } } } - ServerNotification::TurnStarted(_) => { + ServerNotification::TurnStarted(notification) => { + self.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { self.on_task_started(); @@ -7072,8 +7077,11 @@ impl ChatWidget { } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TurnStarted(event) => { + let turn_id = event.turn_id; + let model_context_window = event.model_context_window; + self.last_turn_id = Some(turn_id); if !is_resume_initial_replay { - self.apply_turn_started_context_window(event.model_context_window); + self.apply_turn_started_context_window(model_context_window); self.on_task_started(); } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 09bc9a701..6b18e2459 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -147,6 +147,43 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( assert!(chat.bottom_pane.status_widget().is_none()); } +#[tokio::test] +async fn live_app_server_turn_started_sets_feedback_turn_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + + chat.open_feedback_note( + crate::app_event::FeedbackCategory::Bug, + /*include_logs*/ false, + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitFeedback { + category: crate::app_event::FeedbackCategory::Bug, + reason: None, + turn_id: Some(turn_id), + include_logs: false, + }) if turn_id == "turn-1" + ); +} + #[tokio::test] async fn live_app_server_file_change_item_started_preserves_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d99c304fc..d5c6a5e3b 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -244,6 +244,7 @@ pub(super) async fn make_chatwidget_manual( pending_status_indicator_restore: false, suppress_queue_autosend: false, thread_id: None, + last_turn_id: None, thread_name: None, forked_from: None, frame_requester: FrameRequester::test_dummy(),