| 1 | //! Real-host preservation fixtures, captured before FEAT-026 production edits. |
| 2 | //! These fixtures stay outside the portable session command closure. |
| 3 | use super::{CommandResult, execute}; |
| 4 | use crate::config::Config; |
| 5 | use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; |
| 6 | use crate::tui::app::{App, TuiOptions}; |
| 7 | use crate::tui::clipboard::ClipboardHandler; |
| 8 | use codewhale_localization::{Locale, tr}; |
| 9 | use codewhale_models::{ContentBlock, ImageUrlContent, Message, Role, ToolCaller}; |
| 10 | use serde_json::{Value, json}; |
| 11 | use tempfile::TempDir; |
| 12 | |
| 13 | fn app(temp: &TempDir) -> App { |
| 14 | let options = TuiOptions { |
| 15 | skills_dir: temp.path().join("skills"), |
| 16 | memory_path: temp.path().join("memory.md"), |
| 17 | notes_path: temp.path().join("notes.txt"), |
| 18 | mcp_config_path: temp.path().join("mcp.json"), |
| 19 | ..crate::test_support::test_tui_options(temp.path()) |
| 20 | }; |
| 21 | let mut app = App::new(options, &Config::default()); |
| 22 | app.ui_locale = Locale::En; |
| 23 | app.current_session_id = Some("structcopy-baseline-session".into()); |
| 24 | app |
| 25 | } |
| 26 | |
| 27 | fn call(name: &str) -> ContentBlock { |
| 28 | ContentBlock::ToolUse { |
| 29 | execution_id: None, |
| 30 | id: "call-golden".into(), |
| 31 | name: name.into(), |
| 32 | input: json!({"url":"https://alice:secret@example.test/path?token=x#frag", "api_key":"secret-value"}), |
| 33 | caller: Some(ToolCaller { |
| 34 | caller_type: "code_execution_20250825".into(), |
| 35 | tool_id: None, |
| 36 | }), |
| 37 | thought_signature: Some("private-call-signature".into()), |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | fn result(content: &str, is_error: Option<bool>) -> ContentBlock { |
| 42 | ContentBlock::ToolResult { |
| 43 | execution_id: None, |
| 44 | tool_use_id: "call-golden".into(), |
| 45 | content: content.into(), |
| 46 | is_error, |
| 47 | content_blocks: Some(vec![ |
| 48 | json!({"type":"image","mime_type":"image/png","data":"private-base64"}), |
| 49 | json!({"type":"text","text":"visible tool text"}), |
| 50 | ]), |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | fn seed(app: &mut App) { |
| 55 | app.api_messages = std::sync::Arc::new(vec![ |
| 56 | Message { |
| 57 | role: Role::System, |
| 58 | content: vec![ContentBlock::Text { |
| 59 | text: "hidden system instruction".into(), |
| 60 | cache_control: None, |
| 61 | }], |
| 62 | }, |
| 63 | Message { |
| 64 | role: Role::Assistant, |
| 65 | content: vec![ |
| 66 | ContentBlock::Text { |
| 67 | text: "visible text".into(), |
| 68 | cache_control: None, |
| 69 | }, |
| 70 | ContentBlock::Thinking { |
| 71 | thinking: "hidden reasoning".into(), |
| 72 | signature: Some("hidden-signature".into()), |
| 73 | state: None, |
| 74 | }, |
| 75 | call("first-call"), |
| 76 | result("first-result", Some(false)), |
| 77 | ContentBlock::ImageUrl { |
| 78 | image_url: ImageUrlContent { |
| 79 | url: "data:image/png;base64,private".into(), |
| 80 | }, |
| 81 | }, |
| 82 | ContentBlock::ImageUrl { |
| 83 | image_url: ImageUrlContent { |
| 84 | url: "https://a:b@example.test/image.png?q=x#y".into(), |
| 85 | }, |
| 86 | }, |
| 87 | ContentBlock::ServerToolUse { |
| 88 | id: "server-only".into(), |
| 89 | name: "server".into(), |
| 90 | input: json!({"x":1}), |
| 91 | }, |
| 92 | ContentBlock::ToolSearchToolResult { |
| 93 | tool_use_id: "search".into(), |
| 94 | content: json!({"matches":["one"]}), |
| 95 | }, |
| 96 | ContentBlock::CodeExecutionToolResult { |
| 97 | tool_use_id: "exec".into(), |
| 98 | content: json!({"stdout":"ok"}), |
| 99 | }, |
| 100 | ], |
| 101 | }, |
| 102 | Message { |
| 103 | role: Role::User, |
| 104 | content: vec![result("last-result", None), call("last-call")], |
| 105 | }, |
| 106 | ]); |
| 107 | app.plan_state.try_lock().unwrap().update(UpdatePlanArgs { |
| 108 | title: Some("Golden plan".into()), |
| 109 | objective: Some("Preserve output".into()), |
| 110 | context_summary: Some("Context".into()), |
| 111 | explanation: Some("Explanation".into()), |
| 112 | sources_used: vec!["source".into()], |
| 113 | critical_files: vec!["src/file.rs".into()], |
| 114 | constraints: vec!["read only".into()], |
| 115 | recommended_approach: Some("typed observations".into()), |
| 116 | verification_plan: Some("golden comparison".into()), |
| 117 | risks_and_unknowns: Some("unknown remains null".into()), |
| 118 | handoff_packet: Some("ready".into()), |
| 119 | plan: vec![ |
| 120 | PlanItemArg { |
| 121 | step: "first".into(), |
| 122 | status: StepStatus::Pending, |
| 123 | }, |
| 124 | PlanItemArg { |
| 125 | step: "second".into(), |
| 126 | status: StepStatus::InProgress, |
| 127 | }, |
| 128 | PlanItemArg { |
| 129 | step: "third".into(), |
| 130 | status: StepStatus::Completed, |
| 131 | }, |
| 132 | ], |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | fn observation(result: CommandResult, app: &App) -> Value { |
| 137 | assert!( |
| 138 | result.action.is_none(), |
| 139 | "structcopy must never emit an action" |
| 140 | ); |
| 141 | json!({"message":result.message,"is_error":result.is_error,"clipboard":app.clipboard.last_written_text()}) |
| 142 | } |
| 143 | |
| 144 | fn dispatch(app: &mut App, command: &str) -> Value { |
| 145 | app.clipboard = ClipboardHandler::new(); |
| 146 | let before_messages = app.api_messages.clone(); |
| 147 | let before_plan = app.plan_state.try_lock().unwrap().snapshot(); |
| 148 | let before_session = app.current_session_id.clone(); |
| 149 | let result = execute(command, app); |
| 150 | assert_eq!(app.api_messages, before_messages); |
| 151 | assert_eq!(app.plan_state.try_lock().unwrap().snapshot(), before_plan); |
| 152 | assert_eq!(app.current_session_id, before_session); |
| 153 | observation(result, app) |
| 154 | } |
| 155 | |
| 156 | fn baseline_observations() -> Value { |
| 157 | let temp = TempDir::new().unwrap(); |
| 158 | let mut app = app(&temp); |
| 159 | seed(&mut app); |
| 160 | let mut captures = serde_json::Map::new(); |
| 161 | for (name, command) in [ |
| 162 | ("turn_internal", "/structcopy turn 1 stdout"), |
| 163 | ("turn_blocks", "/structcopy turn 2 stdout"), |
| 164 | ("turn_unknown_result", "/structcopy turn 3 stdout"), |
| 165 | ("tool_duplicate_last", "/structcopy tool call-golden stdout"), |
| 166 | ("plan_full", "/structcopy plan stdout"), |
| 167 | ("usize_plus_and_stdout_case", "/structcopy turn +2 StDoUt"), |
| 168 | ( |
| 169 | "server_tool_unavailable", |
| 170 | "/structcopy tool server-only stdout", |
| 171 | ), |
| 172 | ] { |
| 173 | captures.insert(name.into(), dispatch(&mut app, command)); |
| 174 | } |
| 175 | // Upstream now refuses duplicate identities instead of selecting the last. |
| 176 | assert_eq!(captures["tool_duplicate_last"]["is_error"], true); |
| 177 | assert_eq!(captures["tool_duplicate_last"]["clipboard"], Value::Null); |
| 178 | app.api_messages = std::sync::Arc::new(vec![Message { |
| 179 | role: Role::Assistant, |
| 180 | content: vec![call("lonely")], |
| 181 | }]); |
| 182 | captures.insert( |
| 183 | "tool_missing_result".into(), |
| 184 | dispatch(&mut app, "/structcopy tool call-golden stdout"), |
| 185 | ); |
| 186 | for (label, flag) in [ |
| 187 | ("false", Some(false)), |
| 188 | ("true", Some(true)), |
| 189 | ("unknown", None), |
| 190 | ] { |
| 191 | // Each tri-state case has one result; accumulated duplicates are now |
| 192 | // rejected by upstream's execution-identity contract. |
| 193 | app.api_messages_mut()[0].content.truncate(1); |
| 194 | app.api_messages_mut()[0] |
| 195 | .content |
| 196 | .push(result("result", flag)); |
| 197 | captures.insert( |
| 198 | format!("tool_error_{label}"), |
| 199 | dispatch(&mut app, "/structcopy tool call-golden stdout"), |
| 200 | ); |
| 201 | } |
| 202 | seed(&mut app); |
| 203 | for (locale, label) in [(Locale::En, "en"), (Locale::ZhHans, "zh_hans")] { |
| 204 | app.ui_locale = locale; |
| 205 | for (index, command) in [ |
| 206 | "/structcopy", |
| 207 | "/structcopy stdout", |
| 208 | "/structcopy turn 0", |
| 209 | "/structcopy turn -1", |
| 210 | "/structcopy turn 99999999999999999999999999", |
| 211 | "/structcopy Turn 1", |
| 212 | "/structcopy plan extra", |
| 213 | "/structcopy turn 99 stdout", |
| 214 | "/structcopy workflow absent stdout", |
| 215 | ] |
| 216 | .iter() |
| 217 | .enumerate() |
| 218 | { |
| 219 | captures.insert( |
| 220 | format!("{label}_error_{index}"), |
| 221 | dispatch(&mut app, command), |
| 222 | ); |
| 223 | } |
| 224 | for (transport, clipboard) in [ |
| 225 | ("native", ClipboardHandler::new()), |
| 226 | ("queued", ClipboardHandler::terminal_only_for_test()), |
| 227 | ("failed", ClipboardHandler::unavailable_for_test(false)), |
| 228 | ] { |
| 229 | app.clipboard = clipboard; |
| 230 | let value = execute("/structcopy turn 2", &mut app); |
| 231 | captures.insert(format!("{label}_{transport}"), observation(value, &app)); |
| 232 | } |
| 233 | let plan = app.plan_state.clone(); |
| 234 | let _guard = plan.try_lock().unwrap(); |
| 235 | app.clipboard = ClipboardHandler::new(); |
| 236 | let busy = execute("/structcopy plan stdout", &mut app); |
| 237 | assert!(busy.is_error); |
| 238 | assert!(app.clipboard.last_written_text().is_none()); |
| 239 | captures.insert(format!("{label}_busy_plan"), observation(busy, &app)); |
| 240 | } |
| 241 | app.ui_locale = Locale::En; |
| 242 | assert!( |
| 243 | !temp.path().join(".codewhale").exists(), |
| 244 | "missing workflow lookup created state" |
| 245 | ); |
| 246 | crate::tools::workflow::structcopy_test_seed_run( |
| 247 | temp.path(), |
| 248 | "golden-run", |
| 249 | "structcopy-baseline-session", |
| 250 | ); |
| 251 | let mut workflow = dispatch(&mut app, "/structcopy workflow golden-run stdout"); |
| 252 | let message = workflow["message"].as_str().unwrap(); |
| 253 | let parsed: Value = serde_json::from_str(message).unwrap(); |
| 254 | assert_eq!(parsed["object"]["leaf_count"], Value::Null); |
| 255 | // Only a volatile source timestamp is normalized. Keep the 13-byte width, |
| 256 | // so receipt payload_bytes still checks the exact baseline envelope. |
| 257 | let timestamp = parsed["object"]["started_at_ms"].as_u64().unwrap(); |
| 258 | assert_eq!(timestamp.to_string().len(), 13); |
| 259 | workflow["message"] = Value::String(message.replace( |
| 260 | &format!("\"started_at_ms\":{timestamp}"), |
| 261 | "\"started_at_ms\":1700000000000", |
| 262 | )); |
| 263 | captures.insert("workflow_owned".into(), workflow); |
| 264 | app.current_session_id = Some("another-session".into()); |
| 265 | captures.insert( |
| 266 | "workflow_wrong_owner".into(), |
| 267 | dispatch(&mut app, "/structcopy workflow golden-run stdout"), |
| 268 | ); |
| 269 | app.current_session_id = None; |
| 270 | captures.insert( |
| 271 | "workflow_no_session".into(), |
| 272 | dispatch(&mut app, "/structcopy workflow golden-run stdout"), |
| 273 | ); |
| 274 | let temp2 = TempDir::new().unwrap(); |
| 275 | let mut empty = self::app(&temp2); |
| 276 | captures.insert( |
| 277 | "empty_plan".into(), |
| 278 | dispatch(&mut empty, "/structcopy plan stdout"), |
| 279 | ); |
| 280 | captures.insert( |
| 281 | "empty_turn".into(), |
| 282 | dispatch(&mut empty, "/structcopy turn 1 stdout"), |
| 283 | ); |
| 284 | let info = super::get_command_info("structcopy").unwrap(); |
| 285 | use super::traits::CommandGroup; |
| 286 | captures.insert("metadata".into(),json!({ |
| 287 | "name":info.name,"aliases":info.aliases,"usage":info.usage, |
| 288 | "description_en":tr(Locale::En,info.description_id), |
| 289 | "description_zh_hans":tr(Locale::ZhHans,info.description_id), |
| 290 | "session_order":super::session_group::SessionCommands.commands().iter().map(|c|c.info().name).collect::<Vec<_>>(), |
| 291 | "native_model_tool":crate::core::engine::default_active_native_tool_names().contains(&"structcopy"), |
| 292 | })); |
| 293 | Value::Object(captures) |
| 294 | } |
| 295 | |
| 296 | #[test] |
| 297 | fn structcopy_public_workflow_matches_frozen_baseline() { |
| 298 | let mut expected: Value = serde_json::from_str( |
| 299 | &std::fs::read_to_string(concat!( |
| 300 | env!("CARGO_MANIFEST_DIR"), |
| 301 | "/src/commands/fixtures/structcopy_baseline.json" |
| 302 | )) |
| 303 | .expect("frozen baseline fixture"), |
| 304 | ) |
| 305 | .unwrap(); |
| 306 | // Keep the original capture intact. Upstream's execution-identity change |
| 307 | // supersedes exactly its duplicate-last observation with safe refusal. |
| 308 | expected["tool_duplicate_last"] = expected["server_tool_unavailable"].clone(); |
| 309 | // The integrated localization audit changed these four Chinese strings. |
| 310 | // Preserve the captured payloads and English receipts byte-for-byte. |
| 311 | for key in ["zh_hans_error_7", "zh_hans_native", "zh_hans_queued"] { |
| 312 | expected[key]["message"] = Value::String( |
| 313 | expected[key]["message"] |
| 314 | .as_str() |
| 315 | .unwrap() |
| 316 | .replace("轮次", "回合"), |
| 317 | ); |
| 318 | } |
| 319 | expected["metadata"]["description_zh_hans"] = |
| 320 | Value::String("将一个会话对象复制为已脱敏的 JSON;不是模型工具".into()); |
| 321 | assert_eq!( |
| 322 | baseline_observations(), |
| 323 | expected, |
| 324 | "observable structcopy behavior changed" |
| 325 | ); |
| 326 | } |
| 327 | |
| 328 | #[test] |
| 329 | fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_crossing() { |
| 330 | use super::groups::session::StructcopyRegistration; |
| 331 | use codewhale_command_contract::facets::*; |
| 332 | use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler, ContextParts}; |
| 333 | use codewhale_command_contract::metadata::RegisterCommand; |
| 334 | let CommandHandler::Contextual { capabilities, .. } = StructcopyRegistration::handler() else { |
| 335 | panic!("contract registration required"); |
| 336 | }; |
| 337 | assert_eq!( |
| 338 | capabilities, |
| 339 | CommandCapabilities::SESSION_STRUCTCOPY.union(CommandCapabilities::PRESENTATION) |
| 340 | ); |
| 341 | let tmp = TempDir::new().unwrap(); |
| 342 | let mut app = app(&tmp); |
| 343 | seed(&mut app); |
| 344 | let mut bundle = app.command_contexts(); |
| 345 | let ContextParts { |
| 346 | structcopy, |
| 347 | presentation, |
| 348 | session, |
| 349 | model, |
| 350 | cost, |
| 351 | mode_policy, |
| 352 | system_prompt, |
| 353 | skills, |
| 354 | workspace, |
| 355 | media, |
| 356 | memory, |
| 357 | project, |
| 358 | skill_group, |
| 359 | plugin, |
| 360 | lifecycle, |
| 361 | control, |
| 362 | export, |
| 363 | debug_receipts, |
| 364 | debug_change, |
| 365 | debug_history, |
| 366 | debug_diff, |
| 367 | debug_undo, |
| 368 | debug_diagnostics, |
| 369 | } = bundle.contexts(capabilities).into_parts(); |
| 370 | assert!(presentation.is_some()); |
| 371 | assert!( |
| 372 | session.is_none() |
| 373 | && model.is_none() |
| 374 | && cost.is_none() |
| 375 | && mode_policy.is_none() |
| 376 | && system_prompt.is_none() |
| 377 | && skills.is_none() |
| 378 | && workspace.is_none() |
| 379 | && media.is_none() |
| 380 | && memory.is_none() |
| 381 | && project.is_none() |
| 382 | && skill_group.is_none() |
| 383 | && plugin.is_none() |
| 384 | && lifecycle.is_none() |
| 385 | && control.is_none() |
| 386 | && export.is_none() |
| 387 | && debug_receipts.is_none() |
| 388 | && debug_change.is_none() |
| 389 | && debug_history.is_none() |
| 390 | && debug_diff.is_none() |
| 391 | && debug_undo.is_none() |
| 392 | && debug_diagnostics.is_none() |
| 393 | ); |
| 394 | let copy = structcopy.unwrap(); |
| 395 | assert_eq!(copy.transcript_item(0), Err(StructcopyError::Unavailable)); |
| 396 | assert_eq!( |
| 397 | copy.transcript_item(1).unwrap().content, |
| 398 | StructcopyContent::InternalContext |
| 399 | ); |
| 400 | let visible = copy.transcript_item(2).unwrap(); |
| 401 | let debug = format!("{visible:?}"); |
| 402 | for forbidden in [ |
| 403 | "hidden reasoning", |
| 404 | "hidden-signature", |
| 405 | "private-call-signature", |
| 406 | "private-base64", |
| 407 | "data:image", |
| 408 | ] { |
| 409 | assert!(!debug.contains(forbidden), "{debug}"); |
| 410 | } |
| 411 | let StructcopyContent::Visible(blocks) = visible.content else { |
| 412 | panic!("visible blocks") |
| 413 | }; |
| 414 | assert!(blocks.contains(&StructcopyBlock::ThinkingOmitted)); |
| 415 | assert!(blocks.contains(&StructcopyBlock::ImageOmitted)); |
| 416 | assert_eq!( |
| 417 | copy.tool_pair("call-golden"), |
| 418 | Err(StructcopyError::Unavailable) |
| 419 | ); |
| 420 | assert_eq!( |
| 421 | copy.tool_pair("server-only"), |
| 422 | Err(StructcopyError::Unavailable) |
| 423 | ); |
| 424 | assert_eq!( |
| 425 | copy.plan_snapshot().unwrap().title.as_deref(), |
| 426 | Some("Golden plan") |
| 427 | ); |
| 428 | } |
| 429 | |
| 430 | #[test] |
| 431 | fn structcopy_public_workflow_preserves_payload_across_locales_and_transports() { |
| 432 | use codewhale_localization::MessageId; |
| 433 | let temp = TempDir::new().unwrap(); |
| 434 | let mut app = app(&temp); |
| 435 | seed(&mut app); |
| 436 | // This successful transport matrix needs a unique tool pair. Duplicate |
| 437 | // identities are exercised by the separate refusal regressions. |
| 438 | app.api_messages_mut()[2].content.clear(); |
| 439 | crate::tools::workflow::structcopy_test_seed_run( |
| 440 | temp.path(), |
| 441 | "transport-run", |
| 442 | "structcopy-baseline-session", |
| 443 | ); |
| 444 | for (selector, kind) in [ |
| 445 | ("turn 2", MessageId::CmdStructcopyKindTurn), |
| 446 | ("tool call-golden", MessageId::CmdStructcopyKindTool), |
| 447 | ("plan", MessageId::CmdStructcopyKindPlan), |
| 448 | ( |
| 449 | "workflow transport-run", |
| 450 | MessageId::CmdStructcopyKindWorkflow, |
| 451 | ), |
| 452 | ] { |
| 453 | let command = format!("/structcopy {selector}"); |
| 454 | let expected = dispatch(&mut app, &format!("{command} stdout")); |
| 455 | assert_eq!(expected["is_error"], false); |
| 456 | let payload = expected["message"].as_str().unwrap(); |
| 457 | assert_eq!(expected["clipboard"], Value::Null); |
| 458 | for &locale in Locale::shipped() { |
| 459 | app.ui_locale = locale; |
| 460 | let stdout = dispatch(&mut app, &format!("{command} stdout")); |
| 461 | assert_eq!(stdout["message"], payload); |
| 462 | assert_eq!(stdout["clipboard"], Value::Null); |
| 463 | for (clipboard, id) in [ |
| 464 | ( |
| 465 | ClipboardHandler::new(), |
| 466 | MessageId::CmdStructcopyClipboardAccepted, |
| 467 | ), |
| 468 | ( |
| 469 | ClipboardHandler::terminal_only_for_test(), |
| 470 | MessageId::CmdStructcopyClipboardQueued, |
| 471 | ), |
| 472 | ] { |
| 473 | app.clipboard = clipboard; |
| 474 | let before_messages = app.api_messages.clone(); |
| 475 | let before_plan = app.plan_state.try_lock().unwrap().snapshot(); |
| 476 | let before_session = app.current_session_id.clone(); |
| 477 | let result = execute(&command, &mut app); |
| 478 | assert!(!result.is_error); |
| 479 | assert!(result.action.is_none()); |
| 480 | assert_eq!( |
| 481 | result.message.as_deref(), |
| 482 | Some( |
| 483 | tr(locale, id) |
| 484 | .replace("{kind}", &tr(locale, kind)) |
| 485 | .replace("{bytes}", &payload.len().to_string()) |
| 486 | .as_str() |
| 487 | ) |
| 488 | ); |
| 489 | if id == MessageId::CmdStructcopyClipboardAccepted { |
| 490 | assert_eq!(app.clipboard.last_written_text(), Some(payload)); |
| 491 | } else { |
| 492 | // Terminal writes are queued; this accessor observes native writes only. |
| 493 | assert!(app.clipboard.last_written_text().is_none()); |
| 494 | } |
| 495 | assert_eq!(app.api_messages, before_messages); |
| 496 | assert_eq!(app.plan_state.try_lock().unwrap().snapshot(), before_plan); |
| 497 | assert_eq!(app.current_session_id, before_session); |
| 498 | } |
| 499 | } |
| 500 | } |
| 501 | } |
| 502 |