返回 CodeWhale
tests.rs
1 use std::cell::RefCell;
2 use std::path::{Path, PathBuf};
3
4 use codewhale_protocol::request::{ContentBlock, Message, SystemPrompt};
5 use codewhale_protocol::role::Role;
6
7 use crate::*;
8
9 struct Session;
10 impl CommandSessionContext for Session {
11 fn session_id(&self) -> Option<String> {
12 Some("session".into())
13 }
14 fn api_messages(&self) -> Vec<Message> {
15 vec![]
16 }
17 fn add_message(&mut self, _message: Message) {}
18 fn queued_message_count(&self) -> usize {
19 0
20 }
21 fn remove_queued_message(&mut self, _index: usize) -> Result<(), String> {
22 Ok(())
23 }
24 fn total_tokens(&self) -> u64 {
25 42
26 }
27 }
28
29 struct Model;
30 impl CommandModelContext for Model {
31 fn current_model(&self) -> String {
32 "auto".into()
33 }
34 fn auto_model(&self) -> bool {
35 true
36 }
37 fn set_model_selection(&mut self, _model: String, _provider: Option<CommandProviderId>) {}
38 fn provider_identity(&self) -> Option<CommandProviderId> {
39 None
40 }
41 fn fallback_chain(&self) -> Vec<CommandProviderId> {
42 vec![]
43 }
44 }
45
46 struct Cost;
47 impl CommandCostContext for Cost {
48 fn display_currency(&self) -> CommandCurrency {
49 CommandCurrency::Usd
50 }
51 fn session_cost_for_currency(&self, _currency: CommandCurrency) -> f64 {
52 1.0
53 }
54 fn subagent_cost_for_currency(&self, _currency: CommandCurrency) -> f64 {
55 0.5
56 }
57 fn accrue_cost_estimate(&mut self, _amount: f64, _currency: CommandCurrency) {}
58 fn record_turn_cost(
59 &mut self,
60 _amount: f64,
61 _currency: CommandCurrency,
62 _receipt: Option<String>,
63 ) {
64 }
65 }
66
67 struct Policy;
68 impl CommandModePolicyContext for Policy {
69 fn mode(&self) -> CommandMode {
70 CommandMode::Plan
71 }
72 fn set_mode(&mut self, _mode: CommandMode) {}
73 fn approval_mode(&self) -> CommandApprovalMode {
74 CommandApprovalMode::Suggest
75 }
76 fn allow_shell(&self) -> bool {
77 false
78 }
79 fn set_shell_access(&mut self, _allow: bool) {}
80 fn policy_locked(&self) -> bool {
81 false
82 }
83 }
84
85 struct Prompt;
86 impl CommandSystemPromptContext for Prompt {
87 fn system_prompt(&self) -> Option<SystemPrompt> {
88 None
89 }
90 }
91
92 struct Skills;
93 impl CommandSkillsContext for Skills {
94 fn active_skill(&self) -> Option<String> {
95 None
96 }
97 fn active_skill_provenance(&self) -> Option<String> {
98 None
99 }
100 fn refresh_skill_cache(&mut self) {}
101 }
102
103 struct Workspace;
104 impl CommandWorkspaceContext for Workspace {
105 fn workspace(&self) -> PathBuf {
106 PathBuf::from(".")
107 }
108 fn work_state_snapshot(&self) -> Result<Option<String>, String> {
109 Ok(None)
110 }
111 fn operation_digest(&mut self) -> Result<String, String> {
112 Ok("No active operations or to-do items.".to_string())
113 }
114 }
115
116 #[test]
117 fn all_seven_shapes_are_object_safe() {
118 fn session(_: &dyn CommandSessionContext) {}
119 fn model(_: &dyn CommandModelContext) {}
120 fn cost(_: &dyn CommandCostContext) {}
121 fn policy(_: &dyn CommandModePolicyContext) {}
122 fn prompt(_: &dyn CommandSystemPromptContext) {}
123 fn skills(_: &dyn CommandSkillsContext) {}
124 fn workspace(_: &dyn CommandWorkspaceContext) {}
125
126 session(&Session);
127 model(&Model);
128 cost(&Cost);
129 policy(&Policy);
130 prompt(&Prompt);
131 skills(&Skills);
132 workspace(&Workspace);
133 }
134
135 #[test]
136 fn envelope_carries_independent_facets() {
137 let mut session = Session;
138 let mut model = Model;
139 let parts = CommandContexts::empty()
140 .with_session(&mut session)
141 .with_model(&mut model)
142 .into_parts();
143 assert_eq!(parts.session.expect("session").total_tokens(), 42);
144 assert!(parts.model.expect("model").auto_model());
145 assert!(parts.cost.is_none());
146 }
147
148 struct DebugDiagnostics;
149 impl CommandDebugDiagnosticsContext for DebugDiagnostics {
150 fn balance_projection(&self) -> DebugBalanceProjection {
151 DebugBalanceProjection {
152 provider_display_name: "Example".into(),
153 supports_balance_api: false,
154 }
155 }
156 fn system_projection(&self) -> DebugSystemProjection {
157 DebugSystemProjection {
158 mode_label: "plan".into(),
159 prompt: DebugSystemPrompt::Blocks(vec!["first".into(), "second".into()]),
160 }
161 }
162 fn token_projection(&self) -> DebugTokenProjection {
163 DebugTokenProjection {
164 active_context_used: 0,
165 context_window: 8192,
166 last_input: None,
167 last_output: Some(0),
168 cache_hit: None,
169 cache_miss: Some(0),
170 total_tokens: 0,
171 cache_write_tokens: 0,
172 api_message_count: 0,
173 chat_message_count: 0,
174 model: "example".into(),
175 cost: self.cost_projection(),
176 }
177 }
178 fn cost_projection(&self) -> DebugCostProjection {
179 DebugCostProjection {
180 currency: CommandCurrency::Usd,
181 total: 0.0,
182 parent_turns: 0.0,
183 subagents: 0.0,
184 display_floor: 0.0,
185 priced_turns: 0,
186 unpriced_turns: 0,
187 legacy_coverage_unknown: false,
188 user_declared_estimates: false,
189 itemized_turns: 0,
190 route_amounts: vec![],
191 turn_history_capacity: 10,
192 unpriced_reason_labels: vec![],
193 unpriced_classes: vec![],
194 pricing_provenances: vec![],
195 live_pricing_defects: vec![],
196 unusable_pricing_defects: vec![],
197 route_receipts: vec![],
198 }
199 }
200 fn cache_telemetry(&self) -> DebugCacheTelemetry {
201 DebugCacheTelemetry {
202 model: "example".into(),
203 session_cache_rates: DebugCacheRates::default(),
204 history: vec![],
205 history_capacity: 50,
206 prefix_stability_pct: None,
207 prefix_checks_total: 0,
208 prefix_change_count: 0,
209 prefix_drift_count: 0,
210 prefix_context_updates: 0,
211 prefix_pin_reason: None,
212 prefix_last_miss_reason: None,
213 last_prefix_change_desc: None,
214 last_pinned_prefix_hash: None,
215 api_message_count: 0,
216 non_system_message_count: 0,
217 }
218 }
219 fn context_source_map(&self) -> DebugPromptSourceMap {
220 DebugPromptSourceMap {
221 entries: vec![],
222 total_estimated_tokens: 0,
223 active_context_estimated_tokens: 0,
224 overflow_guard_estimated_tokens: None,
225 context_window_tokens: None,
226 context_window_source: None,
227 budget_used_percent: None,
228 pressure_label: "unknown".into(),
229 context_window_verified: false,
230 generated_at: "2026-01-01T00:00:00Z".into(),
231 note: String::new(),
232 }
233 }
234 fn prompt_context(&self) -> DebugPromptContext {
235 DebugPromptContext {
236 schema_version: 1,
237 provider: "example".into(),
238 model: "model".into(),
239 system_prompt_state: "unknown".into(),
240 tool_catalog_state: "absent".into(),
241 sections: vec![],
242 tools: vec![],
243 source_map: self.context_source_map(),
244 }
245 }
246 fn tool_snapshot(&self) -> Option<DebugToolSnapshot> {
247 None
248 }
249 fn inspect_cache(
250 &self,
251 ) -> Result<DebugCacheInspectionObservation, DebugCacheInspectionUnavailable> {
252 Err(DebugCacheInspectionUnavailable::NoConcreteRoute)
253 }
254 fn remember_cache_inspection(&mut self, _inspection: DebugPromptInspection) {}
255 }
256
257 #[test]
258 fn debug_inspection_schema_preserves_order_and_absence() {
259 let inspection = DebugPromptInspection {
260 base_static_prefix_hash: "base".into(),
261 full_request_prefix_hash: "full".into(),
262 tool_catalog_hash: "".into(),
263 layers: vec![DebugPromptLayer {
264 name: "history".into(),
265 stability: DebugPromptLayerStability::History,
266 char_len: 0,
267 byte_len: 0,
268 token_estimate: 0,
269 sha256: "digest".into(),
270 tool_result: None,
271 turn_meta: None,
272 }],
273 };
274 assert_eq!(
275 serde_json::to_string(&inspection).expect("structured inspection"),
276 r#"{"base_static_prefix_hash":"base","full_request_prefix_hash":"full","tool_catalog_hash":"","layers":[{"name":"history","stability":"History","char_len":0,"byte_len":0,"token_estimate":0,"sha256":"digest","tool_result":null,"turn_meta":null}]}"#,
277 );
278 assert_eq!(DebugPromptLayerStability::Static.label(), "static");
279 assert_eq!(DebugPromptLayerStability::Dynamic.label(), "dynamic");
280 assert_ne!(
281 DebugCacheInspectionUnavailable::NoConcreteRoute,
282 DebugCacheInspectionUnavailable::MissingCapturedEndpoint,
283 );
284 let key = DebugWarmupKey {
285 provider: "provider".into(),
286 model: "model".into(),
287 base_url: "local".into(),
288 static_prefix_hash: "static".into(),
289 tool_catalog_hash: "".into(),
290 project_pack_hash: "".into(),
291 skills_hash: "".into(),
292 };
293 assert_eq!(
294 serde_json::to_string(&key).expect("structured key"),
295 r#"{"provider":"provider","model":"model","base_url":"local","static_prefix_hash":"static","tool_catalog_hash":"","project_pack_hash":"","skills_hash":""}"#,
296 );
297 assert_eq!(
298 DebugDiagnostics.inspect_cache(),
299 Err(DebugCacheInspectionUnavailable::NoConcreteRoute)
300 );
301 }
302
303 #[test]
304 fn debug_context_schema_preserves_explicit_nulls_and_omits_optional_tool_fields() {
305 let mut context = DebugDiagnostics.prompt_context();
306 context.tools.push(DebugPromptTool {
307 tool_type: None,
308 name: "search".into(),
309 description: "Search".into(),
310 input_schema: serde_json::json!({"type": "object"}),
311 allowed_callers: None,
312 defer_loading: Some(false),
313 input_examples: None,
314 strict: None,
315 cache_control: None,
316 });
317 let value = serde_json::to_value(&context).expect("semantic prompt context");
318 assert_eq!(
319 value["source_map"]["context_window_tokens"],
320 serde_json::Value::Null
321 );
322 assert_eq!(value["system_prompt_state"], "unknown");
323 assert_eq!(value["tool_catalog_state"], "absent");
324 assert_eq!(value["tools"][0]["defer_loading"], false);
325 assert!(value["tools"][0].get("type").is_none());
326 assert!(value["tools"][0].get("input_examples").is_none());
327 assert_eq!(
328 serde_json::to_value(DebugSourceKind::ProjectContextWarning).unwrap(),
329 "project_context_warning"
330 );
331 assert_eq!(
332 serde_json::to_value(DebugActivationReason::PerRequest).unwrap(),
333 "per_request"
334 );
335 }
336
337 #[test]
338 fn debug_tool_schema_keeps_unknown_distinct_from_known_empty() {
339 let unknown: DebugEvidence<DebugBoundedList> = DebugEvidence::Unknown {
340 reason: "no surface".into(),
341 };
342 let empty = DebugEvidence::Known {
343 value: DebugBoundedList {
344 count: 0,
345 rendered: vec![],
346 omitted: 0,
347 },
348 };
349 assert_eq!(
350 serde_json::to_value(unknown).unwrap(),
351 serde_json::json!({"status":"unknown", "reason":"no surface"}),
352 );
353 assert_eq!(
354 serde_json::to_value(empty).unwrap(),
355 serde_json::json!({"status":"known", "value":{"count":0,"rendered":[],"omitted":0}}),
356 );
357 assert_eq!(
358 serde_json::to_value(DebugProviderAvailability::Unknown).unwrap(),
359 serde_json::json!({"status":"unknown"}),
360 );
361 assert_eq!(
362 serde_json::to_value(DebugToolVisibility::InRequest).unwrap(),
363 "in_request"
364 );
365 }
366
367 #[test]
368 fn debug_tool_snapshot_schema_preserves_unobserved_and_absent_states() {
369 let snapshot = DebugToolSnapshot {
370 schema_version: 1,
371 capture_source: "prepared model-client request".into(),
372 delivery_status: "unknown (capture does not prove provider delivery)".into(),
373 turn_id: DebugBoundedString {
374 value: "turn".into(),
375 truncated: false,
376 },
377 step: 0,
378 terminal: None,
379 tools_field_present: false,
380 tool_count: 0,
381 rendered_tool_count: 0,
382 omitted_tool_count: 0,
383 payload_json_bytes: None,
384 payload_measurement_status: "unavailable".into(),
385 active_tool_catalog_sha256: None,
386 unavailable_for_this_request: vec!["provider_wire_payload".into()],
387 provider: DebugProviderAvailability::Unknown,
388 registry_facts_present: false,
389 registry_tool_count: DebugEvidence::Unknown {
390 reason: "not captured".into(),
391 },
392 registry_only_tools: DebugEvidence::Unknown {
393 reason: "not captured".into(),
394 },
395 tools: vec![],
396 };
397 let value = serde_json::to_value(&snapshot).expect("bounded snapshot");
398 assert!(
399 value.get("terminal").is_none(),
400 "absent terminal is omitted"
401 );
402 assert!(
403 value["payload_json_bytes"].is_null(),
404 "unmeasured is not zero"
405 );
406 assert!(value["active_tool_catalog_sha256"].is_null());
407 assert_eq!(value["registry_tool_count"]["status"], "unknown");
408 assert_eq!(value["provider"]["status"], "unknown");
409 assert_eq!(
410 value["unavailable_for_this_request"],
411 serde_json::json!(["provider_wire_payload"])
412 );
413 assert_eq!(snapshot.tools, vec![]);
414 }
415
416 #[test]
417 fn debug_cache_observation_keeps_current_and_previous_distinct() {
418 let previous = DebugPromptInspection {
419 base_static_prefix_hash: "before".into(),
420 full_request_prefix_hash: "before".into(),
421 tool_catalog_hash: "".into(),
422 layers: vec![],
423 };
424 let mut current = previous.clone();
425 current.base_static_prefix_hash = "after".into();
426 let observation = DebugCacheInspectionObservation {
427 current: current.clone(),
428 previous: Some(previous),
429 current_warmup_key: DebugWarmupKey {
430 provider: "provider".into(),
431 model: "model".into(),
432 base_url: "endpoint".into(),
433 static_prefix_hash: "after".into(),
434 tool_catalog_hash: "".into(),
435 project_pack_hash: "".into(),
436 skills_hash: "".into(),
437 },
438 last_warmup_key: None,
439 current_warmup_hash_short: "digest".into(),
440 last_warmup_hash_short: None,
441 };
442 assert_eq!(
443 observation
444 .previous
445 .as_ref()
446 .unwrap()
447 .base_static_prefix_hash,
448 "before"
449 );
450 assert_eq!(observation.current.base_static_prefix_hash, "after");
451 assert_ne!(observation.previous.as_ref(), Some(&observation.current));
452 // Contract data alone cannot prove a host write; Phase 3 adapter tests
453 // must assert that the synchronous post-render commit stores `current`.
454 }
455
456 #[test]
457 fn debug_diagnostics_facet_is_object_safe_and_independently_transportable() {
458 fn object_safe(_: &dyn CommandDebugDiagnosticsContext) {}
459 object_safe(&DebugDiagnostics);
460
461 let mut diagnostics = DebugDiagnostics;
462 let parts = CommandContexts::empty()
463 .with_debug_diagnostics(&mut diagnostics)
464 .into_parts();
465 assert_eq!(
466 parts
467 .debug_diagnostics
468 .expect("declared diagnostics facet")
469 .balance_projection(),
470 DebugBalanceProjection {
471 provider_display_name: "Example".into(),
472 supports_balance_api: false,
473 }
474 );
475 assert_eq!(
476 DebugDiagnostics.system_projection(),
477 DebugSystemProjection {
478 mode_label: "plan".into(),
479 prompt: DebugSystemPrompt::Blocks(vec!["first".into(), "second".into()]),
480 }
481 );
482 assert_ne!(
483 DebugSystemPrompt::None,
484 DebugSystemPrompt::Text(String::new())
485 );
486 assert_ne!(DebugSystemPrompt::Blocks(vec![]), DebugSystemPrompt::None);
487 let usage = DebugDiagnostics.token_projection();
488 assert_eq!(
489 DebugDiagnostics.cache_telemetry().prefix_stability_pct,
490 None
491 );
492 assert_eq!(
493 DebugDiagnostics.prompt_context().tool_catalog_state,
494 "absent"
495 );
496 assert!(DebugDiagnostics.tool_snapshot().is_none());
497 assert_eq!(usage.last_input, None);
498 assert_eq!(usage.last_output, Some(0));
499 assert_eq!(usage.cache_miss, Some(0));
500 assert_eq!(usage.cost, DebugDiagnostics.cost_projection());
501 for absent in [
502 parts.session.is_none(),
503 parts.model.is_none(),
504 parts.cost.is_none(),
505 parts.mode_policy.is_none(),
506 parts.system_prompt.is_none(),
507 parts.skills.is_none(),
508 parts.workspace.is_none(),
509 parts.presentation.is_none(),
510 parts.media.is_none(),
511 parts.memory.is_none(),
512 parts.project.is_none(),
513 parts.skill_group.is_none(),
514 parts.plugin.is_none(),
515 parts.lifecycle.is_none(),
516 parts.control.is_none(),
517 parts.export.is_none(),
518 ] {
519 assert!(absent, "diagnostics must not expose another facet");
520 }
521 assert!(
522 CommandContexts::empty()
523 .into_parts()
524 .debug_diagnostics
525 .is_none()
526 );
527 }
528
529 #[test]
530 #[should_panic(expected = "debug diagnostics facet already set")]
531 fn debug_diagnostics_envelope_rejects_duplicate_authority() {
532 let mut first = DebugDiagnostics;
533 let mut second = DebugDiagnostics;
534 let _ = CommandContexts::empty()
535 .with_debug_diagnostics(&mut first)
536 .with_debug_diagnostics(&mut second);
537 }
538
539 fn pure(value: Option<&str>) -> String {
540 value.unwrap_or_default().to_owned()
541 }
542 fn contextual(_contexts: CommandContexts<'_>, value: Option<&str>) -> String {
543 value.unwrap_or_default().to_owned()
544 }
545
546 #[test]
547 fn handlers_are_plain_function_pointers() {
548 let pure_handler = CommandHandler::Pure(pure);
549 let contextual_handler = CommandHandler::Contextual {
550 capabilities: CommandCapabilities::NONE,
551 handler: contextual,
552 };
553 match pure_handler {
554 CommandHandler::Pure(handler) => assert_eq!(handler(Some("x")), "x"),
555 _ => unreachable!(),
556 }
557 match contextual_handler {
558 CommandHandler::Contextual {
559 capabilities,
560 handler,
561 } => {
562 assert!(capabilities.is_empty());
563 assert_eq!(handler(CommandContexts::empty(), Some("y")), "y")
564 }
565 _ => unreachable!(),
566 }
567 }
568
569 struct Sample;
570 impl RegisterCommand<String> for Sample {
571 fn info() -> &'static CommandInfo {
572 static INFO: CommandInfo = CommandInfo {
573 name: "sample",
574 aliases: &["s"],
575 usage: "/sample",
576 description_key: "command.sample",
577 };
578 &INFO
579 }
580 fn handler() -> CommandHandler<String> {
581 CommandHandler::Pure(pure)
582 }
583 }
584
585 #[test]
586 fn registration_shape_has_no_app_dependency() {
587 assert_eq!(Sample::info().name, "sample");
588 assert!(matches!(Sample::handler(), CommandHandler::Pure(_)));
589 }
590
591 // ---------------------------------------------------------------------------
592 // FEAT-018: presentation, media, and digest capabilities (D2-D5)
593 // ---------------------------------------------------------------------------
594
595 struct Presentation;
596 impl CommandPresentationContext for Presentation {
597 fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
598 if key == "automation_usage" {
599 return Ok("Usage: /automation [list|show <id>]".to_string());
600 }
601 if key == "mcp_recommended_unknown_id" {
602 let command = replacements
603 .iter()
604 .find(|(name, _)| *name == "recommendations_command")
605 .map(|(_, value)| *value)
606 .unwrap_or("/mcp recommendations");
607 return Ok(format!("Unknown recommended MCP ID (try {command})"));
608 }
609 // D3: unknown keys fail safely without echoing the raw lookup key.
610 Err("unknown translation key".to_string())
611 }
612 }
613
614 struct Media;
615 impl CommandMediaContext for Media {
616 fn attach_media(&mut self, path: &Path) -> Result<MediaAttachmentReceipt, String> {
617 if path.extension().and_then(|ext| ext.to_str()) == Some("png") {
618 Ok(MediaAttachmentReceipt {
619 kind: "image".to_string(),
620 path: path.to_path_buf(),
621 })
622 } else {
623 Err("Unsupported attachment type".to_string())
624 }
625 }
626 }
627
628 struct DigestWorkspace;
629 impl CommandWorkspaceContext for DigestWorkspace {
630 fn workspace(&self) -> PathBuf {
631 PathBuf::from(".")
632 }
633 fn work_state_snapshot(&self) -> Result<Option<String>, String> {
634 Ok(None)
635 }
636 fn operation_digest(&mut self) -> Result<String, String> {
637 Ok("No active operations or to-do items.".to_string())
638 }
639 }
640
641 #[test]
642 fn new_capabilities_are_object_safe_and_independently_transportable() {
643 fn presentation(_: &dyn CommandPresentationContext) {}
644 fn media(_: &dyn CommandMediaContext) {}
645 fn digest_workspace(_: &dyn CommandWorkspaceContext) {}
646
647 presentation(&Presentation);
648 media(&Media);
649 digest_workspace(&DigestWorkspace);
650 fn export(_: &dyn CommandSessionExportContext) {}
651 export(&FakeExport::default());
652
653 let mut presentation = Presentation;
654 let mut media = Media;
655 let mut export = FakeExport::default();
656 let parts = CommandContexts::empty()
657 .with_presentation(&mut presentation)
658 .with_media(&mut media)
659 .with_export(&mut export)
660 .into_parts();
661 assert!(parts.presentation.is_some());
662 assert!(parts.media.is_some());
663 assert!(parts.export.is_some());
664 assert!(parts.session.is_none());
665 }
666
667 #[test]
668 fn translation_contract_resolves_known_keys_and_fails_safely() {
669 let presentation = Presentation;
670 assert_eq!(
671 presentation
672 .translate("automation_usage", &[])
673 .expect("known key"),
674 "Usage: /automation [list|show <id>]"
675 );
676 assert_eq!(
677 presentation
678 .translate(
679 "mcp_recommended_unknown_id",
680 &[("recommendations_command", "/mcp recommendations")],
681 )
682 .expect("known key with named replacement"),
683 "Unknown recommended MCP ID (try /mcp recommendations)"
684 );
685 let unknown = presentation.translate("no_such_key", &[]);
686 assert!(unknown.is_err(), "unknown key must fail safely");
687 let err = unknown.unwrap_err();
688 assert!(
689 !err.contains("no_such_key"),
690 "no raw lookup key exposure (D3)"
691 );
692 }
693
694 #[test]
695 fn media_contract_is_atomic_and_returns_only_portable_data() {
696 let mut media = Media;
697 let ok = media
698 .attach_media(Path::new("/tmp/photo.png"))
699 .expect("png");
700 assert_eq!(ok.kind, "image");
701 assert_eq!(ok.path, PathBuf::from("/tmp/photo.png"));
702
703 let err = media.attach_media(Path::new("/tmp/notes.txt")).unwrap_err();
704 assert!(!err.is_empty(), "safe error string");
705 }
706
707 #[test]
708 fn digest_operation_returns_final_text_and_safe_errors() {
709 let mut workspace = DigestWorkspace;
710 assert_eq!(
711 workspace.operation_digest().expect("digest"),
712 "No active operations or to-do items."
713 );
714 }
715
716 #[test]
717 fn envelope_rejects_duplicate_new_slots_deterministically() {
718 struct SecondPresentation;
719 impl CommandPresentationContext for SecondPresentation {
720 fn translate(&self, _key: &str, _r: &[(&str, &str)]) -> Result<String, String> {
721 Ok(String::new())
722 }
723 }
724 struct SecondMedia;
725 impl CommandMediaContext for SecondMedia {
726 fn attach_media(&mut self, _p: &Path) -> Result<MediaAttachmentReceipt, String> {
727 Err("unused".to_string())
728 }
729 }
730
731 let mut a = Presentation;
732 let mut b = SecondPresentation;
733 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
734 CommandContexts::empty()
735 .with_presentation(&mut a)
736 .with_presentation(&mut b);
737 }));
738 assert!(result.is_err(), "duplicate presentation slot must assert");
739
740 let mut a = Media;
741 let mut b = SecondMedia;
742 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
743 CommandContexts::empty()
744 .with_media(&mut a)
745 .with_media(&mut b);
746 }));
747 assert!(result.is_err(), "duplicate media slot must assert");
748 }
749
750 // ---------------------------------------------------------------------------
751 // Project facet (FEAT-021 D1/D4)
752 // ---------------------------------------------------------------------------
753
754 /// Deterministic fake project facet over portable values only.
755 struct FakeProject {
756 lsp_enabled: bool,
757 goal: ProjectGoalState,
758 }
759
760 impl FakeProject {
761 fn new() -> Self {
762 Self {
763 lsp_enabled: false,
764 goal: ProjectGoalState {
765 objective: Some("Ship FEAT-021".to_string()),
766 status: ProjectGoalStatus::Active,
767 pause_reason: None,
768 started_at_elapsed_seconds: Some(42),
769 time_used_seconds: 42,
770 token_budget: Some(50_000),
771 tokens_used: 1_000,
772 session_total_tokens: 2_000,
773 continuation_count: 3,
774 pending_controls: false,
775 last_known_objective: None,
776 last_known_status: None,
777 conversation_present: true,
778 is_loading: false,
779 goal_continuation_waiting: false,
780 },
781 }
782 }
783 }
784
785 impl CommandProjectContext for FakeProject {
786 fn lsp_enabled(&self) -> bool {
787 self.lsp_enabled
788 }
789
790 fn lsp_set(&mut self, enabled: bool) -> Result<(), String> {
791 self.lsp_enabled = enabled;
792 Ok(())
793 }
794
795 fn goal_state(&self) -> ProjectGoalState {
796 self.goal.clone()
797 }
798 }
799
800 // ---------------------------------------------------------------------------
801 // FEAT-019: memory capability, typed outcomes, and workspace scoping (D1-D9)
802 // ---------------------------------------------------------------------------
803
804 /// Deterministic fake memory facet over portable values only. Tracks the
805 /// workspace argument discipline (D8): only workspace-scoped methods receive
806 /// the workspace path.
807 struct FakeMemory {
808 hits: Vec<MemoryHit>,
809 remembered_result: Option<MemoryRemembered>,
810 workspace_id_result: Result<String, String>,
811 }
812
813 impl FakeMemory {
814 fn new() -> Self {
815 Self {
816 hits: vec![MemoryHit {
817 source: PathBuf::from("/mem/source.md"),
818 line_start: 3,
819 line_end: 5,
820 text: "reviewed note".to_string(),
821 }],
822 remembered_result: Some(MemoryRemembered {
823 source: PathBuf::from("/mem/global.md"),
824 line_start: 7,
825 }),
826 workspace_id_result: Ok("owner/repo".to_string()),
827 }
828 }
829 }
830
831 impl CommandMemoryContext for FakeMemory {
832 fn memory_path(&self) -> PathBuf {
833 PathBuf::from("/mem/user-memory.md")
834 }
835
836 fn memory_enabled(&self) -> bool {
837 true
838 }
839
840 fn status(&self) -> Result<MemoryStatus, String> {
841 Ok(MemoryStatus {
842 root: PathBuf::from("/mem/memory"),
843 source: PathBuf::from("/mem/memory/global/global.md"),
844 index: PathBuf::from("/mem/memory/index.db"),
845 })
846 }
847
848 fn path(&self) -> Result<PathBuf, String> {
849 Ok(PathBuf::from("/mem/memory"))
850 }
851
852 fn workspace_id(&self, _workspace: &Path) -> Result<String, String> {
853 self.workspace_id_result.clone()
854 }
855
856 fn search(
857 &self,
858 _workspace: &Path,
859 query: &str,
860 limit: usize,
861 ) -> Result<Vec<MemoryHit>, String> {
862 if query.is_empty() {
863 return Ok(Vec::new());
864 }
865 Ok(self.hits.iter().take(limit).cloned().collect())
866 }
867
868 fn remember(
869 &self,
870 _target: MemoryRememberTarget,
871 note: &str,
872 ) -> Result<MemoryRemembered, String> {
873 if note.is_empty() {
874 return Err("empty note".to_string());
875 }
876 Ok(self.remembered_result.clone().unwrap_or(MemoryRemembered {
877 source: PathBuf::from("/mem/global.md"),
878 line_start: 1,
879 }))
880 }
881
882 fn import(&self) -> Result<MemoryImportOutcome, String> {
883 Ok(MemoryImportOutcome::Skipped)
884 }
885
886 fn get(&self, _workspace: &Path, id: i64) -> Result<MemoryGetOutcome, String> {
887 if id == 42 {
888 Ok(MemoryGetOutcome::Found(self.hits[0].clone()))
889 } else {
890 Ok(MemoryGetOutcome::NotFound)
891 }
892 }
893
894 fn export(&self) -> Result<MemoryExport, String> {
895 Ok(MemoryExport {
896 content: "# memory\n\n- bullet".to_string(),
897 })
898 }
899
900 fn reindex(&self) -> Result<MemoryReindex, String> {
901 Ok(MemoryReindex { entry_count: 3 })
902 }
903
904 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String> {
905 match scope {
906 MemoryDeleteScope::All => Ok(MemoryDelete),
907 MemoryDeleteScope::Global => Ok(MemoryDelete),
908 }
909 }
910
911 fn delete_workspace(&self, _workspace: &Path) -> Result<MemoryDelete, String> {
912 Ok(MemoryDelete)
913 }
914 }
915
916 /// Recording fake that captures remember targets and delete scopes to prove
917 /// the typed target/scope discipline (D2/D8/D9). Interior mutability lets the
918 /// contract-level test assert exactly which operations the handler drives.
919 #[derive(Default)]
920 struct RecordingMemory {
921 remembered_targets: std::cell::RefCell<Vec<MemoryRememberTarget>>,
922 delete_scopes: std::cell::RefCell<Vec<String>>,
923 workspace_deletes: std::cell::Cell<usize>,
924 }
925
926 impl RecordingMemory {
927 fn new() -> Self {
928 Self::default()
929 }
930
931 fn recorded_targets(&self) -> Vec<MemoryRememberTarget> {
932 self.remembered_targets.borrow().clone()
933 }
934
935 fn recorded_delete_scopes(&self) -> Vec<String> {
936 self.delete_scopes.borrow().clone()
937 }
938
939 fn recorded_workspace_deletes(&self) -> usize {
940 self.workspace_deletes.get()
941 }
942 }
943
944 impl CommandMemoryContext for RecordingMemory {
945 fn memory_path(&self) -> PathBuf {
946 PathBuf::from("/mem/user-memory.md")
947 }
948
949 fn memory_enabled(&self) -> bool {
950 true
951 }
952
953 fn status(&self) -> Result<MemoryStatus, String> {
954 unreachable!("recording fake")
955 }
956
957 fn path(&self) -> Result<PathBuf, String> {
958 unreachable!("recording fake")
959 }
960
961 fn workspace_id(&self, _workspace: &Path) -> Result<String, String> {
962 Ok("owner/repo".to_string())
963 }
964
965 fn search(
966 &self,
967 _workspace: &Path,
968 _query: &str,
969 _limit: usize,
970 ) -> Result<Vec<MemoryHit>, String> {
971 unreachable!("recording fake")
972 }
973
974 fn remember(
975 &self,
976 target: MemoryRememberTarget,
977 _note: &str,
978 ) -> Result<MemoryRemembered, String> {
979 self.remembered_targets.borrow_mut().push(target);
980 Ok(MemoryRemembered {
981 source: PathBuf::from("/mem/global.md"),
982 line_start: 1,
983 })
984 }
985
986 fn import(&self) -> Result<MemoryImportOutcome, String> {
987 unreachable!("recording fake")
988 }
989
990 fn get(&self, _workspace: &Path, _id: i64) -> Result<MemoryGetOutcome, String> {
991 unreachable!("recording fake")
992 }
993
994 fn export(&self) -> Result<MemoryExport, String> {
995 unreachable!("recording fake")
996 }
997
998 fn reindex(&self) -> Result<MemoryReindex, String> {
999 unreachable!("recording fake")
1000 }
1001
1002 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String> {
1003 self.delete_scopes.borrow_mut().push(match scope {
1004 MemoryDeleteScope::All => "all".to_string(),
1005 MemoryDeleteScope::Global => "global".to_string(),
1006 });
1007 Ok(MemoryDelete)
1008 }
1009
1010 fn delete_workspace(&self, _workspace: &Path) -> Result<MemoryDelete, String> {
1011 self.workspace_deletes.set(self.workspace_deletes.get() + 1);
1012 Ok(MemoryDelete)
1013 }
1014 }
1015
1016 #[test]
1017 fn project_facet_is_object_safe_and_typed() {
1018 fn project(_: &dyn CommandProjectContext) {}
1019 project(&FakeProject::new());
1020
1021 let mut project = FakeProject::new();
1022 assert!(!project.lsp_enabled());
1023 project.lsp_set(true).unwrap();
1024 assert!(project.lsp_enabled());
1025 project.lsp_set(false).unwrap();
1026 assert!(!project.lsp_enabled());
1027 }
1028
1029 #[test]
1030 fn project_goal_state_preserves_semantic_values() {
1031 let project = FakeProject::new();
1032 let goal = project.goal_state();
1033 assert_eq!(goal.objective.as_deref(), Some("Ship FEAT-021"));
1034 assert_eq!(goal.status, ProjectGoalStatus::Active);
1035 assert_eq!(goal.pause_reason, None);
1036 assert_eq!(goal.started_at_elapsed_seconds, Some(42));
1037 assert_eq!(goal.time_used_seconds, 42);
1038 assert_eq!(goal.token_budget, Some(50_000));
1039 assert_eq!(goal.tokens_used, 1_000);
1040 assert_eq!(goal.session_total_tokens, 2_000);
1041 assert_eq!(goal.continuation_count, 3);
1042 assert!(!goal.pending_controls);
1043 assert_eq!(goal.last_known_objective, None);
1044 assert_eq!(goal.last_known_status, None);
1045 assert!(goal.conversation_present);
1046 assert!(!goal.is_loading);
1047 assert!(!goal.goal_continuation_waiting);
1048 }
1049
1050 #[test]
1051 fn project_goal_status_variants_are_distinguishable() {
1052 let paused = ProjectGoalState {
1053 status: ProjectGoalStatus::Paused,
1054 pause_reason: Some("user".to_string()),
1055 ..FakeProject::new().goal
1056 };
1057 assert_eq!(paused.status, ProjectGoalStatus::Paused);
1058 assert_eq!(paused.pause_reason.as_deref(), Some("user"));
1059
1060 let complete = ProjectGoalState {
1061 status: ProjectGoalStatus::Complete,
1062 ..paused
1063 };
1064 assert_eq!(complete.status, ProjectGoalStatus::Complete);
1065 assert_ne!(complete.status, ProjectGoalStatus::Blocked);
1066 }
1067
1068 #[test]
1069 fn project_facet_transports_through_envelope_when_declared() {
1070 let mut project = FakeProject::new();
1071 let parts = CommandContexts::empty()
1072 .with_project(&mut project)
1073 .into_parts();
1074 assert!(parts.project.is_some());
1075 assert!(parts.session.is_none());
1076
1077 // PROJECT combined with WORKSPACE (init) and PRESENTATION (goal).
1078 let mut workspace = Workspace;
1079 let parts = CommandContexts::empty()
1080 .with_project(&mut project)
1081 .with_workspace(&mut workspace)
1082 .into_parts();
1083 assert!(parts.project.is_some());
1084 assert!(parts.workspace.is_some());
1085 assert!(parts.presentation.is_none());
1086 }
1087
1088 #[test]
1089 fn envelope_rejects_duplicate_project_slot_deterministically() {
1090 let mut a = FakeProject::new();
1091 let mut b = FakeProject::new();
1092 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1093 CommandContexts::empty()
1094 .with_project(&mut a)
1095 .with_project(&mut b);
1096 }));
1097 assert!(result.is_err(), "duplicate project slot must assert");
1098 }
1099
1100 #[test]
1101 fn memory_facet_is_object_safe_and_typed() {
1102 fn memory(_: &dyn CommandMemoryContext) {}
1103 let fake = FakeMemory::new();
1104 memory(&fake);
1105
1106 assert_eq!(fake.memory_path(), PathBuf::from("/mem/user-memory.md"));
1107 assert!(fake.memory_enabled());
1108 let status = fake.status().expect("status");
1109 assert_eq!(status.root, PathBuf::from("/mem/memory"));
1110 assert_eq!(status.source, PathBuf::from("/mem/memory/global/global.md"));
1111 assert_eq!(status.index, PathBuf::from("/mem/memory/index.db"));
1112 }
1113
1114 #[test]
1115 fn memory_typed_results_preserve_semantic_distinctions() {
1116 let fake = FakeMemory::new();
1117
1118 // Search returns semantic hits, never preformatted messages.
1119 let hits = fake.search(Path::new("/ws"), "note", 10).expect("search");
1120 assert_eq!(hits.len(), 1);
1121 assert_eq!(hits[0].source, PathBuf::from("/mem/source.md"));
1122 assert_eq!(hits[0].line_start, 3);
1123 assert_eq!(hits[0].line_end, 5);
1124 assert_eq!(hits[0].text, "reviewed note");
1125 assert!(
1126 fake.search(Path::new("/ws"), "", 10)
1127 .expect("empty")
1128 .is_empty()
1129 );
1130
1131 // Get distinguishes found from not-found without an error string.
1132 assert!(matches!(
1133 fake.get(Path::new("/ws"), 42),
1134 Ok(MemoryGetOutcome::Found(_))
1135 ));
1136 assert_eq!(
1137 fake.get(Path::new("/ws"), 1).expect("get"),
1138 MemoryGetOutcome::NotFound
1139 );
1140
1141 // Export carries the raw document, not a command response.
1142 let exported = fake.export().expect("export");
1143 assert_eq!(exported.content, "# memory\n\n- bullet");
1144
1145 // Reindex carries the typed count.
1146 assert_eq!(fake.reindex().expect("reindex").entry_count, 3);
1147
1148 // Remember distinguishes global from workspace via the typed target.
1149 let global = fake
1150 .remember(MemoryRememberTarget::Global, "note")
1151 .expect("global remember");
1152 assert_eq!(global.source, PathBuf::from("/mem/global.md"));
1153 assert_eq!(global.line_start, 7);
1154 let workspace = fake
1155 .remember(
1156 MemoryRememberTarget::Workspace {
1157 workspace_id: "owner/repo".to_string(),
1158 },
1159 "note",
1160 )
1161 .expect("workspace remember");
1162 assert_eq!(workspace.source, PathBuf::from("/mem/global.md"));
1163
1164 // Import distinguishes imported from skipped.
1165 assert_eq!(fake.import().expect("import"), MemoryImportOutcome::Skipped);
1166 assert_eq!(
1167 MemoryImportOutcome::Imported {
1168 destination: PathBuf::from("/mem/global.md")
1169 },
1170 MemoryImportOutcome::Imported {
1171 destination: PathBuf::from("/mem/global.md")
1172 }
1173 );
1174
1175 // Remember rejects empty notes with a safe error, never a panic.
1176 assert!(fake.remember(MemoryRememberTarget::Global, "").is_err());
1177
1178 // Zero-field delete outcome stays distinguishable.
1179 assert_eq!(fake.delete(MemoryDeleteScope::All), Ok(MemoryDelete));
1180 }
1181
1182 #[test]
1183 fn memory_delete_and_remember_targets_are_typed_and_scoped() {
1184 let memory = RecordingMemory::new();
1185 let _ = memory.delete(MemoryDeleteScope::All);
1186 let _ = memory.delete(MemoryDeleteScope::Global);
1187 let _ = memory.delete_workspace(Path::new("/ws"));
1188 let _ = memory.remember(MemoryRememberTarget::Global, "a");
1189 let _ = memory.remember(
1190 MemoryRememberTarget::Workspace {
1191 workspace_id: "owner/repo".to_string(),
1192 },
1193 "b",
1194 );
1195
1196 // The non-workspace delete method receives exactly the all/global scopes;
1197 // workspace deletion goes through the distinct typed method (D8/D9).
1198 assert_eq!(memory.recorded_delete_scopes(), vec!["all", "global"]);
1199 assert_eq!(memory.recorded_workspace_deletes(), 1);
1200
1201 // Remember targets preserve the typed global/workspace distinction.
1202 assert_eq!(
1203 memory.recorded_targets(),
1204 vec![
1205 MemoryRememberTarget::Global,
1206 MemoryRememberTarget::Workspace {
1207 workspace_id: "owner/repo".to_string(),
1208 },
1209 ]
1210 );
1211 }
1212
1213 #[test]
1214 fn capabilities_declare_exact_memory_authority() {
1215 let workspace = CommandCapabilities::WORKSPACE;
1216 let memory = CommandCapabilities::MEMORY;
1217 let workspace_memory = workspace.union(memory);
1218
1219 assert_eq!(
1220 workspace_memory,
1221 CommandCapabilities::WORKSPACE | CommandCapabilities::MEMORY
1222 );
1223 assert_ne!(workspace_memory, workspace);
1224 assert_ne!(workspace_memory, memory);
1225 assert!(workspace_memory.contains(CommandCapabilities::WORKSPACE));
1226 assert!(workspace_memory.contains(CommandCapabilities::MEMORY));
1227 assert!(!workspace.contains(CommandCapabilities::MEMORY));
1228 assert!(!memory.contains(CommandCapabilities::WORKSPACE));
1229 assert!(CommandCapabilities::NONE.is_empty());
1230 assert!(!workspace_memory.contains(CommandCapabilities::NONE));
1231 assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
1232 // No presentation or media authority is declared for the memory group.
1233 assert!(!workspace_memory.contains(CommandCapabilities::PRESENTATION));
1234 assert!(!workspace_memory.contains(CommandCapabilities::MEDIA));
1235 // Existing capability identities stay stable.
1236 assert_ne!(CommandCapabilities::SESSION, CommandCapabilities::MODEL);
1237 }
1238
1239 #[test]
1240 fn memory_facet_transports_through_envelope_when_declared() {
1241 let mut memory = FakeMemory::new();
1242 let parts = CommandContexts::empty()
1243 .with_memory(&mut memory)
1244 .into_parts();
1245 assert!(parts.memory.is_some());
1246 assert!(parts.session.is_none());
1247 assert!(parts.workspace.is_none());
1248
1249 // Undeclared slots stay absent when the memory facet is carried alone.
1250 let mut workspace = Workspace;
1251 let parts = CommandContexts::empty()
1252 .with_memory(&mut memory)
1253 .with_workspace(&mut workspace)
1254 .into_parts();
1255 assert!(parts.memory.is_some());
1256 assert!(parts.workspace.is_some());
1257 assert!(parts.presentation.is_none());
1258 assert!(parts.media.is_none());
1259 }
1260
1261 #[test]
1262 fn envelope_rejects_duplicate_memory_slot_deterministically() {
1263 let mut a = FakeMemory::new();
1264 let mut b = FakeMemory::new();
1265 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1266 CommandContexts::empty()
1267 .with_memory(&mut a)
1268 .with_memory(&mut b);
1269 }));
1270 assert!(result.is_err(), "duplicate memory slot must assert");
1271 }
1272
1273 // ---------------------------------------------------------------------------
1274 // FEAT-020: plugin capability, portable DTOs, and envelope slot (D1-D11)
1275 // ---------------------------------------------------------------------------
1276
1277 /// Deterministic fake plugin facet over portable values only.
1278 struct FakePlugin {
1279 summaries: Vec<PluginSummary>,
1280 detail: Option<PluginDetail>,
1281 installed: bool,
1282 managed_candidates: Vec<PluginManagedCandidate>,
1283 }
1284
1285 impl FakePlugin {
1286 fn new() -> Self {
1287 Self {
1288 summaries: vec![PluginSummary {
1289 name: "demo".to_string(),
1290 id: "demo@1.0.0".to_string(),
1291 state_label: "active".to_string(),
1292 scope: "user".to_string(),
1293 trust_status: "trusted".to_string(),
1294 compatibility: "full".to_string(),
1295 inventory: "skills=1 mcp=0".to_string(),
1296 active: true,
1297 trusted: true,
1298 enabled: true,
1299 }],
1300 detail: Some(PluginDetail {
1301 name: "demo".to_string(),
1302 id: "demo@1.0.0".to_string(),
1303 inventory_summary: "skills=1 mcp=0".to_string(),
1304 version: "1.0.0".to_string(),
1305 origin: "local".to_string(),
1306 scope: "user".to_string(),
1307 state_label: "active".to_string(),
1308 trust_status: "trusted".to_string(),
1309 compatibility: "full".to_string(),
1310 content_hash: "abc".to_string(),
1311 capability_hash: "def".to_string(),
1312 canonical_root: PathBuf::from("/plugins/demo"),
1313 active: true,
1314 trusted: true,
1315 enabled: true,
1316 unsupported_labels: Vec::new(),
1317 supported_labels: vec!["skills".to_string()],
1318 skills: vec!["demo:demo-skill".to_string()],
1319 filesystem_roots: Vec::new(),
1320 network_hosts: Vec::new(),
1321 stdio_mcp_servers: 0,
1322 lifecycle_mutation: false,
1323 mcp_servers: Vec::new(),
1324 diagnostics: Vec::new(),
1325 }),
1326 installed: false,
1327 managed_candidates: Vec::new(),
1328 }
1329 }
1330 }
1331
1332 impl CommandPluginContext for FakePlugin {
1333 fn summaries(&self) -> Result<Vec<PluginSummary>, String> {
1334 Ok(self.summaries.clone())
1335 }
1336
1337 fn detail(&self, selector: &str) -> Result<PluginDetail, String> {
1338 if selector == "demo" {
1339 self.detail
1340 .clone()
1341 .ok_or_else(|| "missing detail".to_string())
1342 } else {
1343 Err(format!("no plugin named {selector}"))
1344 }
1345 }
1346
1347 fn registry_diagnostics(&self) -> Vec<PluginDiagnostic> {
1348 Vec::new()
1349 }
1350
1351 fn validation_is_clean(&self) -> bool {
1352 true
1353 }
1354
1355 fn len(&self) -> usize {
1356 self.summaries.len()
1357 }
1358
1359 fn reload(&mut self) -> Result<usize, String> {
1360 Ok(self.summaries.len())
1361 }
1362
1363 fn is_empty(&self) -> bool {
1364 self.summaries.is_empty()
1365 }
1366
1367 fn reload_nudge(&mut self) -> Option<String> {
1368 None
1369 }
1370
1371 fn state_path(&self) -> Option<PathBuf> {
1372 Some(PathBuf::from("/plugins/state.json"))
1373 }
1374
1375 fn suggest(&self, task: &str) -> Result<Vec<PluginSuggestion>, String> {
1376 if task.len() < 3 {
1377 return Err("task too short".to_string());
1378 }
1379 Ok(vec![PluginSuggestion {
1380 name: "demo".to_string(),
1381 state_label: "active".to_string(),
1382 description: "Demo bundle".to_string(),
1383 why: vec![task.to_string()],
1384 next_step: "Already active: /plugin show demo".to_string(),
1385 }])
1386 }
1387
1388 fn trust(&mut self, _selector: &str, token: &str) -> Result<(), String> {
1389 if token == "abc.def" {
1390 Ok(())
1391 } else {
1392 Err("Review token does not match this bundle content and capability set".to_string())
1393 }
1394 }
1395
1396 fn enable(&mut self, _selector: &str) -> Result<(), String> {
1397 Ok(())
1398 }
1399
1400 fn disable(&mut self, _selector: &str) -> Result<(), String> {
1401 Ok(())
1402 }
1403
1404 fn revoke_trust(&mut self, _selector: &str) -> Result<(), String> {
1405 Ok(())
1406 }
1407
1408 fn install(
1409 &mut self,
1410 _source: &str,
1411 expected_content_hash: Option<&str>,
1412 ) -> Result<PluginMutationReceipt, String> {
1413 if let Some(expected) = expected_content_hash
1414 && expected != "abc"
1415 {
1416 return Err("content hash mismatch".to_string());
1417 }
1418 self.installed = true;
1419 Ok(PluginMutationReceipt {
1420 name: "demo".to_string(),
1421 path: Some(PathBuf::from("/plugins/demo")),
1422 content_hash: Some("abc".to_string()),
1423 installed_content_hash: Some("abc".to_string()),
1424 outcome: PluginMutationOutcome::Installed,
1425 })
1426 }
1427
1428 fn update(&mut self, _selector: &str) -> Result<PluginMutationReceipt, String> {
1429 Ok(PluginMutationReceipt {
1430 name: "demo".to_string(),
1431 path: None,
1432 content_hash: None,
1433 installed_content_hash: None,
1434 outcome: PluginMutationOutcome::NoChange,
1435 })
1436 }
1437
1438 fn uninstall(&mut self, _selector: &str) -> Result<PluginMutationReceipt, String> {
1439 Ok(PluginMutationReceipt {
1440 name: "demo".to_string(),
1441 path: None,
1442 content_hash: None,
1443 installed_content_hash: None,
1444 outcome: PluginMutationOutcome::Uninstalled,
1445 })
1446 }
1447
1448 fn uninstall_path(&mut self, _name: &str, _plugins_dir: &Path) -> Result<(), String> {
1449 Ok(())
1450 }
1451
1452 fn export(&self, _selector: &str, target: &Path) -> Result<PluginExportReceipt, String> {
1453 Ok(PluginExportReceipt {
1454 exported_name: "demo".to_string(),
1455 target: target.to_path_buf(),
1456 display_name: Some("Demo Bundle".to_string()),
1457 wrote_mcp_json: false,
1458 files_copied: 2,
1459 skills_normalized: false,
1460 })
1461 }
1462
1463 fn legacy_scan(&self) -> Result<Option<PluginLegacyScan>, String> {
1464 Ok(None)
1465 }
1466
1467 fn managed_scan(&self, _home_override: Option<&Path>) -> Result<PluginManagedScan, String> {
1468 Ok(PluginManagedScan {
1469 root: PathBuf::from("/kimi/managed"),
1470 candidates: self.managed_candidates.clone(),
1471 rejected: Vec::new(),
1472 })
1473 }
1474
1475 fn managed_install(
1476 &mut self,
1477 canonical_path: &Path,
1478 expected_content_hash: &str,
1479 ) -> Result<PluginMutationReceipt, String> {
1480 if expected_content_hash != "abc" {
1481 return Err("Kimi candidate changed".to_string());
1482 }
1483 Ok(PluginMutationReceipt {
1484 name: "kimi-demo".to_string(),
1485 path: Some(canonical_path.to_path_buf()),
1486 content_hash: Some("abc".to_string()),
1487 installed_content_hash: Some("abc".to_string()),
1488 outcome: PluginMutationOutcome::Installed,
1489 })
1490 }
1491
1492 fn marketplace_state(&self) -> Result<PluginMarketplaceState, String> {
1493 Ok(PluginMarketplaceState {
1494 official: Some(PluginMarketplaceCatalog {
1495 id: "official".to_string(),
1496 source_path: None,
1497 display_name: None,
1498 description: Some("Built into this release".to_string()),
1499 format: "codewhale".to_string(),
1500 tier: "official".to_string(),
1501 publisher: Some("Codewhale".to_string()),
1502 total_candidates: 1,
1503 warning_count: 0,
1504 candidates: Vec::new(),
1505 diagnostics: Vec::new(),
1506 }),
1507 stored: Vec::new(),
1508 })
1509 }
1510
1511 fn marketplace_add(
1512 &mut self,
1513 name: &str,
1514 _path: &Path,
1515 ) -> Result<PluginMarketplaceAddReceipt, String> {
1516 if name == "official" {
1517 return Err(
1518 "`official` is the catalog built into Codewhale; pick another name.".to_string(),
1519 );
1520 }
1521 Ok(PluginMarketplaceAddReceipt {
1522 name: name.to_string(),
1523 candidate_count: 0,
1524 warning_count: 0,
1525 catalog: PluginMarketplaceCatalog {
1526 id: name.to_string(),
1527 source_path: None,
1528 display_name: None,
1529 description: None,
1530 format: "kimi".to_string(),
1531 tier: "community".to_string(),
1532 publisher: None,
1533 total_candidates: 0,
1534 warning_count: 0,
1535 candidates: Vec::new(),
1536 diagnostics: Vec::new(),
1537 },
1538 })
1539 }
1540
1541 fn marketplace_remove(&mut self, _name: &str) -> Result<bool, String> {
1542 Ok(true)
1543 }
1544
1545 fn marketplace_install(
1546 &mut self,
1547 _catalog: &str,
1548 _candidate: &str,
1549 ) -> Result<PluginMutationReceipt, String> {
1550 Ok(PluginMutationReceipt {
1551 name: "market-demo".to_string(),
1552 path: None,
1553 content_hash: None,
1554 installed_content_hash: None,
1555 outcome: PluginMutationOutcome::Installed,
1556 })
1557 }
1558
1559 fn suggestion_dismissals(&self) -> Result<PluginSuggestionDismissals, String> {
1560 Ok(PluginSuggestionDismissals::default())
1561 }
1562
1563 fn reset_suggestion_dismissals(&mut self, _name: Option<&str>) -> Result<Vec<String>, String> {
1564 Ok(Vec::new())
1565 }
1566 }
1567
1568 #[test]
1569 fn plugin_facet_is_object_safe_and_typed() {
1570 fn plugin(_: &dyn CommandPluginContext) {}
1571 plugin(&FakePlugin::new());
1572
1573 let plugin = FakePlugin::new();
1574 assert_eq!(plugin.len(), 1);
1575 assert!(!plugin.is_empty());
1576 assert!(plugin.validation_is_clean());
1577 let summaries = plugin.summaries().unwrap();
1578 assert_eq!(summaries[0].name, "demo");
1579 assert_eq!(summaries[0].state_label, "active");
1580 }
1581
1582 #[test]
1583 fn plugin_detail_preserves_semantic_values() {
1584 let plugin = FakePlugin::new();
1585 let detail = plugin.detail("demo").unwrap();
1586 assert_eq!(detail.content_hash, "abc");
1587 assert_eq!(detail.capability_hash, "def");
1588 assert_eq!(detail.compatibility, "full");
1589 assert!(detail.active);
1590 assert_eq!(detail.skills, vec!["demo:demo-skill"]);
1591 // Unknown selector fails safely.
1592 assert!(plugin.detail("nope").is_err());
1593 }
1594
1595 #[test]
1596 fn plugin_mutation_receipts_distinguish_outcomes() {
1597 let mut plugin = FakePlugin::new();
1598 let installed = plugin.install("path:/demo", Some("abc")).unwrap();
1599 assert_eq!(installed.outcome, PluginMutationOutcome::Installed);
1600 assert_eq!(installed.installed_content_hash.as_deref(), Some("abc"));
1601
1602 // Exact-hash mismatch fails before any install side effect.
1603 let err = plugin.install("path:/demo", Some("wrong")).unwrap_err();
1604 assert!(err.contains("content hash mismatch"));
1605
1606 let uninstalled = plugin.uninstall("demo").unwrap();
1607 assert_eq!(uninstalled.outcome, PluginMutationOutcome::Uninstalled);
1608
1609 plugin.trust("demo", "abc.def").unwrap();
1610 let trust_err = plugin.trust("demo", "bad.token").unwrap_err();
1611 assert!(trust_err.contains("Review token does not match"));
1612 }
1613
1614 #[test]
1615 fn plugin_managed_and_marketplace_values_are_portable() {
1616 let mut plugin = FakePlugin::new();
1617 let scan = plugin.managed_scan(None).unwrap();
1618 assert_eq!(scan.root, PathBuf::from("/kimi/managed"));
1619 assert!(scan.candidates.is_empty());
1620
1621 plugin.managed_candidates.push(PluginManagedCandidate {
1622 name: "kimi-demo".to_string(),
1623 version: "1.0.0".to_string(),
1624 license: Some("MIT".to_string()),
1625 canonical_path: PathBuf::from("/kimi/managed/kimi-demo"),
1626 content_hash: "abc".to_string(),
1627 capability_hash: "def".to_string(),
1628 inventory: "skills=1".to_string(),
1629 applicable: true,
1630 });
1631 let scan = plugin.managed_scan(None).unwrap();
1632 assert_eq!(scan.candidates[0].name, "kimi-demo");
1633 assert_eq!(scan.candidates[0].license.as_deref(), Some("MIT"));
1634
1635 let state = plugin.marketplace_state().unwrap();
1636 let official = state.official.as_ref().expect("fake official catalog");
1637 assert_eq!(official.id, "official");
1638 assert_eq!(official.tier, "official");
1639 assert!(state.stored.is_empty());
1640
1641 let add = plugin
1642 .marketplace_add("custom", Path::new("/catalog.json"))
1643 .unwrap();
1644 assert_eq!(add.name, "custom");
1645 assert_eq!(add.catalog.format, "kimi");
1646
1647 let err = plugin
1648 .marketplace_add("official", Path::new("/x.json"))
1649 .unwrap_err();
1650 assert!(err.contains("built into Codewhale"));
1651 }
1652
1653 #[test]
1654 fn plugin_suggest_is_read_only_and_safe() {
1655 let plugin = FakePlugin::new();
1656 let err = plugin.suggest("ab").unwrap_err();
1657 assert!(err.contains("too short"));
1658 let suggestions = plugin.suggest("translate").unwrap();
1659 assert_eq!(suggestions[0].name, "demo");
1660 assert_eq!(
1661 suggestions[0].next_step,
1662 "Already active: /plugin show demo"
1663 );
1664 }
1665
1666 #[test]
1667 fn plugin_facet_transports_through_envelope_when_declared() {
1668 let mut plugin = FakePlugin::new();
1669 let parts = CommandContexts::empty()
1670 .with_plugin(&mut plugin)
1671 .into_parts();
1672 assert!(parts.plugin.is_some());
1673 assert!(parts.session.is_none());
1674 assert!(parts.memory.is_none());
1675
1676 // Undeclared slots stay absent when the plugin facet is carried alone.
1677 let mut workspace = Workspace;
1678 let parts = CommandContexts::empty()
1679 .with_plugin(&mut plugin)
1680 .with_workspace(&mut workspace)
1681 .into_parts();
1682 assert!(parts.plugin.is_some());
1683 assert!(parts.workspace.is_some());
1684 assert!(parts.presentation.is_none());
1685 }
1686
1687 #[test]
1688 fn envelope_rejects_duplicate_plugin_slot_deterministically() {
1689 let mut a = FakePlugin::new();
1690 let mut b = FakePlugin::new();
1691 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1692 CommandContexts::empty()
1693 .with_plugin(&mut a)
1694 .with_plugin(&mut b);
1695 }));
1696 assert!(result.is_err(), "duplicate plugin slot must assert");
1697 }
1698
1699 #[test]
1700 fn plugin_capability_bit_is_stable_and_distinct() {
1701 let plugin = CommandCapabilities::PLUGIN;
1702 assert_eq!(plugin, CommandCapabilities::PLUGIN);
1703 assert!(plugin.contains(CommandCapabilities::PLUGIN));
1704 assert!(!plugin.contains(CommandCapabilities::MEMORY));
1705 assert!(!plugin.contains(CommandCapabilities::PROJECT));
1706 assert!(!plugin.contains(CommandCapabilities::SKILL_GROUP));
1707 assert!(!plugin.contains(CommandCapabilities::WORKSPACE));
1708
1709 let plugin_workspace = CommandCapabilities::PLUGIN.union(CommandCapabilities::WORKSPACE);
1710 assert!(plugin_workspace.contains(CommandCapabilities::PLUGIN));
1711 assert!(plugin_workspace.contains(CommandCapabilities::WORKSPACE));
1712 assert!(!plugin_workspace.contains(CommandCapabilities::MEMORY));
1713
1714 // The plugin group declares exactly WORKSPACE | PRESENTATION | PLUGIN.
1715 let exact = CommandCapabilities::WORKSPACE
1716 .union(CommandCapabilities::PRESENTATION)
1717 .union(CommandCapabilities::PLUGIN);
1718 assert!(exact.contains(CommandCapabilities::PLUGIN));
1719 assert!(exact.contains(CommandCapabilities::PRESENTATION));
1720 assert!(!exact.contains(CommandCapabilities::MEDIA));
1721 assert!(!exact.contains(CommandCapabilities::MEMORY));
1722 assert!(!exact.contains(CommandCapabilities::PROJECT));
1723 assert!(!exact.contains(CommandCapabilities::SKILL_GROUP));
1724 assert!(!exact.contains(CommandCapabilities::SKILLS));
1725 }
1726
1727 // FEAT-022: skill-group facet (CommandSkillGroupContext)
1728 // ---------------------------------------------------------------------------
1729
1730 struct FakeSkillGroup {
1731 projection: SkillRegistryProjection,
1732 activation_result: Result<SkillActivationOutcome, SkillActivationError>,
1733 receipt: SkillMutationReceipt,
1734 remote: Result<RemoteRegistryOutcome, String>,
1735 sync: Result<SkillSyncOutcome, String>,
1736 review: Result<ReviewOutcome, String>,
1737 snapshots: Vec<SnapshotEntry>,
1738 restore_ok: bool,
1739 approval: CommandApprovalState,
1740 }
1741
1742 impl FakeSkillGroup {
1743 fn new() -> Self {
1744 Self {
1745 projection: SkillRegistryProjection {
1746 workspace: "/ws".into(),
1747 skills_dir: "/ws/.codewhale/skills".into(),
1748 mode_label: "compatible".into(),
1749 dirs: vec!["/ws/.codewhale/skills".into()],
1750 entries: vec![SkillEntry {
1751 name: "demo".into(),
1752 description: "Demo skill".into(),
1753 source: SkillSourceKind::Native,
1754 path: Some("/ws/.codewhale/skills/demo/SKILL.md".into()),
1755 bundled_tier: None,
1756 }],
1757 warnings: vec!["one warning".into()],
1758 total: 1,
1759 },
1760 activation_result: Ok(SkillActivationOutcome {
1761 name: "demo".into(),
1762 description: "Demo skill".into(),
1763 }),
1764 receipt: SkillMutationReceipt {
1765 name: "demo".into(),
1766 safe_target_path: "/ws/.codewhale/skills/demo".into(),
1767 outcome: SkillMutationOutcome::Installed,
1768 },
1769 remote: Ok(RemoteRegistryOutcome::Loaded {
1770 entries: vec![RemoteSkillEntry {
1771 name: "demo".into(),
1772 description: Some("Remote demo".into()),
1773 source: "github.com/acme/skills".into(),
1774 }],
1775 }),
1776 sync: Ok(SkillSyncOutcome::Done {
1777 total: 1,
1778 downloaded: 1,
1779 fresh: 0,
1780 failed: 0,
1781 entries: vec![SkillSyncEntry::Downloaded {
1782 name: "demo".into(),
1783 path: "/cache/demo".into(),
1784 }],
1785 }),
1786 review: Ok(ReviewOutcome::Ready),
1787 snapshots: vec![SnapshotEntry {
1788 id: "abcdef123456".into(),
1789 label: "pre-turn:1".into(),
1790 timestamp: 1_700_000_000,
1791 }],
1792 restore_ok: true,
1793 approval: CommandApprovalState {
1794 yolo: true,
1795 trust_mode: false,
1796 },
1797 }
1798 }
1799 }
1800
1801 impl CommandSkillGroupContext for FakeSkillGroup {
1802 fn skill_registry_projection(&self) -> SkillRegistryProjection {
1803 self.projection.clone()
1804 }
1805
1806 fn activate_skill(
1807 &mut self,
1808 _name: &str,
1809 ) -> Result<SkillActivationOutcome, SkillActivationError> {
1810 self.activation_result.clone()
1811 }
1812
1813 fn install_skill(
1814 &mut self,
1815 _scope: Option<SkillTargetScope>,
1816 _spec: &str,
1817 ) -> Result<SkillMutationReceipt, String> {
1818 Ok(self.receipt.clone())
1819 }
1820
1821 fn update_skill(
1822 &mut self,
1823 _scope: Option<SkillTargetScope>,
1824 _name: &str,
1825 ) -> Result<SkillMutationReceipt, String> {
1826 Ok(self.receipt.clone())
1827 }
1828
1829 fn uninstall_skill(
1830 &mut self,
1831 _scope: Option<SkillTargetScope>,
1832 _name: &str,
1833 ) -> Result<SkillMutationReceipt, String> {
1834 Ok(self.receipt.clone())
1835 }
1836
1837 fn trust_skill(
1838 &mut self,
1839 _scope: Option<SkillTargetScope>,
1840 _name: &str,
1841 ) -> Result<SkillMutationReceipt, String> {
1842 Ok(self.receipt.clone())
1843 }
1844
1845 fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
1846 self.remote.clone()
1847 }
1848
1849 fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String> {
1850 Ok(vec![SkillRecommendation {
1851 name: format!("rec-{task}"),
1852 description: Some("Recommended".into()),
1853 matched_terms: vec!["term".into()],
1854 }])
1855 }
1856
1857 fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
1858 self.sync.clone()
1859 }
1860
1861 fn run_review(&mut self) -> Result<ReviewOutcome, String> {
1862 self.review.clone()
1863 }
1864
1865 fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> {
1866 Ok(self.snapshots.clone())
1867 }
1868
1869 fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
1870 if self.restore_ok {
1871 Ok(())
1872 } else {
1873 Err("Restore failed: boom".into())
1874 }
1875 }
1876
1877 fn approval_state(&self) -> CommandApprovalState {
1878 self.approval
1879 }
1880 }
1881
1882 #[test]
1883 fn skill_group_facet_is_object_safe_and_typed() {
1884 fn project(_: &dyn CommandSkillGroupContext) {}
1885 project(&FakeSkillGroup::new());
1886
1887 let group = FakeSkillGroup::new();
1888 let projection = group.skill_registry_projection();
1889 assert_eq!(projection.total, 1);
1890 assert_eq!(projection.entries[0].name, "demo");
1891 assert!(group.approval_state().yolo);
1892 }
1893
1894 #[test]
1895 fn skill_registry_projection_preserves_semantic_values() {
1896 let group = FakeSkillGroup::new();
1897 let projection = group.skill_registry_projection();
1898 assert_eq!(projection.workspace, "/ws");
1899 assert_eq!(projection.skills_dir, "/ws/.codewhale/skills");
1900 assert_eq!(projection.mode_label, "compatible");
1901 assert_eq!(projection.dirs, vec!["/ws/.codewhale/skills"]);
1902 assert_eq!(projection.warnings, vec!["one warning"]);
1903 assert_eq!(projection.entries.len(), 1);
1904 let entry = &projection.entries[0];
1905 assert_eq!(entry.name, "demo");
1906 assert_eq!(entry.description, "Demo skill");
1907 assert_eq!(entry.source, SkillSourceKind::Native);
1908 assert_eq!(
1909 entry.path.as_deref(),
1910 Some("/ws/.codewhale/skills/demo/SKILL.md")
1911 );
1912 assert_eq!(entry.bundled_tier, None);
1913 }
1914
1915 #[test]
1916 fn skill_bundled_tier_headings_are_stable() {
1917 assert_eq!(SkillBundledTier::CoreAgentic.heading(), "Core agentic");
1918 assert_eq!(
1919 SkillBundledTier::FormatTooling.heading(),
1920 "Format & tooling"
1921 );
1922 }
1923
1924 #[test]
1925 fn skill_mutation_receipt_preserves_outcome_variants() {
1926 let installed = FakeSkillGroup::new().receipt;
1927 assert_eq!(installed.name, "demo");
1928 assert_eq!(installed.outcome, SkillMutationOutcome::Installed);
1929
1930 let denied = SkillMutationReceipt {
1931 outcome: SkillMutationOutcome::NetworkDenied("acme.com".into()),
1932 ..installed.clone()
1933 };
1934 assert_eq!(
1935 denied.outcome,
1936 SkillMutationOutcome::NetworkDenied("acme.com".into())
1937 );
1938
1939 let approval = SkillMutationReceipt {
1940 outcome: SkillMutationOutcome::NeedsApproval("acme.com".into()),
1941 ..installed.clone()
1942 };
1943 assert_eq!(
1944 approval.outcome,
1945 SkillMutationOutcome::NeedsApproval("acme.com".into())
1946 );
1947
1948 assert_ne!(installed.outcome, denied.outcome);
1949 assert_ne!(installed.outcome, approval.outcome);
1950 assert_ne!(denied.outcome, approval.outcome);
1951 }
1952
1953 #[test]
1954 fn skill_source_kind_variants_are_distinguishable() {
1955 let native = SkillSourceKind::Native;
1956 let plugin = SkillSourceKind::Plugin {
1957 plugin_name: "acme".into(),
1958 plugin_id: "acme-1".into(),
1959 };
1960 assert_ne!(native, plugin);
1961 assert_eq!(
1962 plugin,
1963 SkillSourceKind::Plugin {
1964 plugin_name: "acme".into(),
1965 plugin_id: "acme-1".into(),
1966 }
1967 );
1968 }
1969
1970 #[test]
1971 fn remote_registry_outcome_variants_are_distinguishable() {
1972 let loaded = RemoteRegistryOutcome::Loaded {
1973 entries: vec![RemoteSkillEntry {
1974 name: "demo".into(),
1975 description: None,
1976 source: "acme".into(),
1977 }],
1978 };
1979 let approval = RemoteRegistryOutcome::NeedsApproval("acme.com".into());
1980 let denied = RemoteRegistryOutcome::Denied("acme.com".into());
1981 assert_ne!(loaded, approval);
1982 assert_ne!(loaded, denied);
1983 assert_ne!(approval, denied);
1984 }
1985
1986 #[test]
1987 fn skill_sync_outcome_preserves_all_entry_variants() {
1988 let outcome = SkillSyncOutcome::Done {
1989 total: 4,
1990 downloaded: 1,
1991 fresh: 1,
1992 failed: 2,
1993 entries: vec![
1994 SkillSyncEntry::Downloaded {
1995 name: "a".into(),
1996 path: "/cache/a".into(),
1997 },
1998 SkillSyncEntry::Fresh { name: "b".into() },
1999 SkillSyncEntry::Failed {
2000 name: "c".into(),
2001 reason: "boom".into(),
2002 },
2003 SkillSyncEntry::Denied {
2004 name: "d".into(),
2005 host: "acme.com".into(),
2006 },
2007 SkillSyncEntry::NeedsApproval {
2008 name: "e".into(),
2009 host: "acme.com".into(),
2010 },
2011 ],
2012 };
2013 let SkillSyncOutcome::Done {
2014 total,
2015 downloaded,
2016 fresh,
2017 failed,
2018 entries,
2019 } = &outcome
2020 else {
2021 panic!("expected Done");
2022 };
2023 assert_eq!(*total, 4);
2024 assert_eq!(*downloaded, 1);
2025 assert_eq!(*fresh, 1);
2026 assert_eq!(*failed, 2);
2027 assert_eq!(entries.len(), 5);
2028 assert!(matches!(entries[0], SkillSyncEntry::Downloaded { .. }));
2029 assert!(matches!(entries[1], SkillSyncEntry::Fresh { .. }));
2030 assert!(matches!(entries[2], SkillSyncEntry::Failed { .. }));
2031 assert!(matches!(entries[3], SkillSyncEntry::Denied { .. }));
2032 assert!(matches!(entries[4], SkillSyncEntry::NeedsApproval { .. }));
2033 }
2034
2035 #[test]
2036 fn skill_sync_registry_policy_variants_are_distinguishable() {
2037 let approval = SkillSyncOutcome::RegistryNeedsApproval("acme.com".into());
2038 let denied = SkillSyncOutcome::RegistryDenied("acme.com".into());
2039 assert_ne!(approval, denied);
2040 assert!(matches!(
2041 approval,
2042 SkillSyncOutcome::RegistryNeedsApproval(host) if host == "acme.com"
2043 ));
2044 assert!(matches!(
2045 denied,
2046 SkillSyncOutcome::RegistryDenied(host) if host == "acme.com"
2047 ));
2048 }
2049
2050 #[test]
2051 fn skill_activation_error_variants_are_distinguishable() {
2052 let mut group = FakeSkillGroup::new();
2053 group.activation_result = Err(SkillActivationError::NotFound {
2054 requested: "missing".into(),
2055 available: vec!["demo".into()],
2056 warnings: vec![],
2057 });
2058 let not_found = group.activate_skill("missing").unwrap_err();
2059 match &not_found {
2060 SkillActivationError::NotFound {
2061 requested,
2062 available,
2063 ..
2064 } => {
2065 assert_eq!(requested, "missing");
2066 assert_eq!(available, &vec!["demo".to_string()]);
2067 }
2068 _ => panic!("expected NotFound"),
2069 }
2070
2071 let mut group = FakeSkillGroup::new();
2072 group.activation_result = Err(SkillActivationError::PluginRejected {
2073 name: "plug".into(),
2074 reason: "authority revoked".into(),
2075 });
2076 let rejected = group.activate_skill("plug").unwrap_err();
2077 match rejected {
2078 SkillActivationError::PluginRejected { name, reason } => {
2079 assert_eq!(name, "plug");
2080 assert_eq!(reason, "authority revoked");
2081 }
2082 _ => panic!("expected PluginRejected"),
2083 }
2084 }
2085
2086 #[test]
2087 fn review_outcome_variants_are_distinguishable() {
2088 let mut group = FakeSkillGroup::new();
2089 group.review = Ok(ReviewOutcome::NotFound {
2090 skills_dir: "/ws/skills".into(),
2091 global_dir: "/home/u/.codewhale/skills".into(),
2092 warnings: vec!["w".into()],
2093 });
2094 let outcome = group.run_review().unwrap();
2095 match outcome {
2096 ReviewOutcome::NotFound {
2097 skills_dir,
2098 global_dir,
2099 warnings,
2100 } => {
2101 assert_eq!(skills_dir, "/ws/skills");
2102 assert_eq!(global_dir, "/home/u/.codewhale/skills");
2103 assert_eq!(warnings, vec!["w".to_string()]);
2104 }
2105 _ => panic!("expected NotFound"),
2106 }
2107 }
2108
2109 #[test]
2110 fn snapshot_and_approval_values_preserve_semantics() {
2111 let mut group = FakeSkillGroup::new();
2112 let snapshots = group.snapshot_list(20).unwrap();
2113 assert_eq!(snapshots.len(), 1);
2114 assert_eq!(snapshots[0].id, "abcdef123456");
2115 assert_eq!(snapshots[0].label, "pre-turn:1");
2116 assert_eq!(snapshots[0].timestamp, 1_700_000_000);
2117
2118 let approval = group.approval_state();
2119 assert!(approval.yolo);
2120 assert!(!approval.trust_mode);
2121 }
2122
2123 #[test]
2124 fn skill_group_facet_transports_through_envelope_when_declared() {
2125 let mut group = FakeSkillGroup::new();
2126 let parts = CommandContexts::empty()
2127 .with_skill_group(&mut group)
2128 .into_parts();
2129 assert!(parts.skill_group.is_some());
2130 assert!(parts.session.is_none());
2131 assert!(parts.project.is_none());
2132
2133 // /skill combines skill_group with SKILLS for baseline cache refreshes.
2134 let mut skills = Skills;
2135 let parts = CommandContexts::empty()
2136 .with_skill_group(&mut group)
2137 .with_skills(&mut skills)
2138 .into_parts();
2139 assert!(parts.skill_group.is_some());
2140 assert!(parts.skills.is_some());
2141 assert!(parts.workspace.is_none());
2142 }
2143
2144 #[test]
2145 fn envelope_rejects_duplicate_skill_group_slot_deterministically() {
2146 let mut a = FakeSkillGroup::new();
2147 let mut b = FakeSkillGroup::new();
2148 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2149 CommandContexts::empty()
2150 .with_skill_group(&mut a)
2151 .with_skill_group(&mut b);
2152 }));
2153 assert!(result.is_err(), "duplicate skill_group slot must assert");
2154 }
2155
2156 /// Regression: the shared FEAT-015 `CommandSkillsContext` surface is unchanged
2157 /// (getters + cache refresh only, no setter) and still transports through the
2158 /// envelope alongside the new skill-group facet (D2).
2159 #[test]
2160 fn shared_skills_facet_surface_remains_read_only_and_transportable() {
2161 let mut skills = Skills;
2162 let active = skills.active_skill();
2163 assert_eq!(active, None);
2164 assert_eq!(skills.active_skill_provenance(), None);
2165 skills.refresh_skill_cache();
2166
2167 let mut group = FakeSkillGroup::new();
2168 let parts = CommandContexts::empty()
2169 .with_skills(&mut skills)
2170 .with_skill_group(&mut group)
2171 .into_parts();
2172 assert!(parts.skills.is_some());
2173 assert!(parts.skill_group.is_some());
2174 }
2175
2176 // ---------------------------------------------------------------------------
2177 // FEAT-023: session lifecycle contract (D2/D3/D6).
2178 // ---------------------------------------------------------------------------
2179
2180 #[test]
2181 fn lifecycle_capability_is_stable_distinct_and_non_conflicting() {
2182 let lifecycle = CommandCapabilities::SESSION_LIFECYCLE;
2183 for existing in [
2184 CommandCapabilities::NONE,
2185 CommandCapabilities::SESSION,
2186 CommandCapabilities::MODEL,
2187 CommandCapabilities::COST,
2188 CommandCapabilities::MODE_POLICY,
2189 CommandCapabilities::SYSTEM_PROMPT,
2190 CommandCapabilities::SKILLS,
2191 CommandCapabilities::WORKSPACE,
2192 CommandCapabilities::PRESENTATION,
2193 CommandCapabilities::MEDIA,
2194 CommandCapabilities::MEMORY,
2195 CommandCapabilities::PROJECT,
2196 CommandCapabilities::SKILL_GROUP,
2197 CommandCapabilities::PLUGIN,
2198 ] {
2199 assert_ne!(lifecycle, existing, "SESSION_LIFECYCLE must not collide");
2200 }
2201 assert!(!CommandCapabilities::NONE.contains(lifecycle));
2202 assert!(lifecycle.contains(lifecycle));
2203 assert!(
2204 lifecycle
2205 .union(CommandCapabilities::SESSION)
2206 .contains(lifecycle)
2207 );
2208 assert!(
2209 lifecycle
2210 .union(CommandCapabilities::SESSION)
2211 .contains(CommandCapabilities::SESSION)
2212 );
2213 }
2214
2215 /// Deterministic fake lifecycle facet: every delegate returns canned portable
2216 /// values or error text so the contract transport is exercised exactly.
2217 #[derive(Default)]
2218 struct FakeLifecycle {
2219 blocked: bool,
2220 leaf_hint: Option<String>,
2221 branch_outcome: Option<SessionBranchOutcome>,
2222 branch_error: Option<String>,
2223 tree: Option<Result<TreeBodyProjection, String>>,
2224 save: Option<Result<SessionSaveReceipt, String>>,
2225 fork_active: Option<Result<SessionForkReceipt, String>>,
2226 fork_from: Option<Result<SessionForkFromReceipt, String>>,
2227 fresh: Option<Result<SessionNewReceipt, String>>,
2228 load: Option<Result<PathBuf, String>>,
2229 picker: Option<String>,
2230 archived: Option<Result<SessionArchiveReceipt, String>>,
2231 prune: Option<Result<usize, String>>,
2232 }
2233
2234 impl CommandSessionLifecycleContext for FakeLifecycle {
2235 fn transition_blocked(&self) -> bool {
2236 self.blocked
2237 }
2238 fn branch_current_leaf_hint(&self) -> Option<String> {
2239 self.leaf_hint.clone()
2240 }
2241 fn branch_to(&mut self, entry_id: &str) -> Result<SessionBranchOutcome, String> {
2242 if let Some(err) = &self.branch_error {
2243 return Err(err.clone());
2244 }
2245 self.branch_outcome
2246 .clone()
2247 .ok_or_else(|| format!("unexpected branch_to({entry_id}) on empty fake"))
2248 }
2249 fn tree_body(&self) -> Result<TreeBodyProjection, String> {
2250 self.tree
2251 .clone()
2252 .unwrap_or(Ok(TreeBodyProjection::NoSession))
2253 }
2254 fn save_session(
2255 &mut self,
2256 explicit_path: Option<String>,
2257 ) -> Result<SessionSaveReceipt, String> {
2258 self.save
2259 .clone()
2260 .ok_or_else(|| format!("unexpected save_session({explicit_path:?}) on empty fake"))?
2261 }
2262 fn fork_active(&mut self) -> Result<SessionForkReceipt, String> {
2263 self.fork_active
2264 .clone()
2265 .ok_or_else(|| "unexpected fork_active() on empty fake".to_string())?
2266 }
2267 fn fork_from(&mut self, id: &str) -> Result<SessionForkFromReceipt, String> {
2268 self.fork_from
2269 .clone()
2270 .ok_or_else(|| format!("unexpected fork_from({id}) on empty fake"))?
2271 }
2272 fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
2273 self.fresh
2274 .clone()
2275 .ok_or_else(|| format!("unexpected fresh_session({force}) on empty fake"))?
2276 }
2277 fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
2278 self.load
2279 .clone()
2280 .ok_or_else(|| format!("unexpected load_session({path}) on empty fake"))?
2281 }
2282 fn open_picker(&mut self, preselected: Option<String>) {
2283 self.picker = preselected;
2284 }
2285 fn set_archived(
2286 &mut self,
2287 session_id: &str,
2288 archived: bool,
2289 ) -> Result<SessionArchiveReceipt, String> {
2290 self.archived.clone().ok_or_else(|| {
2291 format!("unexpected set_archived({session_id}, {archived}) on empty fake")
2292 })?
2293 }
2294 fn prune_sessions(&mut self, days: u64) -> Result<usize, String> {
2295 self.prune
2296 .clone()
2297 .ok_or_else(|| format!("unexpected prune_sessions({days}) on empty fake"))?
2298 }
2299 }
2300
2301 fn lifecycle_sync_payload(session_id: Option<&str>) -> SessionSyncPayload {
2302 SessionSyncPayload {
2303 session_id: session_id.map(str::to_string),
2304 messages: vec![Message {
2305 role: Role::User,
2306 content: vec![ContentBlock::Text {
2307 text: "hello lifecycle".to_string(),
2308 cache_control: None,
2309 }],
2310 }],
2311 system_prompt: Some(SystemPrompt::Text("prompt".to_string())),
2312 model: "lifecycle-model".to_string(),
2313 workspace: PathBuf::from("/workspace/lifecycle"),
2314 mode: CommandMode::Plan,
2315 }
2316 }
2317
2318 #[test]
2319 fn lifecycle_facet_is_object_safe_and_transports_every_outcome() {
2320 // Object safety: usable behind a single `dyn` reference.
2321 fn accepts_dyn(_: &dyn CommandSessionLifecycleContext) {}
2322 fn accepts_dyn_mut(_: &mut dyn CommandSessionLifecycleContext) {}
2323
2324 let mut fake = FakeLifecycle {
2325 blocked: true,
2326 leaf_hint: Some("entry-42".to_string()),
2327 branch_outcome: Some(SessionBranchOutcome {
2328 leaf_display: "entry-43".to_string(),
2329 journal_entries_before: 7,
2330 sync: lifecycle_sync_payload(Some("branched-session")),
2331 }),
2332 tree: Some(Ok(TreeBodyProjection::Journal {
2333 rendered: "rendered journal".to_string(),
2334 })),
2335 save: Some(Ok(SessionSaveReceipt {
2336 display_path: "/tmp/session.json".to_string(),
2337 truncated_id: "abc123".to_string(),
2338 })),
2339 fork_active: Some(Ok(SessionForkReceipt {
2340 parent_label: "parent".to_string(),
2341 fork_label: "child".to_string(),
2342 sync: lifecycle_sync_payload(Some("child")),
2343 })),
2344 fork_from: Some(Ok(SessionForkFromReceipt {
2345 parent_label: "source".to_string(),
2346 fork_label: "sibling".to_string(),
2347 spawn_depth: 3,
2348 sync: lifecycle_sync_payload(Some("sibling")),
2349 })),
2350 fresh: Some(Ok(SessionNewReceipt {
2351 truncated_id: "new-id".to_string(),
2352 sync: lifecycle_sync_payload(Some("new-id")),
2353 })),
2354 load: Some(Ok(PathBuf::from("/tmp/loaded.json"))),
2355 archived: Some(Ok(SessionArchiveReceipt {
2356 truncated_id: "arch-1".to_string(),
2357 title: "Archive Title".to_string(),
2358 })),
2359 prune: Some(Ok(3)),
2360 ..FakeLifecycle::default()
2361 };
2362 accepts_dyn(&fake);
2363 accepts_dyn_mut(&mut fake);
2364
2365 assert!(fake.transition_blocked());
2366 assert_eq!(fake.branch_current_leaf_hint().as_deref(), Some("entry-42"));
2367 let branch = fake.branch_to("entry-43").expect("branch ok");
2368 assert_eq!(branch.leaf_display, "entry-43");
2369 assert_eq!(branch.journal_entries_before, 7);
2370 assert_eq!(branch.sync.session_id.as_deref(), Some("branched-session"));
2371 assert_eq!(branch.sync.messages.len(), 1);
2372 assert_eq!(branch.sync.mode, CommandMode::Plan);
2373 match fake.tree_body().expect("tree ok") {
2374 TreeBodyProjection::Journal { rendered } => assert_eq!(rendered, "rendered journal"),
2375 other => panic!("expected Journal projection, got {other:?}"),
2376 }
2377 let save = fake
2378 .save_session(Some("/tmp/session.json".to_string()))
2379 .expect("save ok");
2380 assert_eq!(save.display_path, "/tmp/session.json");
2381 assert_eq!(save.truncated_id, "abc123");
2382 let active = fake.fork_active().expect("active fork ok");
2383 assert_eq!(active.parent_label, "parent");
2384 assert_eq!(active.fork_label, "child");
2385 assert_eq!(active.sync.session_id.as_deref(), Some("child"));
2386 assert_eq!(active.sync.messages.len(), 1);
2387 assert_eq!(active.sync.mode, CommandMode::Plan);
2388 let explicit = fake.fork_from("source").expect("explicit fork ok");
2389 assert_eq!(explicit.spawn_depth, 3);
2390 assert_eq!(
2391 explicit.sync.workspace,
2392 PathBuf::from("/workspace/lifecycle")
2393 );
2394 let fresh = fake.fresh_session(true).expect("fresh ok");
2395 assert_eq!(fresh.truncated_id, "new-id");
2396 assert_eq!(fresh.sync.messages.len(), 1);
2397 let loaded = fake.load_session("loaded.json").expect("load ok");
2398 assert_eq!(loaded, PathBuf::from("/tmp/loaded.json"));
2399 fake.open_picker(Some("arch-1".to_string()));
2400 assert_eq!(fake.picker.as_deref(), Some("arch-1"));
2401 let archived = fake.set_archived("arch-1", true).expect("archive ok");
2402 assert_eq!(archived.truncated_id, "arch-1");
2403 assert_eq!(archived.title, "Archive Title");
2404 assert_eq!(fake.prune_sessions(30).expect("prune ok"), 3);
2405 }
2406
2407 #[test]
2408 fn lifecycle_error_text_and_empty_states_transport_exactly() {
2409 let mut fake = FakeLifecycle {
2410 branch_error: Some("could not load session x: boom".to_string()),
2411 tree: Some(Err("could not open sessions directory: boom".to_string())),
2412 save: Some(Err("Failed to save session: boom".to_string())),
2413 load: Some(Err("Failed to read session file: boom".to_string())),
2414 archived: Some(Err("archive failed: boom".to_string())),
2415 prune: Some(Err("prune failed: boom".to_string())),
2416 ..FakeLifecycle::default()
2417 };
2418 assert_eq!(
2419 fake.branch_to("x").unwrap_err(),
2420 "could not load session x: boom"
2421 );
2422 assert_eq!(
2423 fake.tree_body().unwrap_err(),
2424 "could not open sessions directory: boom"
2425 );
2426 assert_eq!(
2427 fake.save_session(None).unwrap_err(),
2428 "Failed to save session: boom"
2429 );
2430 assert_eq!(
2431 fake.load_session("missing.json").unwrap_err(),
2432 "Failed to read session file: boom"
2433 );
2434 assert_eq!(
2435 fake.set_archived("a", false).unwrap_err(),
2436 "archive failed: boom"
2437 );
2438 assert_eq!(fake.prune_sessions(7).unwrap_err(), "prune failed: boom");
2439
2440 let mut empty = FakeLifecycle::default();
2441 assert!(!empty.transition_blocked());
2442 assert_eq!(empty.branch_current_leaf_hint(), None);
2443 assert!(matches!(
2444 empty.tree_body().expect("default tree"),
2445 TreeBodyProjection::NoSession
2446 ));
2447 empty.open_picker(None);
2448 assert_eq!(empty.picker, None);
2449 }
2450
2451 #[test]
2452 fn envelope_lifecycle_slot_is_independent_and_rejects_duplicates() {
2453 let mut first = FakeLifecycle::default();
2454 let mut second = FakeLifecycle::default();
2455
2456 let parts = CommandContexts::empty()
2457 .with_lifecycle(&mut first)
2458 .into_parts();
2459 assert!(
2460 parts.lifecycle.is_some(),
2461 "lifecycle slot must be present when declared"
2462 );
2463 assert!(
2464 parts.session.is_none() && parts.plugin.is_none() && parts.skill_group.is_none(),
2465 "unrelated slots must stay absent (exact exposure)"
2466 );
2467
2468 let bare = CommandContexts::empty().into_parts();
2469 assert!(
2470 bare.lifecycle.is_none(),
2471 "undeclared lifecycle stays absent"
2472 );
2473
2474 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2475 CommandContexts::empty()
2476 .with_lifecycle(&mut first)
2477 .with_lifecycle(&mut second);
2478 }));
2479 assert!(
2480 result.is_err(),
2481 "duplicate lifecycle slot must assert deterministically"
2482 );
2483
2484 // Reading through the dyn facet works after insertion.
2485 first.blocked = true;
2486 let inserted = CommandContexts::empty().with_lifecycle(&mut first);
2487 let lifecycle = inserted.into_parts().lifecycle.expect("inserted lifecycle");
2488 assert!(lifecycle.transition_blocked());
2489 }
2490
2491 // ---------------------------------------------------------------------------
2492 // FEAT-024: session control contract (D2/D3/D6/D7).
2493 // ---------------------------------------------------------------------------
2494
2495 #[test]
2496 fn control_capability_is_stable_distinct_and_non_conflicting() {
2497 let control = CommandCapabilities::SESSION_CONTROL;
2498 for existing in [
2499 CommandCapabilities::NONE,
2500 CommandCapabilities::SESSION,
2501 CommandCapabilities::MODEL,
2502 CommandCapabilities::COST,
2503 CommandCapabilities::MODE_POLICY,
2504 CommandCapabilities::SYSTEM_PROMPT,
2505 CommandCapabilities::SKILLS,
2506 CommandCapabilities::WORKSPACE,
2507 CommandCapabilities::PRESENTATION,
2508 CommandCapabilities::MEDIA,
2509 CommandCapabilities::MEMORY,
2510 CommandCapabilities::PROJECT,
2511 CommandCapabilities::SKILL_GROUP,
2512 CommandCapabilities::PLUGIN,
2513 CommandCapabilities::SESSION_LIFECYCLE,
2514 ] {
2515 assert_ne!(control, existing, "SESSION_CONTROL must not collide");
2516 }
2517 assert!(!CommandCapabilities::NONE.contains(control));
2518 assert!(control.contains(control));
2519 assert!(
2520 control
2521 .union(CommandCapabilities::PRESENTATION)
2522 .contains(control)
2523 );
2524 assert!(
2525 control
2526 .union(CommandCapabilities::PRESENTATION)
2527 .contains(CommandCapabilities::PRESENTATION)
2528 );
2529 assert!(!CommandCapabilities::SESSION_LIFECYCLE.contains(control));
2530 assert!(!control.contains(CommandCapabilities::SESSION_LIFECYCLE));
2531 // Storage remains u16-backed by construction: bit 14 (1 << 14 = 16384)
2532 // fits the backing `u16` without the speculative widening FEAT-023's
2533 // maintainer review ruled out.
2534 }
2535
2536 /// Deterministic fake control facet: every delegate returns canned portable
2537 /// values or error text so the contract transport is exercised exactly.
2538 #[derive(Default)]
2539 struct FakeControl {
2540 blocked: bool,
2541 relay: Option<RelayProjection>,
2542 resume: Option<Result<ResumeSource, String>>,
2543 import: Option<Result<ResumeImportReceipt, String>>,
2544 sanitized_title: Option<String>,
2545 rename: Option<Result<SessionTitleReceipt, String>>,
2546 title_report: Option<TitleReport>,
2547 set_title: Option<Result<(), String>>,
2548 clear_title: Option<Result<(), String>>,
2549 remote_status: Option<String>,
2550 remote_link: Option<Option<RemoteLink>>,
2551 browser_open: Option<RemoteOpenOutcome>,
2552 start_info: Option<RemoteStartInfo>,
2553 stop_refusal: Option<Option<String>>,
2554 hosted: Option<Option<HostedWorkTarget>>,
2555 }
2556
2557 impl CommandSessionControlContext for FakeControl {
2558 fn transition_blocked(&self) -> bool {
2559 self.blocked
2560 }
2561 fn relay_projection(&self) -> RelayProjection {
2562 self.relay
2563 .clone()
2564 .expect("unexpected relay_projection() on empty fake")
2565 }
2566 fn open_resume_picker(&mut self) {}
2567 fn resolve_resume_source(&mut self, raw: &str) -> Result<ResumeSource, String> {
2568 self.resume.clone().unwrap_or_else(|| {
2569 Err(format!(
2570 "unexpected resolve_resume_source({raw}) on empty fake"
2571 ))
2572 })
2573 }
2574 fn import_session_file(&mut self, path: PathBuf) -> Result<ResumeImportReceipt, String> {
2575 self.import.clone().unwrap_or_else(|| {
2576 Err(format!(
2577 "unexpected import_session_file({path:?}) on empty fake"
2578 ))
2579 })
2580 }
2581 fn sanitize_session_title(&self, raw: &str) -> String {
2582 self.sanitized_title
2583 .clone()
2584 .unwrap_or_else(|| raw.to_string())
2585 }
2586 fn rename_session(&mut self, title: &str) -> Result<SessionTitleReceipt, String> {
2587 self.rename
2588 .clone()
2589 .unwrap_or_else(|| Err(format!("unexpected rename_session({title}) on empty fake")))
2590 }
2591 fn title_report(&self) -> TitleReport {
2592 self.title_report
2593 .clone()
2594 .expect("unexpected title_report() on empty fake")
2595 }
2596 fn set_window_title(&mut self, title: String) -> Result<(), String> {
2597 self.set_title.clone().unwrap_or_else(|| {
2598 Err(format!(
2599 "unexpected set_window_title({title}) on empty fake"
2600 ))
2601 })
2602 }
2603 fn clear_window_title(&mut self) -> Result<(), String> {
2604 self.clear_title
2605 .clone()
2606 .unwrap_or_else(|| Err("unexpected clear_window_title() on empty fake".to_string()))
2607 }
2608 fn remote_status(&self) -> String {
2609 self.remote_status
2610 .clone()
2611 .expect("unexpected remote_status() on empty fake")
2612 }
2613 fn remote_link(&self) -> Option<RemoteLink> {
2614 self.remote_link
2615 .clone()
2616 .expect("unexpected remote_link() on empty fake")
2617 }
2618 fn remote_browser_open(&self) -> RemoteOpenOutcome {
2619 self.browser_open
2620 .clone()
2621 .expect("unexpected remote_browser_open() on empty fake")
2622 }
2623 fn remote_start_info(&self) -> RemoteStartInfo {
2624 self.start_info
2625 .clone()
2626 .expect("unexpected remote_start_info() on empty fake")
2627 }
2628 fn remote_stop_refusal(&self) -> Option<String> {
2629 self.stop_refusal
2630 .clone()
2631 .expect("unexpected remote_stop_refusal() on empty fake")
2632 }
2633 fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget> {
2634 self.hosted
2635 .clone()
2636 .expect("unexpected resolve_hosted_work_target() on empty fake")
2637 }
2638 }
2639
2640 fn control_relay_projection() -> RelayProjection {
2641 RelayProjection {
2642 compact_template: "# Session relay".to_string(),
2643 workspace: "/workspace/control".to_string(),
2644 mode: "operate".to_string(),
2645 model: "control-model".to_string(),
2646 goal_objective: Some("ship the slice".to_string()),
2647 goal_token_budget: Some(42_000),
2648 todos: TodoProjection::Body("- [ ] port relay".to_string()),
2649 plan: PlanProjection::Sections(PlanSections {
2650 title: Some("Plan title".to_string()),
2651 items: vec![PlanStep {
2652 status: PlanStepStatus::InProgress,
2653 text: "port the control slice".to_string(),
2654 }],
2655 ..PlanSections::default()
2656 }),
2657 }
2658 }
2659
2660 #[test]
2661 fn control_facet_is_object_safe_and_transports_every_outcome() {
2662 // Object safety: usable behind a single `dyn` reference.
2663 fn accepts_dyn(_: &dyn CommandSessionControlContext) {}
2664 fn accepts_dyn_mut(_: &mut dyn CommandSessionControlContext) {}
2665
2666 let mut fake = FakeControl {
2667 blocked: true,
2668 relay: Some(control_relay_projection()),
2669 resume: Some(Ok(ResumeSource::Session {
2670 load_path: Some(PathBuf::from("/tmp/sessions/abc123.json")),
2671 truncated_id: "abc123".to_string(),
2672 title: "Control Session".to_string(),
2673 })),
2674 import: Some(Ok(ResumeImportReceipt {
2675 truncated_id: "imp-9".to_string(),
2676 entry_count: 12,
2677 leaf_display: "leaf-3".to_string(),
2678 sync: lifecycle_sync_payload(Some("imp-9")),
2679 })),
2680 sanitized_title: Some("Renamed".to_string()),
2681 rename: Some(Ok(SessionTitleReceipt {
2682 title: "Renamed".to_string(),
2683 })),
2684 title_report: Some(TitleReport {
2685 effective: "task-7".to_string(),
2686 source: TitleSource::Session,
2687 }),
2688 set_title: Some(Ok(())),
2689 clear_title: Some(Ok(())),
2690 remote_status: Some("live".to_string()),
2691 remote_link: Some(Some(RemoteLink {
2692 url: "https://remote.example/s".to_string(),
2693 computer_url: Some("https://remote.example/c".to_string()),
2694 })),
2695 browser_open: Some(RemoteOpenOutcome::Opened {
2696 url: "https://remote.example/s".to_string(),
2697 }),
2698 start_info: Some(RemoteStartInfo { connecting: true }),
2699 stop_refusal: Some(None),
2700 hosted: Some(Some(HostedWorkTarget {
2701 url: "https://app.codewhale.net/work?repo=A%2FB".to_string(),
2702 repo: "A/B".to_string(),
2703 branch: "main".to_string(),
2704 })),
2705 };
2706 accepts_dyn(&fake);
2707 accepts_dyn_mut(&mut fake);
2708
2709 assert!(fake.transition_blocked());
2710 let relay = fake.relay_projection();
2711 assert_eq!(relay.model, "control-model");
2712 assert_eq!(relay.goal_token_budget, Some(42_000));
2713 assert!(matches!(relay.todos, TodoProjection::Body(_)));
2714 match relay.plan {
2715 PlanProjection::Sections(sections) => {
2716 assert_eq!(sections.title.as_deref(), Some("Plan title"));
2717 assert_eq!(sections.items.len(), 1);
2718 assert_eq!(sections.items[0].status, PlanStepStatus::InProgress);
2719 }
2720 other => panic!("expected Sections plan, got {other:?}"),
2721 }
2722 let resolved = fake
2723 .resolve_resume_source("abc123")
2724 .expect("resume resolution ok");
2725 match resolved {
2726 ResumeSource::Session {
2727 load_path, title, ..
2728 } => {
2729 assert_eq!(load_path, Some(PathBuf::from("/tmp/sessions/abc123.json")));
2730 assert_eq!(title, "Control Session");
2731 }
2732 other => panic!("expected Session resolution, got {other:?}"),
2733 }
2734 let imported = fake
2735 .import_session_file(PathBuf::from("/tmp/import.json"))
2736 .expect("import ok");
2737 assert_eq!(imported.truncated_id, "imp-9");
2738 assert_eq!(imported.entry_count, 12);
2739 assert_eq!(imported.leaf_display, "leaf-3");
2740 assert_eq!(fake.sanitize_session_title("raw"), "Renamed");
2741 let renamed = fake.rename_session("Renamed").expect("rename ok");
2742 assert_eq!(renamed.title, "Renamed");
2743 let report = fake.title_report();
2744 assert_eq!(report.effective, "task-7");
2745 assert!(matches!(report.source, TitleSource::Session));
2746 fake.set_window_title("task-7".to_string()).expect("set ok");
2747 fake.clear_window_title().expect("clear ok");
2748 assert_eq!(fake.remote_status(), "live");
2749 let link = fake.remote_link().expect("link present");
2750 assert_eq!(link.url, "https://remote.example/s");
2751 assert!(matches!(
2752 fake.remote_browser_open(),
2753 RemoteOpenOutcome::Opened { .. }
2754 ));
2755 assert!(fake.remote_start_info().connecting);
2756 assert_eq!(fake.remote_stop_refusal(), None);
2757 let hosted = fake.resolve_hosted_work_target().expect("target present");
2758 assert_eq!(hosted.repo, "A/B");
2759 assert_eq!(hosted.branch, "main");
2760 }
2761
2762 #[test]
2763 fn control_error_and_empty_states_transport_exactly() {
2764 let mut fake = FakeControl {
2765 blocked: false,
2766 resume: Some(Err("could not open sessions directory: boom".to_string())),
2767 import: Some(Err(
2768 "File x.json is not a recognized session export".to_string()
2769 )),
2770 rename: Some(Err("Could not save session: boom".to_string())),
2771 set_title: Some(Err("Could not save session: boom".to_string())),
2772 clear_title: Some(Err("Could not save session: boom".to_string())),
2773 remote_link: Some(None),
2774 browser_open: Some(RemoteOpenOutcome::NoLink),
2775 stop_refusal: Some(Some(
2776 "stop refused while a remote turn is active".to_string(),
2777 )),
2778 hosted: Some(None),
2779 ..FakeControl::default()
2780 };
2781 assert!(!fake.transition_blocked());
2782 assert_eq!(
2783 fake.resolve_resume_source("x").unwrap_err(),
2784 "could not open sessions directory: boom"
2785 );
2786 assert_eq!(
2787 fake.import_session_file(PathBuf::from("x.json"))
2788 .unwrap_err(),
2789 "File x.json is not a recognized session export"
2790 );
2791 assert_eq!(
2792 fake.rename_session("t").unwrap_err(),
2793 "Could not save session: boom"
2794 );
2795 assert_eq!(
2796 fake.set_window_title("task".to_string()).unwrap_err(),
2797 "Could not save session: boom"
2798 );
2799 assert_eq!(
2800 fake.clear_window_title().unwrap_err(),
2801 "Could not save session: boom"
2802 );
2803 assert_eq!(fake.remote_link(), None);
2804 assert!(matches!(
2805 fake.remote_browser_open(),
2806 RemoteOpenOutcome::NoLink
2807 ));
2808 assert_eq!(
2809 fake.remote_stop_refusal().as_deref(),
2810 Some("stop refused while a remote turn is active")
2811 );
2812 assert_eq!(fake.resolve_hosted_work_target(), None);
2813
2814 // Empty-state variants: absent to-do/plan and no effective title transport.
2815 fake.relay = Some(RelayProjection {
2816 todos: TodoProjection::Absent,
2817 plan: PlanProjection::Absent,
2818 ..control_relay_projection()
2819 });
2820 let relay = fake.relay_projection();
2821 assert!(matches!(relay.todos, TodoProjection::Absent));
2822 assert!(matches!(relay.plan, PlanProjection::Absent));
2823 fake.title_report = Some(TitleReport {
2824 effective: "unset".to_string(),
2825 source: TitleSource::None,
2826 });
2827 assert!(matches!(fake.title_report().source, TitleSource::None));
2828 fake.clear_title = Some(Ok(()));
2829 fake.clear_window_title().expect("cleared");
2830 }
2831
2832 #[test]
2833 fn envelope_control_slot_is_independent_and_rejects_duplicates() {
2834 let mut first = FakeControl::default();
2835 let mut second = FakeControl::default();
2836 let mut lifecycle = FakeLifecycle::default();
2837
2838 let parts = CommandContexts::empty()
2839 .with_control(&mut first)
2840 .with_lifecycle(&mut lifecycle)
2841 .into_parts();
2842 assert!(
2843 parts.control.is_some(),
2844 "control slot must be present when declared"
2845 );
2846 assert!(
2847 parts.lifecycle.is_some(),
2848 "lifecycle slot may coexist with control"
2849 );
2850 assert!(
2851 parts.session.is_none()
2852 && parts.plugin.is_none()
2853 && parts.skill_group.is_none()
2854 && parts.presentation.is_none(),
2855 "unrelated slots must stay absent (exact exposure)"
2856 );
2857
2858 let bare = CommandContexts::empty().into_parts();
2859 assert!(bare.control.is_none(), "undeclared control stays absent");
2860
2861 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2862 CommandContexts::empty()
2863 .with_control(&mut first)
2864 .with_control(&mut second);
2865 }));
2866 assert!(
2867 result.is_err(),
2868 "duplicate control slot must assert deterministically"
2869 );
2870
2871 // Reading through the dyn facet works after insertion.
2872 first.blocked = true;
2873 let inserted = CommandContexts::empty().with_control(&mut first);
2874 let control = inserted.into_parts().control.expect("inserted control");
2875 assert!(control.transition_blocked());
2876 }
2877
2878 #[test]
2879 fn control_surface_does_not_widen_session_or_lifecycle_facets() {
2880 // The basic session and lifecycle facets still expose exactly their own
2881 // method surface alongside the new control slot: all three may populate an
2882 // envelope at once without colliding, and control does not add behavior to
2883 // the existing facets.
2884 let mut session = Session;
2885 let mut lifecycle = FakeLifecycle::default();
2886 let mut control = FakeControl {
2887 blocked: true,
2888 ..FakeControl::default()
2889 };
2890
2891 let mut parts = CommandContexts::empty()
2892 .with_session(&mut session)
2893 .with_lifecycle(&mut lifecycle)
2894 .with_control(&mut control)
2895 .into_parts();
2896 assert_eq!(
2897 parts.session.as_deref().unwrap().session_id().as_deref(),
2898 Some("session")
2899 );
2900 assert!(!parts.lifecycle.as_deref_mut().unwrap().transition_blocked());
2901 assert!(parts.control.as_deref_mut().unwrap().transition_blocked());
2902 }
2903
2904 // ---------------------------------------------------------------------------
2905 // FEAT-025: session export contract (D1/D3/D5/D6/D7/D8/D9).
2906 // ---------------------------------------------------------------------------
2907
2908 #[test]
2909 fn export_capability_is_stable_distinct_and_non_conflicting() {
2910 let export = CommandCapabilities::SESSION_EXPORT;
2911 let existing = [
2912 CommandCapabilities::SESSION,
2913 CommandCapabilities::MODEL,
2914 CommandCapabilities::COST,
2915 CommandCapabilities::MODE_POLICY,
2916 CommandCapabilities::SYSTEM_PROMPT,
2917 CommandCapabilities::SKILLS,
2918 CommandCapabilities::WORKSPACE,
2919 CommandCapabilities::PRESENTATION,
2920 CommandCapabilities::MEDIA,
2921 CommandCapabilities::MEMORY,
2922 CommandCapabilities::PROJECT,
2923 CommandCapabilities::SKILL_GROUP,
2924 CommandCapabilities::PLUGIN,
2925 CommandCapabilities::SESSION_LIFECYCLE,
2926 CommandCapabilities::SESSION_CONTROL,
2927 ];
2928 let mut union = CommandCapabilities::NONE;
2929 for capability in existing {
2930 assert_ne!(
2931 export, capability,
2932 "SESSION_EXPORT must not collide with an existing capability"
2933 );
2934 union = union.union(capability);
2935 }
2936 assert!(
2937 !union.contains(export),
2938 "SESSION_EXPORT must be a bit outside every existing capability (bits 0-14)"
2939 );
2940 assert!(!CommandCapabilities::NONE.contains(export));
2941 assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
2942 assert!(export.contains(export));
2943 assert!(
2944 export
2945 .union(CommandCapabilities::SESSION_CONTROL)
2946 .contains(export)
2947 );
2948 assert!(
2949 export
2950 .union(CommandCapabilities::SESSION_CONTROL)
2951 .contains(CommandCapabilities::SESSION_CONTROL)
2952 );
2953 assert!(!CommandCapabilities::SESSION_CONTROL.contains(export));
2954 assert!(!export.contains(CommandCapabilities::SESSION_CONTROL));
2955 // FEAT-029 widened storage after bit 15 filled the original space; export
2956 // retains its exact published identity.
2957 assert_eq!(
2958 std::mem::size_of::<CommandCapabilities>(),
2959 std::mem::size_of::<u32>(),
2960 "CommandCapabilities storage must be widened for diagnostics"
2961 );
2962 }
2963
2964 /// Canary: FEAT-029 widened the previously full 16-bit capability space.
2965 ///
2966 /// The first sixteen identities stay published as before; diagnostics takes
2967 /// bit 16 and later slices can allocate independently without renumbering.
2968 #[test]
2969 fn debug_diagnostics_capability_preserves_published_bits() {
2970 let all = [
2971 CommandCapabilities::SESSION,
2972 CommandCapabilities::MODEL,
2973 CommandCapabilities::COST,
2974 CommandCapabilities::MODE_POLICY,
2975 CommandCapabilities::SYSTEM_PROMPT,
2976 CommandCapabilities::SKILLS,
2977 CommandCapabilities::WORKSPACE,
2978 CommandCapabilities::PRESENTATION,
2979 CommandCapabilities::MEDIA,
2980 CommandCapabilities::MEMORY,
2981 CommandCapabilities::PROJECT,
2982 CommandCapabilities::SKILL_GROUP,
2983 CommandCapabilities::PLUGIN,
2984 CommandCapabilities::SESSION_LIFECYCLE,
2985 CommandCapabilities::SESSION_CONTROL,
2986 CommandCapabilities::SESSION_EXPORT,
2987 CommandCapabilities::DEBUG_DIAGNOSTICS,
2988 ];
2989
2990 let mut union = CommandCapabilities::NONE;
2991 for (index, capability) in all.iter().enumerate() {
2992 assert_eq!(
2993 capability.bits_for_test(),
2994 1u32 << index,
2995 "capability {index} must occupy exactly bit {index}"
2996 );
2997 union = union.union(*capability);
2998 }
2999
3000 assert_eq!(
3001 all.len(),
3002 u16::BITS as usize + 1,
3003 "diagnostics is the first bit after the original u16 space"
3004 );
3005 assert_eq!(
3006 union.bits_for_test(),
3007 u32::from(u16::MAX) | (1u32 << 16),
3008 "bits 0-15 retain their published values and diagnostics occupies bit 16"
3009 );
3010 assert!(union.contains(CommandCapabilities::DEBUG_DIAGNOSTICS));
3011 assert!(!CommandCapabilities::SESSION_EXPORT.contains(CommandCapabilities::DEBUG_DIAGNOSTICS));
3012 assert!(!CommandCapabilities::DEBUG_DIAGNOSTICS.contains(CommandCapabilities::SESSION_EXPORT));
3013 assert!(CommandCapabilities::NONE.is_empty());
3014 assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::DEBUG_DIAGNOSTICS));
3015 assert!(!CommandCapabilities::DEBUG_DIAGNOSTICS.contains(CommandCapabilities::NONE));
3016 }
3017
3018 /// Deterministic fake export facet: every delegate returns canned portable
3019 /// values or host error text, and effectful delegates record their calls so a
3020 /// later phase can assert sequencing without a real host.
3021 #[derive(Default)]
3022 struct FakeExport {
3023 projection: Option<ConversationExportProjection>,
3024 turn: Option<TurnHandoffProjection>,
3025 terminal_paste: bool,
3026 recovery: Option<Option<PathBuf>>,
3027 clipboard: Option<Result<(), String>>,
3028 resolved: Option<Result<PathBuf, String>>,
3029 write: Option<Result<(), String>>,
3030 calls: RefCell<Vec<String>>,
3031 }
3032
3033 impl CommandSessionExportContext for FakeExport {
3034 fn conversation_projection(&self) -> ConversationExportProjection {
3035 self.calls
3036 .borrow_mut()
3037 .push("conversation_projection".to_string());
3038 self.projection
3039 .clone()
3040 .expect("unexpected conversation_projection() on empty fake")
3041 }
3042 fn turn_handoff_projection(&self) -> TurnHandoffProjection {
3043 self.calls
3044 .borrow_mut()
3045 .push("turn_handoff_projection".to_string());
3046 self.turn
3047 .clone()
3048 .expect("unexpected turn_handoff_projection() on empty fake")
3049 }
3050 fn clipboard_requires_terminal_paste(&self) -> bool {
3051 self.calls
3052 .borrow_mut()
3053 .push("clipboard_requires_terminal_paste".to_string());
3054 self.terminal_paste
3055 }
3056 fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
3057 self.calls
3058 .borrow_mut()
3059 .push(format!("write_recovery_copy:{markdown}"));
3060 self.recovery
3061 .clone()
3062 .expect("unexpected write_recovery_copy() on empty fake")
3063 }
3064 fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
3065 self.calls
3066 .borrow_mut()
3067 .push(format!("write_clipboard:{markdown}"));
3068 self.clipboard
3069 .clone()
3070 .unwrap_or_else(|| Err("unexpected write_clipboard() on empty fake".to_string()))
3071 }
3072 fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
3073 self.calls
3074 .borrow_mut()
3075 .push(format!("resolve_export_path:{raw}"));
3076 self.resolved.clone().unwrap_or_else(|| {
3077 Err(format!(
3078 "unexpected resolve_export_path({raw}) on empty fake"
3079 ))
3080 })
3081 }
3082 fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
3083 self.calls.borrow_mut().push(format!(
3084 "write_export_file:{}:{}:{force}",
3085 path.display(),
3086 contents.len()
3087 ));
3088 self.write
3089 .clone()
3090 .unwrap_or_else(|| Err("unexpected write_export_file() on empty fake".to_string()))
3091 }
3092 }
3093
3094 fn export_metadata() -> ExportMetadata {
3095 ExportMetadata {
3096 session_label: "abc123".to_string(),
3097 provider: "deepseek".to_string(),
3098 model: "deepseek-chat".to_string(),
3099 mode: "ACT".to_string(),
3100 workspace_name: "workspace".to_string(),
3101 message_count: 2,
3102 exported_at_unix: 1_760_000_000,
3103 }
3104 }
3105
3106 fn export_recorded_snapshot() -> RestoreSnapshot {
3107 RestoreSnapshot {
3108 id: "0123456789abcdef".to_string(),
3109 label: "pre-turn:3: fix parser".to_string(),
3110 timestamp_unix: 1_759_999_000,
3111 kind: "pre-turn".to_string(),
3112 sequence: Some(3),
3113 prompt_snippet: Some("fix parser".to_string()),
3114 }
3115 }
3116
3117 #[test]
3118 fn export_facet_is_object_safe_and_transports_every_outcome() {
3119 // Object safety: usable behind a single `dyn` reference.
3120 fn accepts_dyn(_: &dyn CommandSessionExportContext) {}
3121 fn accepts_dyn_mut(_: &mut dyn CommandSessionExportContext) {}
3122
3123 let mut fake = FakeExport {
3124 projection: Some(ConversationExportProjection {
3125 metadata: export_metadata(),
3126 transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
3127 is_user_role: false,
3128 role: "assistant".to_string(),
3129 prompt_snippet: Some("fix parser".to_string()),
3130 blocks: vec![
3131 ExportBlock::Text {
3132 text: "visible".to_string(),
3133 },
3134 ExportBlock::ImageReference {
3135 url: "https://example.test/a.png".to_string(),
3136 },
3137 ExportBlock::ImageOmitted,
3138 ExportBlock::InternalReasoning,
3139 ExportBlock::ToolCall {
3140 id: "tool-1".to_string(),
3141 name: "read".to_string(),
3142 caller: Some(ToolCallerProjection {
3143 caller_type: "direct".to_string(),
3144 tool_id: Some("caller-1".to_string()),
3145 }),
3146 input: serde_json::json!({"path": "a.txt"}),
3147 },
3148 ExportBlock::ToolResult {
3149 tool_use_id: "tool-1".to_string(),
3150 content: "ok".to_string(),
3151 is_error: false,
3152 structured: Some(serde_json::json!([{"type": "text", "text": "ok"}])),
3153 },
3154 ExportBlock::ServerToolCall {
3155 id: "server-1".to_string(),
3156 name: "web_search".to_string(),
3157 input: serde_json::json!({"q": "rust"}),
3158 },
3159 ExportBlock::ToolSearchResult {
3160 tool_use_id: "search-1".to_string(),
3161 content: serde_json::json!({"results": []}),
3162 },
3163 ExportBlock::CodeExecutionResult {
3164 tool_use_id: "code-1".to_string(),
3165 content: serde_json::json!({"stdout": "hi"}),
3166 },
3167 ],
3168 }]),
3169 restore_points: RestorePointProjection::Recorded {
3170 snapshots: vec![export_recorded_snapshot()],
3171 },
3172 }),
3173 turn: Some(TurnHandoffProjection {
3174 markdown: "# turn handoff".to_string(),
3175 workspace_path: "/workspace/example".to_string(),
3176 }),
3177 terminal_paste: true,
3178 recovery: Some(Some(PathBuf::from(
3179 "/home/u/.codewhale/exports/last-copy.md",
3180 ))),
3181 clipboard: Some(Ok(())),
3182 resolved: Some(Ok(PathBuf::from("/workspace/example/out.md"))),
3183 write: Some(Ok(())),
3184 ..FakeExport::default()
3185 };
3186 accepts_dyn(&fake);
3187 accepts_dyn_mut(&mut fake);
3188
3189 let projection = fake.conversation_projection();
3190 assert_eq!(projection.metadata.session_label, "abc123");
3191 assert_eq!(projection.metadata.provider, "deepseek");
3192 assert_eq!(projection.metadata.model, "deepseek-chat");
3193 assert_eq!(projection.metadata.mode, "ACT");
3194 assert_eq!(projection.metadata.workspace_name, "workspace");
3195 assert_eq!(projection.metadata.message_count, 2);
3196 assert_eq!(projection.metadata.exported_at_unix, 1_760_000_000);
3197 let TranscriptProjection::Authoritative(messages) = projection.transcript else {
3198 panic!("expected authoritative transcript");
3199 };
3200 assert_eq!(messages.len(), 1);
3201 assert_eq!(messages[0].role, "assistant");
3202 assert_eq!(messages[0].prompt_snippet.as_deref(), Some("fix parser"));
3203 assert_eq!(messages[0].blocks.len(), 9);
3204 let ExportBlock::ToolCall {
3205 caller: Some(caller),
3206 input,
3207 ..
3208 } = &messages[0].blocks[4]
3209 else {
3210 panic!("expected tool call with caller");
3211 };
3212 assert_eq!(caller.caller_type, "direct");
3213 assert_eq!(caller.tool_id.as_deref(), Some("caller-1"));
3214 assert_eq!(input["path"], "a.txt");
3215 let ExportBlock::ToolResult {
3216 is_error,
3217 structured,
3218 ..
3219 } = &messages[0].blocks[5]
3220 else {
3221 panic!("expected tool result");
3222 };
3223 assert!(!is_error);
3224 assert!(structured.is_some());
3225 let RestorePointProjection::Recorded { snapshots } = projection.restore_points else {
3226 panic!("expected recorded restore points");
3227 };
3228 assert_eq!(snapshots.len(), 1);
3229 assert_eq!(snapshots[0].id, "0123456789abcdef");
3230 assert_eq!(snapshots[0].label, "pre-turn:3: fix parser");
3231 assert_eq!(snapshots[0].timestamp_unix, 1_759_999_000);
3232 assert_eq!(snapshots[0].kind, "pre-turn");
3233 assert_eq!(snapshots[0].sequence, Some(3));
3234 assert_eq!(snapshots[0].prompt_snippet.as_deref(), Some("fix parser"));
3235
3236 let turn = fake.turn_handoff_projection();
3237 assert_eq!(turn.markdown, "# turn handoff");
3238 assert_eq!(turn.workspace_path, "/workspace/example");
3239
3240 assert!(fake.clipboard_requires_terminal_paste());
3241 assert_eq!(
3242 fake.write_recovery_copy("# md"),
3243 Some(PathBuf::from("/home/u/.codewhale/exports/last-copy.md"))
3244 );
3245 assert!(fake.write_clipboard("# md").is_ok());
3246 assert_eq!(
3247 fake.resolve_export_path("out.md").expect("resolved"),
3248 PathBuf::from("/workspace/example/out.md")
3249 );
3250 assert!(
3251 fake.write_export_file(Path::new("/workspace/example/out.md"), b"# md", false)
3252 .is_ok()
3253 );
3254 // Effectful delegates were exercised exactly once each, in call order.
3255 let expected: Vec<String> = [
3256 "conversation_projection",
3257 "turn_handoff_projection",
3258 "clipboard_requires_terminal_paste",
3259 "write_recovery_copy:# md",
3260 "write_clipboard:# md",
3261 "resolve_export_path:out.md",
3262 "write_export_file:/workspace/example/out.md:4:false",
3263 ]
3264 .into_iter()
3265 .map(str::to_string)
3266 .collect();
3267 assert_eq!(fake.calls.borrow().as_slice(), expected.as_slice());
3268 }
3269
3270 #[test]
3271 fn export_error_and_empty_states_transport_exactly() {
3272 let fake = FakeExport {
3273 projection: Some(ConversationExportProjection {
3274 metadata: export_metadata(),
3275 transcript: TranscriptProjection::HistoryFallback(vec![
3276 HistoryEntry::Sanitized {
3277 role: "user".to_string(),
3278 body: "visible history".to_string(),
3279 },
3280 HistoryEntry::Literal {
3281 role: "system".to_string(),
3282 body: "[internal context omitted]".to_string(),
3283 },
3284 ]),
3285 restore_points: RestorePointProjection::Unreadable {
3286 reason: "permission denied".to_string(),
3287 },
3288 }),
3289 recovery: Some(None),
3290 clipboard: Some(Err("clipboard unavailable".to_string())),
3291 resolved: Some(Err("export paths may not contain `..`".to_string())),
3292 write: Some(Err("destination already exists".to_string())),
3293 ..FakeExport::default()
3294 };
3295
3296 let projection = fake.conversation_projection();
3297 let TranscriptProjection::HistoryFallback(entries) = projection.transcript else {
3298 panic!("expected history fallback");
3299 };
3300 assert_eq!(entries.len(), 2);
3301 assert!(matches!(
3302 &entries[0],
3303 HistoryEntry::Sanitized { role, body }
3304 if role == "user" && body == "visible history"
3305 ));
3306 assert!(matches!(
3307 &entries[1],
3308 HistoryEntry::Literal { role, body }
3309 if role == "system" && body == "[internal context omitted]"
3310 ));
3311 let RestorePointProjection::Unreadable { reason } = projection.restore_points else {
3312 panic!("expected unreadable restore points");
3313 };
3314 assert_eq!(reason, "permission denied");
3315
3316 assert!(!fake.clipboard_requires_terminal_paste());
3317 assert_eq!(fake.write_recovery_copy("# md"), None);
3318 assert_eq!(
3319 fake.write_clipboard("# md").unwrap_err(),
3320 "clipboard unavailable"
3321 );
3322 assert_eq!(
3323 fake.resolve_export_path("../out.md").unwrap_err(),
3324 "export paths may not contain `..`"
3325 );
3326 assert_eq!(
3327 fake.write_export_file(Path::new("/tmp/out.md"), b"x", false)
3328 .unwrap_err(),
3329 "destination already exists"
3330 );
3331 }
3332
3333 #[test]
3334 fn export_projection_distinguishes_restore_states() {
3335 let states = [
3336 RestorePointProjection::None,
3337 RestorePointProjection::Unreadable {
3338 reason: "boom".to_string(),
3339 },
3340 RestorePointProjection::Recorded { snapshots: vec![] },
3341 RestorePointProjection::Recorded {
3342 snapshots: vec![export_recorded_snapshot()],
3343 },
3344 ];
3345 assert!(matches!(&states[0], RestorePointProjection::None));
3346 assert!(matches!(
3347 &states[1],
3348 RestorePointProjection::Unreadable { reason } if reason == "boom"
3349 ));
3350 let RestorePointProjection::Recorded { snapshots } = &states[2] else {
3351 panic!("expected recorded state");
3352 };
3353 assert!(snapshots.is_empty(), "existing-but-empty stays distinct");
3354 let RestorePointProjection::Recorded { snapshots } = &states[3] else {
3355 panic!("expected recorded state");
3356 };
3357 assert_eq!(snapshots.len(), 1);
3358 }
3359
3360 #[test]
3361 fn export_projection_omission_markers_carry_no_hidden_payload() {
3362 // D9: the projection has no field for a reasoning body, reasoning
3363 // signature, or inline/local image payload. Omission markers are data-free
3364 // unit variants, so prohibited payloads cannot be transported even by
3365 // accident.
3366 let block = ExportBlock::InternalReasoning;
3367 let ExportBlock::InternalReasoning = block else {
3368 panic!("internal reasoning must be a payload-free marker");
3369 };
3370 let block = ExportBlock::ImageOmitted;
3371 let ExportBlock::ImageOmitted = block else {
3372 panic!("omitted image must be a payload-free marker");
3373 };
3374
3375 const HIDDEN_REASONING: &str = "signed-thinking-secret-body";
3376 const HIDDEN_SIGNATURE: &str = "sig_1234567890";
3377 const HIDDEN_IMAGE: &str = "data:image/png;base64,QUJD";
3378
3379 let projection = ConversationExportProjection {
3380 metadata: export_metadata(),
3381 transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
3382 is_user_role: false,
3383 role: "assistant".to_string(),
3384 prompt_snippet: None,
3385 blocks: vec![ExportBlock::InternalReasoning, ExportBlock::ImageOmitted],
3386 }]),
3387 restore_points: RestorePointProjection::None,
3388 };
3389 let rendered = format!("{projection:?}");
3390 assert!(!rendered.contains(HIDDEN_REASONING));
3391 assert!(!rendered.contains(HIDDEN_SIGNATURE));
3392 assert!(!rendered.contains(HIDDEN_IMAGE));
3393 assert!(rendered.contains("InternalReasoning"));
3394 assert!(rendered.contains("ImageOmitted"));
3395 }
3396
3397 #[test]
3398 fn envelope_export_slot_is_independent_and_rejects_duplicates() {
3399 let mut first = FakeExport::default();
3400 let mut second = FakeExport::default();
3401 let mut control = FakeControl::default();
3402
3403 let parts = CommandContexts::empty()
3404 .with_export(&mut first)
3405 .with_control(&mut control)
3406 .into_parts();
3407 assert!(
3408 parts.export.is_some(),
3409 "export slot must be present when declared"
3410 );
3411 assert!(
3412 parts.control.is_some(),
3413 "control slot may coexist with export"
3414 );
3415 assert!(
3416 parts.session.is_none()
3417 && parts.lifecycle.is_none()
3418 && parts.plugin.is_none()
3419 && parts.skill_group.is_none(),
3420 "unrelated slots must stay absent (exact exposure)"
3421 );
3422
3423 let bare = CommandContexts::empty().into_parts();
3424 assert!(bare.export.is_none(), "undeclared export stays absent");
3425
3426 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3427 CommandContexts::empty()
3428 .with_export(&mut first)
3429 .with_export(&mut second);
3430 }));
3431 assert!(
3432 result.is_err(),
3433 "duplicate export slot must assert deterministically"
3434 );
3435
3436 // Reading through the dyn facet works after insertion.
3437 let mut projection = FakeExport {
3438 terminal_paste: true,
3439 ..FakeExport::default()
3440 };
3441 let inserted = CommandContexts::empty().with_export(&mut projection);
3442 let export = inserted.into_parts().export.expect("inserted export");
3443 assert!(export.clipboard_requires_terminal_paste());
3444 }
3445
3446 #[test]
3447 fn whole_debug_capabilities_extend_published_bits_without_aliasing_authority() {
3448 let old = (1u32 << 17) - 1;
3449 let capabilities = [
3450 CommandCapabilities::DEBUG_RECEIPTS,
3451 CommandCapabilities::DEBUG_CHANGE,
3452 CommandCapabilities::DEBUG_HISTORY,
3453 CommandCapabilities::DEBUG_DIFF,
3454 CommandCapabilities::DEBUG_UNDO,
3455 ];
3456 let mut seen = old;
3457 for (index, capability) in capabilities.into_iter().enumerate() {
3458 assert_eq!(capability.bits_for_test(), 1 << (17 + index));
3459 assert_eq!(seen & capability.bits_for_test(), 0);
3460 assert!(!capability.contains(CommandCapabilities::DEBUG_DIAGNOSTICS));
3461 seen |= capability.bits_for_test();
3462 }
3463 assert_eq!(seen, (1u32 << 22) - 1);
3464 }
3465
3466 struct StructcopyFixture {
3467 delivery: Result<StructcopyTransport, String>,
3468 writes: RefCell<Vec<String>>,
3469 }
3470 impl CommandSessionStructcopyContext for StructcopyFixture {
3471 fn transcript_item(&self, index: usize) -> Result<StructcopyTranscript, StructcopyError> {
3472 Ok(StructcopyTranscript {
3473 index,
3474 role: "system".into(),
3475 content: StructcopyContent::InternalContext,
3476 })
3477 }
3478 fn tool_pair(&self, _: &str) -> Result<StructcopyToolPair, StructcopyError> {
3479 Ok(StructcopyToolPair {
3480 name: "tool".into(),
3481 input: serde_json::json!({}),
3482 result: None,
3483 })
3484 }
3485 fn plan_snapshot(&self) -> Result<StructcopyPlan, StructcopyError> {
3486 Err(StructcopyError::Busy)
3487 }
3488 fn workflow_projection(&self, _: &str) -> Result<StructcopyWorkflow, StructcopyError> {
3489 Err(StructcopyError::Unavailable)
3490 }
3491 fn path_roots(&self) -> StructcopyPathRoots {
3492 StructcopyPathRoots::default()
3493 }
3494 fn write_clipboard(&self, text: &str) -> Result<StructcopyTransport, String> {
3495 self.writes.borrow_mut().push(text.into());
3496 self.delivery.clone()
3497 }
3498 }
3499
3500 #[test]
3501 fn structcopy_capability_preserves_all_published_identities() {
3502 let capabilities = [
3503 CommandCapabilities::SESSION,
3504 CommandCapabilities::MODEL,
3505 CommandCapabilities::COST,
3506 CommandCapabilities::MODE_POLICY,
3507 CommandCapabilities::SYSTEM_PROMPT,
3508 CommandCapabilities::SKILLS,
3509 CommandCapabilities::WORKSPACE,
3510 CommandCapabilities::PRESENTATION,
3511 CommandCapabilities::MEDIA,
3512 CommandCapabilities::MEMORY,
3513 CommandCapabilities::PROJECT,
3514 CommandCapabilities::SKILL_GROUP,
3515 CommandCapabilities::PLUGIN,
3516 CommandCapabilities::SESSION_LIFECYCLE,
3517 CommandCapabilities::SESSION_CONTROL,
3518 CommandCapabilities::SESSION_EXPORT,
3519 CommandCapabilities::DEBUG_DIAGNOSTICS,
3520 CommandCapabilities::DEBUG_RECEIPTS,
3521 CommandCapabilities::DEBUG_CHANGE,
3522 CommandCapabilities::DEBUG_HISTORY,
3523 CommandCapabilities::DEBUG_DIFF,
3524 CommandCapabilities::DEBUG_UNDO,
3525 CommandCapabilities::SESSION_STRUCTCOPY,
3526 ];
3527 let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION;
3528 for (index, capability) in capabilities.into_iter().enumerate() {
3529 assert_eq!(capability.bits_for_test(), 1u32 << index);
3530 assert_eq!(exact.contains(capability), index == 7 || index == 22);
3531 }
3532 assert!(!exact.contains(CommandCapabilities::NONE));
3533 }
3534
3535 #[test]
3536 fn structcopy_slot_is_optional_and_does_not_grant_other_authority() {
3537 assert!(CommandContexts::empty().into_parts().structcopy.is_none());
3538 let mut fixture = StructcopyFixture {
3539 delivery: Ok(StructcopyTransport::Native),
3540 writes: RefCell::default(),
3541 };
3542 let mut presentation = Presentation;
3543 let ContextParts {
3544 session,
3545 model,
3546 cost,
3547 mode_policy,
3548 system_prompt,
3549 skills,
3550 workspace,
3551 presentation,
3552 media,
3553 memory,
3554 project,
3555 skill_group,
3556 plugin,
3557 lifecycle,
3558 control,
3559 export,
3560 structcopy,
3561 debug_receipts,
3562 debug_change,
3563 debug_history,
3564 debug_diff,
3565 debug_undo,
3566 debug_diagnostics,
3567 } = CommandContexts::empty()
3568 .with_structcopy(&mut fixture)
3569 .with_presentation(&mut presentation)
3570 .into_parts();
3571 assert!(presentation.is_some());
3572 let copy = structcopy.expect("selected structcopy facet");
3573 assert_eq!(
3574 copy.transcript_item(3).unwrap(),
3575 StructcopyTranscript {
3576 index: 3,
3577 role: "system".into(),
3578 content: StructcopyContent::InternalContext
3579 }
3580 );
3581 assert_eq!(copy.tool_pair("id").unwrap().result, None);
3582 assert_eq!(copy.plan_snapshot(), Err(StructcopyError::Busy));
3583 assert_eq!(
3584 copy.workflow_projection("id"),
3585 Err(StructcopyError::Unavailable)
3586 );
3587 for present in [
3588 session.is_some(),
3589 model.is_some(),
3590 cost.is_some(),
3591 mode_policy.is_some(),
3592 system_prompt.is_some(),
3593 skills.is_some(),
3594 workspace.is_some(),
3595 media.is_some(),
3596 memory.is_some(),
3597 project.is_some(),
3598 skill_group.is_some(),
3599 plugin.is_some(),
3600 lifecycle.is_some(),
3601 control.is_some(),
3602 export.is_some(),
3603 debug_receipts.is_some(),
3604 debug_change.is_some(),
3605 debug_history.is_some(),
3606 debug_diff.is_some(),
3607 debug_undo.is_some(),
3608 debug_diagnostics.is_some(),
3609 ] {
3610 assert!(!present);
3611 }
3612 assert!(
3613 fixture.writes.borrow().is_empty(),
3614 "observations do not write clipboard"
3615 );
3616 }
3617
3618 #[test]
3619 #[should_panic(expected = "structcopy facet already set")]
3620 fn structcopy_duplicate_slot_fails_loudly() {
3621 let mut a = StructcopyFixture {
3622 delivery: Ok(StructcopyTransport::Native),
3623 writes: RefCell::default(),
3624 };
3625 let mut b = StructcopyFixture {
3626 delivery: Ok(StructcopyTransport::Native),
3627 writes: RefCell::default(),
3628 };
3629 let _ = CommandContexts::empty()
3630 .with_structcopy(&mut a)
3631 .with_structcopy(&mut b);
3632 }
3633
3634 #[test]
3635 fn structcopy_transport_and_unknown_observations_remain_distinct() {
3636 for delivery in [
3637 Ok(StructcopyTransport::Native),
3638 Ok(StructcopyTransport::TerminalQueued),
3639 Err("original host error".into()),
3640 ] {
3641 let fixture = StructcopyFixture {
3642 delivery: delivery.clone(),
3643 writes: RefCell::default(),
3644 };
3645 assert_eq!(fixture.write_clipboard("exact bytes"), delivery);
3646 assert_eq!(*fixture.writes.borrow(), ["exact bytes"]);
3647 }
3648 let unknown = StructcopyToolResult {
3649 content: "recorded".into(),
3650 is_error: None,
3651 content_blocks: None,
3652 };
3653 assert_ne!(
3654 unknown,
3655 StructcopyToolResult {
3656 is_error: Some(false),
3657 ..unknown.clone()
3658 }
3659 );
3660 assert_ne!(
3661 StructcopyError::Preparation("detail".into()),
3662 StructcopyError::Unavailable
3663 );
3664 let result = crate::outcome::StructcopyCommandResult::error("detail");
3665 assert_eq!(result.message.as_deref(), Some("Error: detail"));
3666 assert!(result.action.is_none());
3667 }
3668
3669 #[test]
3670 fn structcopy_known_projection_fields_preserve_nulls_and_omission_rules() {
3671 let plan: StructcopyPlan = serde_json::from_value(
3672 serde_json::json!({"title":"plan","items":[{"step":"one","status":"in_progress"}]}),
3673 )
3674 .unwrap();
3675 assert_eq!(
3676 serde_json::to_value(plan).unwrap(),
3677 serde_json::json!({"title":"plan","items":[{"step":"one","status":"in_progress"}]})
3678 );
3679 let workflow = serde_json::json!({
3680 "run_id":"run","status":"degraded","lifecycle_seq":1,"started_at_ms":2,"completed_at_ms":null,
3681 "source_file":"flow.js","workflow_id":null,"workflow_goal":null,"token_budget":null,
3682 "child_count":0,"schema_error_count":0,"schema_repair_count":0,"dispatch_failure_count":0,
3683 "progress_count":0,"last_progress":null,"event_count":0,"last_event_type":null,
3684 "leaf_count":null,"branch_count":null,"control_count":null,"execution_status":null,
3685 "gate_count":1,"blocked_gate_count":0,"gate_status":[{"gate_id":"gate","state":"pending"}],
3686 "error":null,"usage":{"tasks_reported":0,"input_tokens":0},"events_dropped":4
3687 });
3688 let parsed: StructcopyWorkflow = serde_json::from_value(workflow.clone()).unwrap();
3689 assert_eq!(parsed.status, StructcopyWorkflowStatus::Degraded);
3690 assert_eq!(parsed.leaf_count, None);
3691 assert_eq!(parsed.usage.as_ref().unwrap().input_tokens, Some(0));
3692 assert_eq!(parsed.usage.as_ref().unwrap().output_tokens, None);
3693 assert_eq!(serde_json::to_value(parsed).unwrap(), workflow);
3694 }
3695
3695 lines RUST