返回 CodeWhale
session_structcopy_host_tests.rs
根目录 / crates / tui / src / commands / session_structcopy_host_tests.rs
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
502 lines RUST