返回 CodeWhale
operations_tests.rs
根目录 / crates / tui / src / commands / groups / debug / operations_tests.rs
1 //! Portable regression evidence for the expanded debug group. Fake facets
2 //! record authority and ordering; all handlers and renderers are production code.
3
4 use super::{DebugAction, portable_handlers, receipts, undo};
5 use codewhale_command_contract::facets::*;
6 use codewhale_command_contract::handler::{
7 CommandCapabilities as Caps, CommandContexts, CommandHandler,
8 };
9 use std::cell::RefCell;
10 use std::rc::Rc;
11
12 #[test]
13 fn diff_formats_observations_without_host_state_or_actions() {
14 struct Diff(DebugDiffObservation);
15 impl CommandDebugDiffContext for Diff {
16 fn diff(&self) -> DebugDiffObservation {
17 self.0.clone()
18 }
19 }
20 for (observation, message, error) in [
21 (
22 DebugDiffObservation::GitUnavailable,
23 "Error: git not found on PATH",
24 true,
25 ),
26 (
27 DebugDiffObservation::Failed("fixture I/O".into()),
28 "Git diff failed — is this a git repository?\nfixture I/O",
29 false,
30 ),
31 (
32 DebugDiffObservation::Output {
33 names: " \n".into(),
34 stat: "ignored".into(),
35 },
36 "No changes since session start",
37 false,
38 ),
39 (
40 DebugDiffObservation::Output {
41 names: "a.rs\n\nb.rs\n".into(),
42 stat: "".into(),
43 },
44 "Changed files (2):\na.rs\nb.rs",
45 false,
46 ),
47 (
48 DebugDiffObservation::Output {
49 names: "a -> b\nc.rs\n".into(),
50 stat: " 2 files changed\n".into(),
51 },
52 "Changed files (2, 1 renamed):\na -> b\nc.rs\n\n── Stat ──\n2 files changed",
53 false,
54 ),
55 ] {
56 let result = undo::diff(
57 CommandContexts::empty().with_debug_diff(&mut Diff(observation)),
58 None,
59 );
60 assert_eq!(result.message.as_deref(), Some(message));
61 assert_eq!(result.is_error, error);
62 assert!(result.action.is_none());
63 }
64 }
65
66 #[test]
67 fn change_checks_presentation_before_reading_the_host_projection() {
68 struct NoRead;
69 impl CommandDebugChangeContext for NoRead {
70 fn change_projection(&self) -> DebugChangeProjection {
71 panic!("missing capability must prevent host reads")
72 }
73 }
74 let result = super::change::change(
75 CommandContexts::empty().with_debug_change(&mut NoRead),
76 None,
77 );
78 assert_eq!(
79 result.message.as_deref(),
80 Some("Error: Command capability unavailable: presentation")
81 );
82 assert!(result.is_error);
83 assert!(result.action.is_none());
84 }
85
86 #[test]
87 fn whole_group_has_exact_authority_and_safe_missing_facets() {
88 let expected = [
89 ("tokens", Caps::DEBUG_DIAGNOSTICS | Caps::PRESENTATION),
90 ("cost", Caps::DEBUG_DIAGNOSTICS | Caps::PRESENTATION),
91 ("receipts", Caps::DEBUG_RECEIPTS),
92 ("balance", Caps::DEBUG_DIAGNOSTICS),
93 ("cache", Caps::DEBUG_DIAGNOSTICS | Caps::PRESENTATION),
94 ("preview-request", Caps::NONE),
95 ("tools", Caps::DEBUG_DIAGNOSTICS),
96 ("change", Caps::DEBUG_CHANGE | Caps::PRESENTATION),
97 ("system", Caps::DEBUG_DIAGNOSTICS),
98 ("context", Caps::DEBUG_DIAGNOSTICS),
99 ("edit", Caps::DEBUG_HISTORY),
100 ("diff", Caps::DEBUG_DIFF),
101 ("undo", Caps::DEBUG_UNDO | Caps::DEBUG_HISTORY),
102 ("retry", Caps::DEBUG_HISTORY),
103 ];
104 for ((info, handler), (name, expected)) in portable_handlers().into_iter().zip(expected) {
105 assert_eq!(info.name, name);
106 match handler {
107 CommandHandler::Pure(run) => {
108 assert_eq!(name, "preview-request");
109 assert!(expected.is_empty());
110 assert!(matches!(
111 run(Some("json")).action,
112 Some(DebugAction::PreviewOutboundRequest { json: true, .. })
113 ));
114 }
115 CommandHandler::Contextual {
116 capabilities,
117 handler,
118 } => {
119 assert_eq!(capabilities, expected, "/{name}");
120 let result = handler(CommandContexts::empty(), None);
121 assert!(result.is_error, "/{name}");
122 assert!(
123 result
124 .message
125 .unwrap()
126 .starts_with("Error: Command capability unavailable:")
127 );
128 assert!(result.action.is_none());
129 }
130 }
131 }
132 }
133
134 fn empty_receipt() -> Receipt {
135 Receipt {
136 schema_id: RECEIPT_SCHEMA_ID,
137 source: ReceiptSource {
138 kind: SourceKind::Session,
139 id: "session-1".into(),
140 title: None,
141 workspace: None,
142 model: None,
143 started_at: None,
144 updated_at: None,
145 },
146 turn: None,
147 postures: vec![],
148 totals: ReceiptTotals::default(),
149 actions: vec![],
150 omitted_actions: 0,
151 not_recorded: vec![],
152 claim_ceiling: [
153 "local_record_only",
154 "not_safety_certification",
155 "not_provider_compatibility_certification",
156 ],
157 }
158 }
159
160 struct ReceiptFacet {
161 calls: RefCell<Vec<Option<String>>>,
162 result: Result<Receipt, DebugReceiptError>,
163 }
164 impl CommandDebugReceiptsContext for ReceiptFacet {
165 fn receipt(&self, turn: Option<&str>) -> Result<Receipt, DebugReceiptError> {
166 self.calls.borrow_mut().push(turn.map(str::to_owned));
167 self.result.clone()
168 }
169 }
170
171 #[test]
172 fn receipts_validate_before_host_reads_and_preserve_last_turn_selector() {
173 let mut facet = ReceiptFacet {
174 calls: RefCell::new(vec![]),
175 result: Ok(empty_receipt()),
176 };
177 for argument in [
178 "bad",
179 "json -1",
180 "999999999999999999999999999999999999999999",
181 ] {
182 let result = receipts::receipts(
183 CommandContexts::empty().with_debug_receipts(&mut facet),
184 Some(argument),
185 );
186 assert!(result.is_error);
187 assert!(
188 result
189 .message
190 .unwrap()
191 .contains("Use /receipts [json] [<turn>].")
192 );
193 }
194 assert!(facet.calls.borrow().is_empty());
195 let result = receipts::receipts(
196 CommandContexts::empty().with_debug_receipts(&mut facet),
197 Some("json 2 03"),
198 );
199 assert!(!result.is_error);
200 assert_eq!(&*facet.calls.borrow(), &[Some("03".into())]);
201 let result = receipts::receipts(
202 CommandContexts::empty().with_debug_receipts(&mut facet),
203 None,
204 );
205 assert_eq!(
206 result.message.as_deref(),
207 Some("# Receipt: session session-1\n\nsession session-1\n\nNo actions recorded.")
208 );
209 assert!(result.action.is_none());
210 }
211
212 #[test]
213 fn receipt_failures_keep_the_approval_and_builder_error_contracts() {
214 for (error, message) in [
215 (
216 DebugReceiptError::ApprovalLog("broken record".into()),
217 "Error: Could not read this session's approval log: broken record",
218 ),
219 (
220 DebugReceiptError::Build("turn 9 not found".into()),
221 "Error: turn 9 not found",
222 ),
223 ] {
224 let mut facet = ReceiptFacet {
225 calls: RefCell::new(vec![]),
226 result: Err(error),
227 };
228 let result = receipts::receipts(
229 CommandContexts::empty().with_debug_receipts(&mut facet),
230 None,
231 );
232 assert!(result.is_error);
233 assert_eq!(result.message.as_deref(), Some(message));
234 assert!(result.action.is_none());
235 assert_eq!(facet.calls.borrow().len(), 1);
236 }
237 }
238
239 #[test]
240 fn receipt_rendering_preserves_json_fences_and_prevents_forged_terminal_lines() {
241 let mut receipt = empty_receipt();
242 receipt.source.title = Some("````\nforged\u{1b}[31m".into());
243 let mut facet = ReceiptFacet {
244 calls: RefCell::new(vec![]),
245 result: Ok(receipt),
246 };
247 let result = receipts::receipts(
248 CommandContexts::empty().with_debug_receipts(&mut facet),
249 Some("json"),
250 );
251 let output = result.message.unwrap();
252 let json = output
253 .strip_prefix("`````json\n")
254 .unwrap()
255 .strip_suffix("\n`````")
256 .unwrap();
257 let value: serde_json::Value = serde_json::from_str(json).unwrap();
258 assert_eq!(value["schema_id"], RECEIPT_SCHEMA_ID);
259 assert_eq!(value["source"]["title"], "````\nforged\u{1b}[31m");
260 let result = receipts::receipts(
261 CommandContexts::empty().with_debug_receipts(&mut facet),
262 None,
263 );
264 let output = result.message.unwrap();
265 assert!(!output.contains("\nforged"));
266 assert!(!output.contains('\u{1b}'));
267 assert!(output.contains("\\nforged\\u{1b}"));
268 }
269
270 #[derive(Default)]
271 struct HistoryFacet {
272 events: Rc<RefCell<Vec<&'static str>>>,
273 input: Option<String>,
274 composer: Option<String>,
275 removed: usize,
276 }
277 impl CommandDebugHistoryContext for HistoryFacet {
278 fn last_user_input(&self) -> Option<String> {
279 self.events.borrow_mut().push("last_user");
280 self.input.clone()
281 }
282 fn load_composer(&mut self, input: String) {
283 self.events.borrow_mut().push("composer");
284 self.composer = Some(input);
285 }
286 fn undo_conversation(&mut self) -> DebugConversationUndo {
287 self.events.borrow_mut().push("undo_chat");
288 DebugConversationUndo {
289 removed: self.removed,
290 sync: synced_conversation(),
291 }
292 }
293 }
294 fn synced_conversation() -> SessionSyncPayload {
295 SessionSyncPayload {
296 session_id: Some("undo-session".to_string()),
297 messages: Vec::new(),
298 system_prompt: None,
299 model: "model".to_string(),
300 workspace: std::path::PathBuf::from("/tmp/undo-workspace"),
301 mode: codewhale_command_contract::CommandMode::Agent,
302 }
303 }
304 struct UndoFacet {
305 events: Rc<RefCell<Vec<&'static str>>>,
306 outcome: DebugUndoOutcome,
307 }
308 impl CommandDebugUndoContext for UndoFacet {
309 fn undo_files(&mut self) -> DebugUndoOutcome {
310 self.events.borrow_mut().push("undo_files");
311 self.outcome.clone()
312 }
313 }
314
315 #[test]
316 fn undo_refusals_never_fall_back_to_history_or_parse_backend_text() {
317 let events = Rc::new(RefCell::new(vec![]));
318 let mut history = HistoryFacet {
319 events: events.clone(),
320 removed: 2,
321 ..Default::default()
322 };
323 for outcome in [
324 DebugUndoOutcome::Untrusted,
325 DebugUndoOutcome::SnapshotPending,
326 DebugUndoOutcome::ChangedSince {
327 label: "tool:1".into(),
328 paths: vec!["a.txt".into()],
329 },
330 // Deliberately resembles the old English fallback prefixes. Its typed
331 // failure must stay a refusal and cannot silently delete conversation.
332 DebugUndoOutcome::RestoreBlocked("No snapshots found, but operation was refused".into()),
333 DebugUndoOutcome::CompareFailed("read error".into()),
334 DebugUndoOutcome::RestoreFailed("write error".into()),
335 ] {
336 events.borrow_mut().clear();
337 let mut facet = UndoFacet {
338 events: events.clone(),
339 outcome,
340 };
341 let result = undo::undo(
342 CommandContexts::empty()
343 .with_debug_undo(&mut facet)
344 .with_debug_history(&mut history),
345 None,
346 );
347 assert!(result.action.is_none());
348 assert_eq!(&*events.borrow(), &["undo_files"]);
349 }
350 }
351
352 #[test]
353 fn undo_fallback_requires_both_facets_and_keeps_file_warning() {
354 let events = Rc::new(RefCell::new(vec![]));
355 let mut facet = UndoFacet {
356 events: events.clone(),
357 outcome: DebugUndoOutcome::RepoUnavailable {
358 workspace: "/workspace".into(),
359 error: "read denied".into(),
360 },
361 };
362 let result = undo::undo(CommandContexts::empty().with_debug_undo(&mut facet), None);
363 assert_eq!(
364 result.message.as_deref(),
365 Some("Error: Command capability unavailable: debug_history")
366 );
367 assert!(events.borrow().is_empty());
368 let mut history = HistoryFacet {
369 events: events.clone(),
370 removed: 2,
371 ..Default::default()
372 };
373 let result = undo::undo(
374 CommandContexts::empty()
375 .with_debug_undo(&mut facet)
376 .with_debug_history(&mut history),
377 None,
378 );
379 assert_eq!(
380 result.message.as_deref(),
381 Some(
382 "Removed 2 message(s)\nWorkspace files were NOT reverted — only the conversation was rolled back.\nSnapshot repo unavailable for /workspace: read denied"
383 )
384 );
385 assert_eq!(&*events.borrow(), &["undo_files", "undo_chat"]);
386 for outcome in [
387 DebugUndoOutcome::NoSnapshots,
388 DebugUndoOutcome::NoSession,
389 DebugUndoOutcome::NoOwnedSteps,
390 DebugUndoOutcome::NoDifference,
391 ] {
392 facet.outcome = outcome;
393 events.borrow_mut().clear();
394 let result = undo::undo(
395 CommandContexts::empty()
396 .with_debug_undo(&mut facet)
397 .with_debug_history(&mut history),
398 None,
399 );
400 assert_eq!(result.message.as_deref(), Some("Removed 2 message(s)"));
401 assert_eq!(&*events.borrow(), &["undo_files", "undo_chat"]);
402 }
403 }
404
405 #[test]
406 fn retry_truncates_history_before_emitting_the_exact_original_input() {
407 let input = "漢字".repeat(20);
408 let mut history = HistoryFacet {
409 input: Some(input.clone()),
410 removed: 2,
411 ..Default::default()
412 };
413 let result = undo::retry(
414 CommandContexts::empty().with_debug_history(&mut history),
415 None,
416 );
417 assert_eq!(
418 result.action,
419 Some(DebugAction::ConversationUndo {
420 sync: synced_conversation(),
421 retry_input: Some(input.clone()),
422 })
423 );
424 assert_eq!(
425 result.message,
426 Some(format!("Retrying: {}...", "漢字".repeat(8)))
427 );
428 assert_eq!(&*history.events.borrow(), &["last_user", "undo_chat"]);
429 history.events.borrow_mut().clear();
430 let result = undo::edit(
431 CommandContexts::empty().with_debug_history(&mut history),
432 None,
433 );
434 assert!(!result.is_error);
435 assert_eq!(history.composer, Some(input));
436 assert_eq!(&*history.events.borrow(), &["last_user", "composer"]);
437 }
438
438 lines RUST