| 1 | //! FEAT-029: public command-surface and localization parity for the |
| 2 | //! `debug::diagnostics` slice. |
| 3 | //! |
| 4 | //! The host regressions prove handler/rendering parity. This module proves the |
| 5 | //! *observable command surface* is unchanged: registry metadata (name, aliases, |
| 6 | //! usage), registry position, the `description_key` -> catalog bridge, palette |
| 7 | //! and discovery classification, and canonical-name/alias dispatch equivalence |
| 8 | //! through the public `execute` seam. |
| 9 | //! |
| 10 | //! It lives at the `commands` root for the same FEAT-045 extraction reason as |
| 11 | //! the regression suite. The metadata fixture was captured from the |
| 12 | //! untouched implementation and compared byte-for-byte. |
| 13 | |
| 14 | use codewhale_localization::{Locale, MessageId}; |
| 15 | |
| 16 | use crate::commands::debug_diagnostics_test_support::{DiagnosticsHarness, assert_fixture}; |
| 17 | use crate::commands::{CommandResult, execute}; |
| 18 | use crate::config::ProviderKind; |
| 19 | use crate::tui::app::AppAction; |
| 20 | |
| 21 | /// The eight declared diagnostics commands in registry order, with their exact |
| 22 | /// aliases and catalog description ids. |
| 23 | const DIAGNOSTICS: &[(&str, &[&str], MessageId)] = &[ |
| 24 | ("tokens", &[], MessageId::CmdTokensDescription), |
| 25 | ("cost", &[], MessageId::CmdCostDescription), |
| 26 | ("balance", &[], MessageId::CmdBalanceDescription), |
| 27 | ("cache", &[], MessageId::CmdCacheDescription), |
| 28 | ( |
| 29 | "preview-request", |
| 30 | &["dryrun", "preview_request"], |
| 31 | MessageId::CmdPreviewRequestDescription, |
| 32 | ), |
| 33 | ("tools", &["tool-studio"], MessageId::CmdToolsDescription), |
| 34 | ("system", &["xitong"], MessageId::CmdSystemDescription), |
| 35 | ("context", &["ctx"], MessageId::CmdContextDescription), |
| 36 | ]; |
| 37 | |
| 38 | fn info(name: &str) -> &'static crate::commands::CommandInfo { |
| 39 | crate::commands::get_command_info(name) |
| 40 | .unwrap_or_else(|| panic!("/{name} must remain registered")) |
| 41 | } |
| 42 | |
| 43 | /// Freeze the complete registry metadata surface (name, aliases, usage, |
| 44 | /// English description, palette text, discovery flags) for all eight commands. |
| 45 | #[test] |
| 46 | fn diagnostics_surface_metadata_matches_baseline() { |
| 47 | let _harness = DiagnosticsHarness::new(); |
| 48 | let mut out = String::new(); |
| 49 | for (name, _, _) in DIAGNOSTICS { |
| 50 | let info = info(name); |
| 51 | out.push_str(&format!("name: {}\n", info.name)); |
| 52 | out.push_str(&format!("aliases: {:?}\n", info.aliases)); |
| 53 | out.push_str(&format!("usage: {}\n", info.usage)); |
| 54 | out.push_str(&format!( |
| 55 | "description_en: {}\n", |
| 56 | info.description_for(Locale::En) |
| 57 | )); |
| 58 | out.push_str(&format!( |
| 59 | "palette_en: {}\n", |
| 60 | info.palette_description_for(Locale::En) |
| 61 | )); |
| 62 | out.push_str(&format!( |
| 63 | "requires_argument: {}\n", |
| 64 | info.requires_argument() |
| 65 | )); |
| 66 | out.push_str(&format!("unlisted: {}\n", info.is_unlisted())); |
| 67 | out.push_str(&format!( |
| 68 | "show_in_empty_discovery: {}\n", |
| 69 | info.show_in_empty_discovery() |
| 70 | )); |
| 71 | out.push_str("---\n"); |
| 72 | } |
| 73 | assert_fixture("surface_metadata.txt", &out); |
| 74 | } |
| 75 | |
| 76 | /// Every canonical name and alias resolves to the same registry entry, and the |
| 77 | /// `description_key` bridge maps to the expected catalog `MessageId`. |
| 78 | #[test] |
| 79 | fn diagnostics_names_aliases_and_description_bridge_are_exact() { |
| 80 | let registry = crate::commands::registry(); |
| 81 | for (name, aliases, description_id) in DIAGNOSTICS { |
| 82 | let canonical = info(name); |
| 83 | assert_eq!(canonical.name, *name, "canonical name"); |
| 84 | assert_eq!(canonical.aliases, *aliases, "/{name} alias list"); |
| 85 | assert_eq!( |
| 86 | canonical.description_id, *description_id, |
| 87 | "/{name} catalog bridge" |
| 88 | ); |
| 89 | assert!( |
| 90 | !canonical.description_for(Locale::En).trim().is_empty(), |
| 91 | "/{name} must resolve an English description" |
| 92 | ); |
| 93 | |
| 94 | let entry = registry |
| 95 | .get(name) |
| 96 | .unwrap_or_else(|| panic!("/{name} must be registered")); |
| 97 | assert_eq!(entry.info().name, *name); |
| 98 | for alias in *aliases { |
| 99 | let via_alias = registry |
| 100 | .get(alias) |
| 101 | .unwrap_or_else(|| panic!("/{alias} must resolve")); |
| 102 | assert_eq!( |
| 103 | via_alias.info().name, |
| 104 | *name, |
| 105 | "/{alias} must resolve to /{name}" |
| 106 | ); |
| 107 | assert_eq!(via_alias.info().usage, canonical.usage, "/{alias} usage"); |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Portable description keys resolve to the exact original catalog ids for |
| 113 | /// every member; metadata does not grant a runtime presentation facet. |
| 114 | #[test] |
| 115 | fn diagnostics_description_keys_resolve_to_the_original_catalog() { |
| 116 | let keys = [ |
| 117 | ("tokens", "cmd_tokens_description"), |
| 118 | ("cost", "cmd_cost_description"), |
| 119 | ("balance", "cmd_balance_description"), |
| 120 | ("cache", "cmd_cache_description"), |
| 121 | ("preview-request", "cmd_preview_request_description"), |
| 122 | ("tools", "cmd_tools_description"), |
| 123 | ("system", "cmd_system_description"), |
| 124 | ("context", "cmd_context_description"), |
| 125 | ]; |
| 126 | for ((name, _, id), (expected_name, key)) in DIAGNOSTICS.iter().zip(keys) { |
| 127 | assert_eq!(*name, expected_name); |
| 128 | assert_eq!(super::contract::key_to_message_id(key), Some(*id)); |
| 129 | assert_eq!(info(name).description_id, *id); |
| 130 | assert!(!info(name).description_for(Locale::Ja).is_empty()); |
| 131 | } |
| 132 | assert_eq!( |
| 133 | super::contract::key_to_message_id("cmd_not_registered_description"), |
| 134 | None |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | /// Runtime presentation uses the existing catalog and exact named-placeholder |
| 139 | /// contract for only the three commands that need it. Literal metadata is |
| 140 | /// separately bridged above; an unknown key or incomplete replacements fail. |
| 141 | #[test] |
| 142 | fn diagnostics_runtime_translation_uses_original_catalog_and_placeholder_contract() { |
| 143 | let mut harness = DiagnosticsHarness::new(); |
| 144 | harness.app.ui_locale = Locale::Ja; |
| 145 | let mut bundle = harness.app.command_contexts(); |
| 146 | let mut parts = bundle |
| 147 | .contexts(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION) |
| 148 | .into_parts(); |
| 149 | let presentation = parts.presentation.as_deref_mut().unwrap(); |
| 150 | assert_eq!( |
| 151 | presentation.translate("cmd_cost_coverage", &[("priced", "2"), ("turns", "3")]), |
| 152 | Ok("対象: 課金対象ターン 3 件のうち 2 件を算定しました。".into()) |
| 153 | ); |
| 154 | assert_eq!( |
| 155 | presentation.translate( |
| 156 | "cmd_cache_totals", |
| 157 | &[ |
| 158 | ("sum_in", "4"), |
| 159 | ("sum_hit", "2"), |
| 160 | ("sum_miss", "1"), |
| 161 | ("avg", "50%") |
| 162 | ], |
| 163 | ), |
| 164 | Ok("Σ 入力: 4 Σ ヒット: 2 Σ ミス: 1 平均ヒット率: 50%\n".into()) |
| 165 | ); |
| 166 | assert_eq!( |
| 167 | presentation.translate( |
| 168 | "cmd_tokens_context_with_window", |
| 169 | &[("used", "6"), ("window", "12"), ("percent", "50.0")], |
| 170 | ), |
| 171 | Ok("~6 / 12 (50.0%)".into()) |
| 172 | ); |
| 173 | assert_eq!( |
| 174 | presentation.translate("cmd_cost_coverage", &[("priced", "2")]), |
| 175 | Err("invalid translation replacement contract".into()) |
| 176 | ); |
| 177 | assert_eq!( |
| 178 | presentation.translate("cmd_not_registered", &[]), |
| 179 | Err("unknown translation key".into()) |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | /// The production registry now exposes exactly the declared facets, including |
| 184 | /// each alias. Preview stays a pure function, not a contextual empty envelope. |
| 185 | #[test] |
| 186 | fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { |
| 187 | use codewhale_command_contract::handler::{ |
| 188 | CommandCapabilities as Caps, CommandContexts, CommandHandler, |
| 189 | }; |
| 190 | |
| 191 | let mut harness = DiagnosticsHarness::new(); |
| 192 | for (name, aliases, _) in DIAGNOSTICS { |
| 193 | let expected = match *name { |
| 194 | "tokens" | "cost" | "cache" => Caps::DEBUG_DIAGNOSTICS | Caps::PRESENTATION, |
| 195 | "preview-request" => Caps::NONE, |
| 196 | _ => Caps::DEBUG_DIAGNOSTICS, |
| 197 | }; |
| 198 | for spelling in std::iter::once(*name).chain(aliases.iter().copied()) { |
| 199 | let registered = crate::commands::registry() |
| 200 | .get(spelling) |
| 201 | .expect("registered spelling"); |
| 202 | match registered |
| 203 | .contextual_handler() |
| 204 | .expect("portable registration") |
| 205 | { |
| 206 | CommandHandler::Pure(pure) => { |
| 207 | assert_eq!(*name, "preview-request", "only preview is pure"); |
| 208 | assert_eq!(expected, Caps::NONE); |
| 209 | assert!(matches!( |
| 210 | pure(Some("json")).action, |
| 211 | Some(AppAction::PreviewOutboundRequest { json: true, .. }) |
| 212 | )); |
| 213 | } |
| 214 | CommandHandler::Contextual { |
| 215 | capabilities, |
| 216 | handler, |
| 217 | } => { |
| 218 | assert_ne!(*name, "preview-request"); |
| 219 | assert_eq!(capabilities, expected, "/{spelling} declaration"); |
| 220 | let result = handler(CommandContexts::empty(), None); |
| 221 | assert!(result.is_error); |
| 222 | assert_eq!( |
| 223 | result.message.as_deref(), |
| 224 | Some("Error: Command capability unavailable: debug_diagnostics") |
| 225 | ); |
| 226 | assert!(result.action.is_none()); |
| 227 | |
| 228 | let mut bundle = harness.app.command_contexts(); |
| 229 | let parts = bundle.contexts(capabilities).into_parts(); |
| 230 | let codewhale_command_contract::handler::ContextParts { |
| 231 | session, |
| 232 | model, |
| 233 | cost, |
| 234 | mode_policy, |
| 235 | system_prompt, |
| 236 | skills, |
| 237 | workspace, |
| 238 | presentation, |
| 239 | media, |
| 240 | memory, |
| 241 | project, |
| 242 | skill_group, |
| 243 | plugin, |
| 244 | lifecycle, |
| 245 | control, |
| 246 | export, |
| 247 | structcopy, |
| 248 | debug_receipts, |
| 249 | debug_change, |
| 250 | debug_history, |
| 251 | debug_diff, |
| 252 | debug_undo, |
| 253 | debug_diagnostics, |
| 254 | } = parts; |
| 255 | assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); |
| 256 | assert_eq!( |
| 257 | presentation.is_some(), |
| 258 | expected.contains(Caps::PRESENTATION), |
| 259 | "/{spelling} presentation" |
| 260 | ); |
| 261 | for (facet, exposed) in [ |
| 262 | ("session", session.is_some()), |
| 263 | ("model", model.is_some()), |
| 264 | ("cost", cost.is_some()), |
| 265 | ("mode_policy", mode_policy.is_some()), |
| 266 | ("system_prompt", system_prompt.is_some()), |
| 267 | ("skills", skills.is_some()), |
| 268 | ("workspace", workspace.is_some()), |
| 269 | ("media", media.is_some()), |
| 270 | ("memory", memory.is_some()), |
| 271 | ("project", project.is_some()), |
| 272 | ("skill_group", skill_group.is_some()), |
| 273 | ("plugin", plugin.is_some()), |
| 274 | ("lifecycle", lifecycle.is_some()), |
| 275 | ("control", control.is_some()), |
| 276 | ("export", export.is_some()), |
| 277 | ("structcopy", structcopy.is_some()), |
| 278 | ("debug_receipts", debug_receipts.is_some()), |
| 279 | ("debug_change", debug_change.is_some()), |
| 280 | ("debug_history", debug_history.is_some()), |
| 281 | ("debug_diff", debug_diff.is_some()), |
| 282 | ("debug_undo", debug_undo.is_some()), |
| 283 | ] { |
| 284 | assert!(!exposed, "/{spelling} must not expose {facet}"); |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | for name in ["receipts", "change", "edit", "diff", "undo", "retry"] { |
| 291 | assert!( |
| 292 | crate::commands::registry() |
| 293 | .get(name) |
| 294 | .unwrap() |
| 295 | .contextual_handler() |
| 296 | .is_some(), |
| 297 | "/{name} must use its portable registration" |
| 298 | ); |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | /// Registry position inside the debug group is preserved: the eight |
| 303 | /// diagnostics commands stay in their original relative order and the five |
| 304 | /// The other debug commands remain registered around these diagnostics. |
| 305 | #[test] |
| 306 | fn diagnostics_registry_position_matches_baseline() { |
| 307 | let names: Vec<&str> = crate::commands::command_infos() |
| 308 | .iter() |
| 309 | .map(|info| info.name) |
| 310 | .collect(); |
| 311 | let position = |name: &str| { |
| 312 | names |
| 313 | .iter() |
| 314 | .position(|candidate| *candidate == name) |
| 315 | .unwrap_or_else(|| panic!("/{name} must be registered; found {names:?}")) |
| 316 | }; |
| 317 | |
| 318 | let order = [ |
| 319 | "tokens", |
| 320 | "cost", |
| 321 | "receipts", |
| 322 | "balance", |
| 323 | "cache", |
| 324 | "preview-request", |
| 325 | "tools", |
| 326 | "change", |
| 327 | "system", |
| 328 | "context", |
| 329 | "edit", |
| 330 | "diff", |
| 331 | "undo", |
| 332 | "retry", |
| 333 | ]; |
| 334 | for pair in order.windows(2) { |
| 335 | assert!( |
| 336 | position(pair[0]) < position(pair[1]), |
| 337 | "/{} must stay before /{}", |
| 338 | pair[0], |
| 339 | pair[1] |
| 340 | ); |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | fn render(result: &CommandResult) -> String { |
| 345 | let mut out = String::new(); |
| 346 | out.push_str(&format!("is_error: {}\n", result.is_error)); |
| 347 | match &result.message { |
| 348 | Some(message) => out.push_str(&format!("message:\n{message}\n")), |
| 349 | None => out.push_str("message: <none>\n"), |
| 350 | } |
| 351 | match &result.action { |
| 352 | Some(action) => out.push_str(&format!("action: {action:?}\n")), |
| 353 | None => out.push_str("action: <none>\n"), |
| 354 | } |
| 355 | out |
| 356 | } |
| 357 | |
| 358 | /// Canonical names and every compatibility alias dispatch to byte-identical |
| 359 | /// results through the public seam. |
| 360 | #[test] |
| 361 | fn public_dispatch_canonical_and_alias_are_byte_equivalent() { |
| 362 | let preview_cases = [ |
| 363 | "/preview-request json", |
| 364 | "/preview-request json --prompt keep the bytes", |
| 365 | "/preview-request base-prompt", |
| 366 | ]; |
| 367 | for command in preview_cases { |
| 368 | let canonical = { |
| 369 | let mut canonical_harness = DiagnosticsHarness::new(); |
| 370 | execute(command, &mut canonical_harness.app) |
| 371 | }; |
| 372 | for alias in ["dryrun", "preview_request"] { |
| 373 | let aliased = command.replacen("preview-request", alias, 1); |
| 374 | let mut alias_harness = DiagnosticsHarness::new(); |
| 375 | let aliased_result = execute(&aliased, &mut alias_harness.app); |
| 376 | assert_eq!( |
| 377 | render(&canonical), |
| 378 | render(&aliased_result), |
| 379 | "{aliased} must match {command}" |
| 380 | ); |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | // /tools and /tool-studio share the prepared-snapshot payload. |
| 385 | let canonical = { |
| 386 | let mut canonical_harness = DiagnosticsHarness::new(); |
| 387 | canonical_harness.app.session.last_tool_request_snapshot = Some(snapshot()); |
| 388 | execute("/tools json", &mut canonical_harness.app) |
| 389 | }; |
| 390 | let mut alias_harness = DiagnosticsHarness::new(); |
| 391 | alias_harness.app.session.last_tool_request_snapshot = Some(snapshot()); |
| 392 | let aliased = execute("/tool-studio json", &mut alias_harness.app); |
| 393 | assert_eq!(render(&canonical), render(&aliased)); |
| 394 | |
| 395 | // /system and /xitong share the exact message. |
| 396 | let canonical = { |
| 397 | let mut canonical_harness = DiagnosticsHarness::new(); |
| 398 | canonical_harness.app.system_prompt = |
| 399 | Some(codewhale_models::SystemPrompt::Text("aliased".to_string())); |
| 400 | execute("/system", &mut canonical_harness.app) |
| 401 | }; |
| 402 | let mut alias_harness = DiagnosticsHarness::new(); |
| 403 | alias_harness.app.system_prompt = |
| 404 | Some(codewhale_models::SystemPrompt::Text("aliased".to_string())); |
| 405 | let aliased = execute("/xitong", &mut alias_harness.app); |
| 406 | assert_eq!(render(&canonical), render(&aliased)); |
| 407 | |
| 408 | // /context and /ctx share the bare inspector action. |
| 409 | let canonical = { |
| 410 | let mut canonical_harness = DiagnosticsHarness::new(); |
| 411 | execute("/context", &mut canonical_harness.app) |
| 412 | }; |
| 413 | let mut alias_harness = DiagnosticsHarness::new(); |
| 414 | let aliased = execute("/ctx", &mut alias_harness.app); |
| 415 | assert!(matches!( |
| 416 | canonical.action, |
| 417 | Some(AppAction::OpenContextInspector) |
| 418 | )); |
| 419 | assert_eq!(render(&canonical), render(&aliased)); |
| 420 | } |
| 421 | |
| 422 | /// `/preview-request` remains a pure action-only leaf through the public |
| 423 | /// dispatch path; no prompt, route or provider state is read. |
| 424 | #[test] |
| 425 | fn preview_request_remains_the_pure_registration() { |
| 426 | // The public dispatcher still uses the staged function wrapper until |
| 427 | // Phase 6 wires the portable registrations. Even here preview creates no |
| 428 | // facet bundle and parses the action without provider/session state. |
| 429 | let mut harness = DiagnosticsHarness::new(); |
| 430 | harness.app.api_provider = ProviderKind::Ollama; |
| 431 | let result = execute("/preview-request", &mut harness.app); |
| 432 | assert!(matches!( |
| 433 | result.action, |
| 434 | Some(AppAction::PreviewOutboundRequest { .. }) |
| 435 | )); |
| 436 | } |
| 437 | |
| 438 | fn snapshot() -> crate::tool_inspection::ToolInspectionSnapshot { |
| 439 | crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request( |
| 440 | "turn-1", |
| 441 | 2, |
| 442 | Some(&[tool("read_file"), tool("write_file")]), |
| 443 | ) |
| 444 | } |
| 445 | |
| 446 | fn tool(name: &str) -> codewhale_models::Tool { |
| 447 | codewhale_models::Tool { |
| 448 | tool_type: Some("function".to_string()), |
| 449 | name: name.to_string(), |
| 450 | description: format!("{name} test tool"), |
| 451 | input_schema: serde_json::json!({ |
| 452 | "type": "object", |
| 453 | "properties": {"path": {"type": "string"}} |
| 454 | }), |
| 455 | allowed_callers: None, |
| 456 | defer_loading: Some(false), |
| 457 | input_examples: None, |
| 458 | strict: Some(true), |
| 459 | cache_control: None, |
| 460 | } |
| 461 | } |
| 462 |