返回 CodeWhale
feedback_host.rs
根目录 / crates / tui / src / tui / ui / feedback_host.rs
1 //! Async local report review on the existing dispatch completion mailbox.
2 use super::*;
3 use crate::hooks::HookCaller;
4 use crate::tools::github::report;
5 use std::sync::Arc;
6
7 #[derive(Clone)]
8 struct Scope {
9 caller: HookCaller,
10 host: bool,
11 }
12 impl Scope {
13 fn capture(app: &App, config: &Config) -> Result<Self> {
14 Ok(Self {
15 caller: app
16 .base_hook_context()
17 .caller
18 .context("Feedback caller is unavailable")?,
19 host: config
20 .features()
21 .enabled(crate::features::Feature::GithubHost),
22 })
23 }
24 fn current(&self, app: &App, config: &Config) -> bool {
25 self.caller.workspace == app.workspace
26 && self.caller.session_id == app.current_session_id
27 && self.caller.agent_id == app.agent_focus.as_ref().map(|focus| focus.agent_id.clone())
28 && self.caller.origin_turn_id == app.runtime_turn_id
29 && self.host
30 == config
31 .features()
32 .enabled(crate::features::Feature::GithubHost)
33 && self
34 .caller
35 .plugins
36 .as_ref()
37 .is_some_and(|plugins| Arc::ptr_eq(plugins, &app.extension_plugin_view()))
38 }
39 }
40 pub(crate) struct EditReady {
41 scope: Scope,
42 instruction: String,
43 }
44
45 pub(super) fn start(
46 app: &mut App,
47 config: &Config,
48 id: String,
49 change: Option<String>,
50 ) -> Result<()> {
51 if app.feedback_dispatch.is_some() {
52 anyhow::bail!("A feedback edit is already waiting for dispatch");
53 }
54 let scope = Scope::capture(app, config)?;
55 let session = scope
56 .caller
57 .session_id
58 .clone()
59 .filter(|session| !session.is_empty())
60 .context("Feedback review requires a current session")?;
61 let permit = app
62 .dispatch_completion_tx
63 .clone()
64 .context("Feedback completion mailbox is unavailable")?
65 .try_reserve_owned()
66 .context("Feedback completion mailbox is full or closed")?;
67 let manager = crate::extension_host::manager();
68 let handle = tokio::runtime::Handle::try_current()
69 .context("Feedback Engine scheduler is unavailable")?;
70 manager.bind_engine_handle(handle.clone());
71 let notice = app.tr(MessageId::FeedbackReviewNotice).into_owned();
72 let unavailable = app.tr(MessageId::FeedbackUnavailable).into_owned();
73 let draft_notice = app.tr(MessageId::FeedbackDraftRequested).into_owned();
74 #[cfg(test)]
75 let env_scope = crate::test_support::env_scope_ticket();
76 handle.spawn(async move {
77 #[cfg(test)]
78 let _env_scope = crate::test_support::join_env_scope(env_scope);
79 let result = if scope.host {
80 manager
81 .execute_github_review(scope.caller.clone(), id.clone())
82 .await
83 .map(|result| result.content)
84 .map_err(|_| ())
85 } else {
86 #[cfg(test)]
87 let env_scope = crate::test_support::env_scope_ticket();
88 let session = session.clone();
89 let id = id.clone();
90 tokio::task::spawn_blocking(move || {
91 #[cfg(test)]
92 let _env_scope = crate::test_support::join_env_scope(env_scope);
93 report::load(&session, &id)
94 .map(|report| report.render_review())
95 .map_err(|_| ())
96 })
97 .await
98 .unwrap_or(Err(()))
99 };
100 let apply: crate::tui::app::DispatchApplyFn = Box::new(move |app, _, config| {
101 if !scope.current(app, config) {
102 return Ok(());
103 }
104 match result {
105 Err(()) => app.add_message(HistoryCell::System {
106 content: unavailable,
107 }),
108 Ok(review) => {
109 if let Some(change) = change {
110 if app.feedback_dispatch.is_some() {
111 app.add_message(HistoryCell::System {
112 content: unavailable,
113 });
114 } else {
115 app.add_message(HistoryCell::System {
116 content: draft_notice,
117 });
118 app.feedback_dispatch = Some(EditReady {
119 scope,
120 instruction: report::draft_instruction(
121 &change,
122 Some((&id, &review)),
123 ),
124 });
125 }
126 } else {
127 app.add_message(HistoryCell::System {
128 content: format!("{notice}\n\n{review}"),
129 });
130 }
131 }
132 }
133 app.needs_redraw = true;
134 Ok(())
135 });
136 permit.send(apply);
137 });
138 Ok(())
139 }
140
141 pub(super) async fn dispatch_ready(
142 app: &mut App,
143 config: &Config,
144 engine: &EngineHandle,
145 ) -> Result<()> {
146 let Some(ready) = app.feedback_dispatch.take() else {
147 return Ok(());
148 };
149 if !ready.scope.current(app, config) {
150 return Ok(());
151 }
152 let message = build_queued_message(app, ready.instruction);
153 let action = ComposerSubmitAction::Submit(app.decide_submit_disposition());
154 dispatch_composer_message(
155 app,
156 config,
157 engine,
158 message,
159 DispatchRecovery::Immediate,
160 action,
161 )
162 .await
163 }
164
165 #[cfg(test)]
166 mod tests {
167 use super::*;
168 #[test]
169 fn captured_feedback_result_rejects_session_workspace_and_plugin_view_changes() {
170 let tmp = tempfile::tempdir().unwrap();
171 let config = Config::default();
172 let mut app = App::new(crate::test_support::test_tui_options(tmp.path()), &config);
173 app.current_session_id = Some("session-a".into());
174 let scope = Scope::capture(&app, &config).unwrap();
175 assert!(scope.current(&app, &config));
176 app.current_session_id = Some("session-b".into());
177 assert!(!scope.current(&app, &config));
178 app.current_session_id = Some("session-a".into());
179 app.runtime_turn_id = Some("new-turn".into());
180 assert!(!scope.current(&app, &config));
181 app.runtime_turn_id = scope.caller.origin_turn_id.clone();
182 app.workspace = tmp.path().join("other");
183 assert!(!scope.current(&app, &config));
184 app.workspace = scope.caller.workspace.clone();
185 app.plugin_registry = Arc::new(crate::plugins::PluginRegistry::new());
186 assert!(!scope.current(&app, &config));
187 }
188 #[test]
189 fn feedback_edit_slot_refuses_replacement_and_feature_change_rejects_apply() {
190 let tmp = tempfile::tempdir().unwrap();
191 let mut config = Config::default();
192 let mut app = App::new(crate::test_support::test_tui_options(tmp.path()), &config);
193 app.current_session_id = Some("session".into());
194 let scope = Scope::capture(&app, &config).unwrap();
195 app.feedback_dispatch = Some(EditReady {
196 scope: scope.clone(),
197 instruction: "first edit".into(),
198 });
199 assert!(
200 start(
201 &mut app,
202 &config,
203 "other".into(),
204 Some("second edit".into())
205 )
206 .unwrap_err()
207 .to_string()
208 .contains("already waiting")
209 );
210 assert_eq!(
211 app.feedback_dispatch.as_ref().unwrap().instruction,
212 "first edit"
213 );
214 config.set_feature("github_host", true).unwrap();
215 assert!(!scope.current(&app, &config));
216 let mut wrong_agent = scope;
217 wrong_agent.caller.agent_id = Some("other-agent".into());
218 assert!(!wrong_agent.current(&app, &Config::default()));
219 }
220
221 #[tokio::test(flavor = "current_thread")]
222 async fn full_or_closed_feedback_mailbox_refuses_before_any_read_or_host_job() {
223 let tmp = tempfile::tempdir().unwrap();
224 let config = Config::default();
225 let mut app = App::new(crate::test_support::test_tui_options(tmp.path()), &config);
226 app.current_session_id = Some("session".into());
227 let (tx, rx) = tokio::sync::mpsc::channel::<crate::tui::app::DispatchApplyFn>(1);
228 let _reserved = tx.clone().try_reserve_owned().unwrap();
229 app.dispatch_completion_tx = Some(tx);
230 assert!(
231 start(&mut app, &config, "invalid".into(), None)
232 .unwrap_err()
233 .to_string()
234 .contains("full or closed")
235 );
236 drop(rx);
237 assert!(start(&mut app, &config, "invalid".into(), None).is_err());
238 assert!(app.feedback_dispatch.is_none());
239 }
240 }
241
241 lines RUST