返回 CodeWhale
tests.rs
1 use std::cell::RefCell;
2 use std::path::{Path, PathBuf};
3
4 use codewhale_core::request::{ContentBlock, Message, SystemPrompt};
5 use codewhale_core::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 fn pure(value: Option<&str>) -> String {
149 value.unwrap_or_default().to_owned()
150 }
151 fn contextual(_contexts: CommandContexts<'_>, value: Option<&str>) -> String {
152 value.unwrap_or_default().to_owned()
153 }
154
155 #[test]
156 fn handlers_are_plain_function_pointers() {
157 let pure_handler = CommandHandler::Pure(pure);
158 let contextual_handler = CommandHandler::Contextual {
159 capabilities: CommandCapabilities::NONE,
160 handler: contextual,
161 };
162 match pure_handler {
163 CommandHandler::Pure(handler) => assert_eq!(handler(Some("x")), "x"),
164 _ => unreachable!(),
165 }
166 match contextual_handler {
167 CommandHandler::Contextual {
168 capabilities,
169 handler,
170 } => {
171 assert!(capabilities.is_empty());
172 assert_eq!(handler(CommandContexts::empty(), Some("y")), "y")
173 }
174 _ => unreachable!(),
175 }
176 }
177
178 struct Sample;
179 impl RegisterCommand<String> for Sample {
180 fn info() -> &'static CommandInfo {
181 static INFO: CommandInfo = CommandInfo {
182 name: "sample",
183 aliases: &["s"],
184 usage: "/sample",
185 description_key: "command.sample",
186 };
187 &INFO
188 }
189 fn handler() -> CommandHandler<String> {
190 CommandHandler::Pure(pure)
191 }
192 }
193
194 #[test]
195 fn registration_shape_has_no_app_dependency() {
196 assert_eq!(Sample::info().name, "sample");
197 assert!(matches!(Sample::handler(), CommandHandler::Pure(_)));
198 }
199
200 // ---------------------------------------------------------------------------
201 // FEAT-018: presentation, media, and digest capabilities (D2-D5)
202 // ---------------------------------------------------------------------------
203
204 struct Presentation;
205 impl CommandPresentationContext for Presentation {
206 fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
207 if key == "automation_usage" {
208 return Ok("Usage: /automation [list|show <id>]".to_string());
209 }
210 if key == "mcp_recommended_unknown_id" {
211 let command = replacements
212 .iter()
213 .find(|(name, _)| *name == "recommendations_command")
214 .map(|(_, value)| *value)
215 .unwrap_or("/mcp recommendations");
216 return Ok(format!("Unknown recommended MCP ID (try {command})"));
217 }
218 // D3: unknown keys fail safely without echoing the raw lookup key.
219 Err("unknown translation key".to_string())
220 }
221 }
222
223 struct Media;
224 impl CommandMediaContext for Media {
225 fn attach_media(&mut self, path: &Path) -> Result<MediaAttachmentReceipt, String> {
226 if path.extension().and_then(|ext| ext.to_str()) == Some("png") {
227 Ok(MediaAttachmentReceipt {
228 kind: "image".to_string(),
229 path: path.to_path_buf(),
230 })
231 } else {
232 Err("Unsupported attachment type".to_string())
233 }
234 }
235 }
236
237 struct DigestWorkspace;
238 impl CommandWorkspaceContext for DigestWorkspace {
239 fn workspace(&self) -> PathBuf {
240 PathBuf::from(".")
241 }
242 fn work_state_snapshot(&self) -> Result<Option<String>, String> {
243 Ok(None)
244 }
245 fn operation_digest(&mut self) -> Result<String, String> {
246 Ok("No active operations or to-do items.".to_string())
247 }
248 }
249
250 #[test]
251 fn new_capabilities_are_object_safe_and_independently_transportable() {
252 fn presentation(_: &dyn CommandPresentationContext) {}
253 fn media(_: &dyn CommandMediaContext) {}
254 fn digest_workspace(_: &dyn CommandWorkspaceContext) {}
255
256 presentation(&Presentation);
257 media(&Media);
258 digest_workspace(&DigestWorkspace);
259 fn export(_: &dyn CommandSessionExportContext) {}
260 export(&FakeExport::default());
261
262 let mut presentation = Presentation;
263 let mut media = Media;
264 let mut export = FakeExport::default();
265 let parts = CommandContexts::empty()
266 .with_presentation(&mut presentation)
267 .with_media(&mut media)
268 .with_export(&mut export)
269 .into_parts();
270 assert!(parts.presentation.is_some());
271 assert!(parts.media.is_some());
272 assert!(parts.export.is_some());
273 assert!(parts.session.is_none());
274 }
275
276 #[test]
277 fn translation_contract_resolves_known_keys_and_fails_safely() {
278 let presentation = Presentation;
279 assert_eq!(
280 presentation
281 .translate("automation_usage", &[])
282 .expect("known key"),
283 "Usage: /automation [list|show <id>]"
284 );
285 assert_eq!(
286 presentation
287 .translate(
288 "mcp_recommended_unknown_id",
289 &[("recommendations_command", "/mcp recommendations")],
290 )
291 .expect("known key with named replacement"),
292 "Unknown recommended MCP ID (try /mcp recommendations)"
293 );
294 let unknown = presentation.translate("no_such_key", &[]);
295 assert!(unknown.is_err(), "unknown key must fail safely");
296 let err = unknown.unwrap_err();
297 assert!(
298 !err.contains("no_such_key"),
299 "no raw lookup key exposure (D3)"
300 );
301 }
302
303 #[test]
304 fn media_contract_is_atomic_and_returns_only_portable_data() {
305 let mut media = Media;
306 let ok = media
307 .attach_media(Path::new("/tmp/photo.png"))
308 .expect("png");
309 assert_eq!(ok.kind, "image");
310 assert_eq!(ok.path, PathBuf::from("/tmp/photo.png"));
311
312 let err = media.attach_media(Path::new("/tmp/notes.txt")).unwrap_err();
313 assert!(!err.is_empty(), "safe error string");
314 }
315
316 #[test]
317 fn digest_operation_returns_final_text_and_safe_errors() {
318 let mut workspace = DigestWorkspace;
319 assert_eq!(
320 workspace.operation_digest().expect("digest"),
321 "No active operations or to-do items."
322 );
323 }
324
325 #[test]
326 fn envelope_rejects_duplicate_new_slots_deterministically() {
327 struct SecondPresentation;
328 impl CommandPresentationContext for SecondPresentation {
329 fn translate(&self, _key: &str, _r: &[(&str, &str)]) -> Result<String, String> {
330 Ok(String::new())
331 }
332 }
333 struct SecondMedia;
334 impl CommandMediaContext for SecondMedia {
335 fn attach_media(&mut self, _p: &Path) -> Result<MediaAttachmentReceipt, String> {
336 Err("unused".to_string())
337 }
338 }
339
340 let mut a = Presentation;
341 let mut b = SecondPresentation;
342 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
343 CommandContexts::empty()
344 .with_presentation(&mut a)
345 .with_presentation(&mut b);
346 }));
347 assert!(result.is_err(), "duplicate presentation slot must assert");
348
349 let mut a = Media;
350 let mut b = SecondMedia;
351 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
352 CommandContexts::empty()
353 .with_media(&mut a)
354 .with_media(&mut b);
355 }));
356 assert!(result.is_err(), "duplicate media slot must assert");
357 }
358
359 // ---------------------------------------------------------------------------
360 // Project facet (FEAT-021 D1/D4)
361 // ---------------------------------------------------------------------------
362
363 /// Deterministic fake project facet over portable values only.
364 struct FakeProject {
365 lsp_enabled: bool,
366 share: ProjectShareProjection,
367 goal: ProjectGoalState,
368 }
369
370 impl FakeProject {
371 fn new() -> Self {
372 Self {
373 lsp_enabled: false,
374 share: ProjectShareProjection {
375 history_is_empty: true,
376 history_len: 0,
377 model: "deepseek-chat".to_string(),
378 mode_label: "ACT".to_string(),
379 },
380 goal: ProjectGoalState {
381 objective: Some("Ship FEAT-021".to_string()),
382 status: ProjectGoalStatus::Active,
383 pause_reason: None,
384 started_at_elapsed_seconds: Some(42),
385 time_used_seconds: 42,
386 token_budget: Some(50_000),
387 tokens_used: 1_000,
388 session_total_tokens: 2_000,
389 continuation_count: 3,
390 pending_controls: false,
391 last_known_objective: None,
392 last_known_status: None,
393 conversation_present: true,
394 is_loading: false,
395 goal_continuation_waiting: false,
396 },
397 }
398 }
399 }
400
401 impl CommandProjectContext for FakeProject {
402 fn lsp_enabled(&self) -> bool {
403 self.lsp_enabled
404 }
405
406 fn lsp_set(&mut self, enabled: bool) -> Result<(), String> {
407 self.lsp_enabled = enabled;
408 Ok(())
409 }
410
411 fn share_projection(&self) -> ProjectShareProjection {
412 self.share.clone()
413 }
414
415 fn goal_state(&self) -> ProjectGoalState {
416 self.goal.clone()
417 }
418 }
419
420 // ---------------------------------------------------------------------------
421 // FEAT-019: memory capability, typed outcomes, and workspace scoping (D1-D9)
422 // ---------------------------------------------------------------------------
423
424 /// Deterministic fake memory facet over portable values only. Tracks the
425 /// workspace argument discipline (D8): only workspace-scoped methods receive
426 /// the workspace path.
427 struct FakeMemory {
428 hits: Vec<MemoryHit>,
429 remembered_result: Option<MemoryRemembered>,
430 workspace_id_result: Result<String, String>,
431 }
432
433 impl FakeMemory {
434 fn new() -> Self {
435 Self {
436 hits: vec![MemoryHit {
437 source: PathBuf::from("/mem/source.md"),
438 line_start: 3,
439 line_end: 5,
440 text: "reviewed note".to_string(),
441 }],
442 remembered_result: Some(MemoryRemembered {
443 source: PathBuf::from("/mem/global.md"),
444 line_start: 7,
445 }),
446 workspace_id_result: Ok("owner/repo".to_string()),
447 }
448 }
449 }
450
451 impl CommandMemoryContext for FakeMemory {
452 fn memory_path(&self) -> PathBuf {
453 PathBuf::from("/mem/user-memory.md")
454 }
455
456 fn memory_enabled(&self) -> bool {
457 true
458 }
459
460 fn status(&self) -> Result<MemoryStatus, String> {
461 Ok(MemoryStatus {
462 root: PathBuf::from("/mem/memory"),
463 source: PathBuf::from("/mem/memory/global/global.md"),
464 index: PathBuf::from("/mem/memory/index.db"),
465 })
466 }
467
468 fn path(&self) -> Result<PathBuf, String> {
469 Ok(PathBuf::from("/mem/memory"))
470 }
471
472 fn workspace_id(&self, _workspace: &Path) -> Result<String, String> {
473 self.workspace_id_result.clone()
474 }
475
476 fn search(
477 &self,
478 _workspace: &Path,
479 query: &str,
480 limit: usize,
481 ) -> Result<Vec<MemoryHit>, String> {
482 if query.is_empty() {
483 return Ok(Vec::new());
484 }
485 Ok(self.hits.iter().take(limit).cloned().collect())
486 }
487
488 fn remember(
489 &self,
490 _target: MemoryRememberTarget,
491 note: &str,
492 ) -> Result<MemoryRemembered, String> {
493 if note.is_empty() {
494 return Err("empty note".to_string());
495 }
496 Ok(self.remembered_result.clone().unwrap_or(MemoryRemembered {
497 source: PathBuf::from("/mem/global.md"),
498 line_start: 1,
499 }))
500 }
501
502 fn import(&self) -> Result<MemoryImportOutcome, String> {
503 Ok(MemoryImportOutcome::Skipped)
504 }
505
506 fn get(&self, _workspace: &Path, id: i64) -> Result<MemoryGetOutcome, String> {
507 if id == 42 {
508 Ok(MemoryGetOutcome::Found(self.hits[0].clone()))
509 } else {
510 Ok(MemoryGetOutcome::NotFound)
511 }
512 }
513
514 fn export(&self) -> Result<MemoryExport, String> {
515 Ok(MemoryExport {
516 content: "# memory\n\n- bullet".to_string(),
517 })
518 }
519
520 fn reindex(&self) -> Result<MemoryReindex, String> {
521 Ok(MemoryReindex { entry_count: 3 })
522 }
523
524 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String> {
525 match scope {
526 MemoryDeleteScope::All => Ok(MemoryDelete),
527 MemoryDeleteScope::Global => Ok(MemoryDelete),
528 }
529 }
530
531 fn delete_workspace(&self, _workspace: &Path) -> Result<MemoryDelete, String> {
532 Ok(MemoryDelete)
533 }
534 }
535
536 /// Recording fake that captures remember targets and delete scopes to prove
537 /// the typed target/scope discipline (D2/D8/D9). Interior mutability lets the
538 /// contract-level test assert exactly which operations the handler drives.
539 #[derive(Default)]
540 struct RecordingMemory {
541 remembered_targets: std::cell::RefCell<Vec<MemoryRememberTarget>>,
542 delete_scopes: std::cell::RefCell<Vec<String>>,
543 workspace_deletes: std::cell::Cell<usize>,
544 }
545
546 impl RecordingMemory {
547 fn new() -> Self {
548 Self::default()
549 }
550
551 fn recorded_targets(&self) -> Vec<MemoryRememberTarget> {
552 self.remembered_targets.borrow().clone()
553 }
554
555 fn recorded_delete_scopes(&self) -> Vec<String> {
556 self.delete_scopes.borrow().clone()
557 }
558
559 fn recorded_workspace_deletes(&self) -> usize {
560 self.workspace_deletes.get()
561 }
562 }
563
564 impl CommandMemoryContext for RecordingMemory {
565 fn memory_path(&self) -> PathBuf {
566 PathBuf::from("/mem/user-memory.md")
567 }
568
569 fn memory_enabled(&self) -> bool {
570 true
571 }
572
573 fn status(&self) -> Result<MemoryStatus, String> {
574 unreachable!("recording fake")
575 }
576
577 fn path(&self) -> Result<PathBuf, String> {
578 unreachable!("recording fake")
579 }
580
581 fn workspace_id(&self, _workspace: &Path) -> Result<String, String> {
582 Ok("owner/repo".to_string())
583 }
584
585 fn search(
586 &self,
587 _workspace: &Path,
588 _query: &str,
589 _limit: usize,
590 ) -> Result<Vec<MemoryHit>, String> {
591 unreachable!("recording fake")
592 }
593
594 fn remember(
595 &self,
596 target: MemoryRememberTarget,
597 _note: &str,
598 ) -> Result<MemoryRemembered, String> {
599 self.remembered_targets.borrow_mut().push(target);
600 Ok(MemoryRemembered {
601 source: PathBuf::from("/mem/global.md"),
602 line_start: 1,
603 })
604 }
605
606 fn import(&self) -> Result<MemoryImportOutcome, String> {
607 unreachable!("recording fake")
608 }
609
610 fn get(&self, _workspace: &Path, _id: i64) -> Result<MemoryGetOutcome, String> {
611 unreachable!("recording fake")
612 }
613
614 fn export(&self) -> Result<MemoryExport, String> {
615 unreachable!("recording fake")
616 }
617
618 fn reindex(&self) -> Result<MemoryReindex, String> {
619 unreachable!("recording fake")
620 }
621
622 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String> {
623 self.delete_scopes.borrow_mut().push(match scope {
624 MemoryDeleteScope::All => "all".to_string(),
625 MemoryDeleteScope::Global => "global".to_string(),
626 });
627 Ok(MemoryDelete)
628 }
629
630 fn delete_workspace(&self, _workspace: &Path) -> Result<MemoryDelete, String> {
631 self.workspace_deletes.set(self.workspace_deletes.get() + 1);
632 Ok(MemoryDelete)
633 }
634 }
635
636 #[test]
637 fn project_facet_is_object_safe_and_typed() {
638 fn project(_: &dyn CommandProjectContext) {}
639 project(&FakeProject::new());
640
641 let mut project = FakeProject::new();
642 assert!(!project.lsp_enabled());
643 project.lsp_set(true).unwrap();
644 assert!(project.lsp_enabled());
645 project.lsp_set(false).unwrap();
646 assert!(!project.lsp_enabled());
647 }
648
649 #[test]
650 fn project_share_projection_preserves_semantic_values() {
651 let project = FakeProject::new();
652 let share = project.share_projection();
653 assert!(share.history_is_empty);
654 assert_eq!(share.history_len, 0);
655 assert_eq!(share.model, "deepseek-chat");
656 assert_eq!(share.mode_label, "ACT");
657 }
658
659 #[test]
660 fn project_goal_state_preserves_semantic_values() {
661 let project = FakeProject::new();
662 let goal = project.goal_state();
663 assert_eq!(goal.objective.as_deref(), Some("Ship FEAT-021"));
664 assert_eq!(goal.status, ProjectGoalStatus::Active);
665 assert_eq!(goal.pause_reason, None);
666 assert_eq!(goal.started_at_elapsed_seconds, Some(42));
667 assert_eq!(goal.time_used_seconds, 42);
668 assert_eq!(goal.token_budget, Some(50_000));
669 assert_eq!(goal.tokens_used, 1_000);
670 assert_eq!(goal.session_total_tokens, 2_000);
671 assert_eq!(goal.continuation_count, 3);
672 assert!(!goal.pending_controls);
673 assert_eq!(goal.last_known_objective, None);
674 assert_eq!(goal.last_known_status, None);
675 assert!(goal.conversation_present);
676 assert!(!goal.is_loading);
677 assert!(!goal.goal_continuation_waiting);
678 }
679
680 #[test]
681 fn project_goal_status_variants_are_distinguishable() {
682 let paused = ProjectGoalState {
683 status: ProjectGoalStatus::Paused,
684 pause_reason: Some("user".to_string()),
685 ..FakeProject::new().goal
686 };
687 assert_eq!(paused.status, ProjectGoalStatus::Paused);
688 assert_eq!(paused.pause_reason.as_deref(), Some("user"));
689
690 let complete = ProjectGoalState {
691 status: ProjectGoalStatus::Complete,
692 ..paused
693 };
694 assert_eq!(complete.status, ProjectGoalStatus::Complete);
695 assert_ne!(complete.status, ProjectGoalStatus::Blocked);
696 }
697
698 #[test]
699 fn project_facet_transports_through_envelope_when_declared() {
700 let mut project = FakeProject::new();
701 let parts = CommandContexts::empty()
702 .with_project(&mut project)
703 .into_parts();
704 assert!(parts.project.is_some());
705 assert!(parts.session.is_none());
706
707 // PROJECT combined with WORKSPACE (init) and PRESENTATION (goal).
708 let mut workspace = Workspace;
709 let parts = CommandContexts::empty()
710 .with_project(&mut project)
711 .with_workspace(&mut workspace)
712 .into_parts();
713 assert!(parts.project.is_some());
714 assert!(parts.workspace.is_some());
715 assert!(parts.presentation.is_none());
716 }
717
718 #[test]
719 fn envelope_rejects_duplicate_project_slot_deterministically() {
720 let mut a = FakeProject::new();
721 let mut b = FakeProject::new();
722 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
723 CommandContexts::empty()
724 .with_project(&mut a)
725 .with_project(&mut b);
726 }));
727 assert!(result.is_err(), "duplicate project slot must assert");
728 }
729
730 #[test]
731 fn memory_facet_is_object_safe_and_typed() {
732 fn memory(_: &dyn CommandMemoryContext) {}
733 let fake = FakeMemory::new();
734 memory(&fake);
735
736 assert_eq!(fake.memory_path(), PathBuf::from("/mem/user-memory.md"));
737 assert!(fake.memory_enabled());
738 let status = fake.status().expect("status");
739 assert_eq!(status.root, PathBuf::from("/mem/memory"));
740 assert_eq!(status.source, PathBuf::from("/mem/memory/global/global.md"));
741 assert_eq!(status.index, PathBuf::from("/mem/memory/index.db"));
742 }
743
744 #[test]
745 fn memory_typed_results_preserve_semantic_distinctions() {
746 let fake = FakeMemory::new();
747
748 // Search returns semantic hits, never preformatted messages.
749 let hits = fake.search(Path::new("/ws"), "note", 10).expect("search");
750 assert_eq!(hits.len(), 1);
751 assert_eq!(hits[0].source, PathBuf::from("/mem/source.md"));
752 assert_eq!(hits[0].line_start, 3);
753 assert_eq!(hits[0].line_end, 5);
754 assert_eq!(hits[0].text, "reviewed note");
755 assert!(
756 fake.search(Path::new("/ws"), "", 10)
757 .expect("empty")
758 .is_empty()
759 );
760
761 // Get distinguishes found from not-found without an error string.
762 assert!(matches!(
763 fake.get(Path::new("/ws"), 42),
764 Ok(MemoryGetOutcome::Found(_))
765 ));
766 assert_eq!(
767 fake.get(Path::new("/ws"), 1).expect("get"),
768 MemoryGetOutcome::NotFound
769 );
770
771 // Export carries the raw document, not a command response.
772 let exported = fake.export().expect("export");
773 assert_eq!(exported.content, "# memory\n\n- bullet");
774
775 // Reindex carries the typed count.
776 assert_eq!(fake.reindex().expect("reindex").entry_count, 3);
777
778 // Remember distinguishes global from workspace via the typed target.
779 let global = fake
780 .remember(MemoryRememberTarget::Global, "note")
781 .expect("global remember");
782 assert_eq!(global.source, PathBuf::from("/mem/global.md"));
783 assert_eq!(global.line_start, 7);
784 let workspace = fake
785 .remember(
786 MemoryRememberTarget::Workspace {
787 workspace_id: "owner/repo".to_string(),
788 },
789 "note",
790 )
791 .expect("workspace remember");
792 assert_eq!(workspace.source, PathBuf::from("/mem/global.md"));
793
794 // Import distinguishes imported from skipped.
795 assert_eq!(fake.import().expect("import"), MemoryImportOutcome::Skipped);
796 assert_eq!(
797 MemoryImportOutcome::Imported {
798 destination: PathBuf::from("/mem/global.md")
799 },
800 MemoryImportOutcome::Imported {
801 destination: PathBuf::from("/mem/global.md")
802 }
803 );
804
805 // Remember rejects empty notes with a safe error, never a panic.
806 assert!(fake.remember(MemoryRememberTarget::Global, "").is_err());
807
808 // Zero-field delete outcome stays distinguishable.
809 assert_eq!(fake.delete(MemoryDeleteScope::All), Ok(MemoryDelete));
810 }
811
812 #[test]
813 fn memory_delete_and_remember_targets_are_typed_and_scoped() {
814 let memory = RecordingMemory::new();
815 let _ = memory.delete(MemoryDeleteScope::All);
816 let _ = memory.delete(MemoryDeleteScope::Global);
817 let _ = memory.delete_workspace(Path::new("/ws"));
818 let _ = memory.remember(MemoryRememberTarget::Global, "a");
819 let _ = memory.remember(
820 MemoryRememberTarget::Workspace {
821 workspace_id: "owner/repo".to_string(),
822 },
823 "b",
824 );
825
826 // The non-workspace delete method receives exactly the all/global scopes;
827 // workspace deletion goes through the distinct typed method (D8/D9).
828 assert_eq!(memory.recorded_delete_scopes(), vec!["all", "global"]);
829 assert_eq!(memory.recorded_workspace_deletes(), 1);
830
831 // Remember targets preserve the typed global/workspace distinction.
832 assert_eq!(
833 memory.recorded_targets(),
834 vec![
835 MemoryRememberTarget::Global,
836 MemoryRememberTarget::Workspace {
837 workspace_id: "owner/repo".to_string(),
838 },
839 ]
840 );
841 }
842
843 #[test]
844 fn capabilities_declare_exact_memory_authority() {
845 let workspace = CommandCapabilities::WORKSPACE;
846 let memory = CommandCapabilities::MEMORY;
847 let workspace_memory = workspace.union(memory);
848
849 assert_eq!(
850 workspace_memory,
851 CommandCapabilities::WORKSPACE | CommandCapabilities::MEMORY
852 );
853 assert_ne!(workspace_memory, workspace);
854 assert_ne!(workspace_memory, memory);
855 assert!(workspace_memory.contains(CommandCapabilities::WORKSPACE));
856 assert!(workspace_memory.contains(CommandCapabilities::MEMORY));
857 assert!(!workspace.contains(CommandCapabilities::MEMORY));
858 assert!(!memory.contains(CommandCapabilities::WORKSPACE));
859 assert!(CommandCapabilities::NONE.is_empty());
860 assert!(!workspace_memory.contains(CommandCapabilities::NONE));
861 assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
862 // No presentation or media authority is declared for the memory group.
863 assert!(!workspace_memory.contains(CommandCapabilities::PRESENTATION));
864 assert!(!workspace_memory.contains(CommandCapabilities::MEDIA));
865 // Existing capability identities stay stable.
866 assert_ne!(CommandCapabilities::SESSION, CommandCapabilities::MODEL);
867 }
868
869 #[test]
870 fn memory_facet_transports_through_envelope_when_declared() {
871 let mut memory = FakeMemory::new();
872 let parts = CommandContexts::empty()
873 .with_memory(&mut memory)
874 .into_parts();
875 assert!(parts.memory.is_some());
876 assert!(parts.session.is_none());
877 assert!(parts.workspace.is_none());
878
879 // Undeclared slots stay absent when the memory facet is carried alone.
880 let mut workspace = Workspace;
881 let parts = CommandContexts::empty()
882 .with_memory(&mut memory)
883 .with_workspace(&mut workspace)
884 .into_parts();
885 assert!(parts.memory.is_some());
886 assert!(parts.workspace.is_some());
887 assert!(parts.presentation.is_none());
888 assert!(parts.media.is_none());
889 }
890
891 #[test]
892 fn envelope_rejects_duplicate_memory_slot_deterministically() {
893 let mut a = FakeMemory::new();
894 let mut b = FakeMemory::new();
895 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
896 CommandContexts::empty()
897 .with_memory(&mut a)
898 .with_memory(&mut b);
899 }));
900 assert!(result.is_err(), "duplicate memory slot must assert");
901 }
902
903 // ---------------------------------------------------------------------------
904 // FEAT-020: plugin capability, portable DTOs, and envelope slot (D1-D11)
905 // ---------------------------------------------------------------------------
906
907 /// Deterministic fake plugin facet over portable values only.
908 struct FakePlugin {
909 summaries: Vec<PluginSummary>,
910 detail: Option<PluginDetail>,
911 installed: bool,
912 managed_candidates: Vec<PluginManagedCandidate>,
913 }
914
915 impl FakePlugin {
916 fn new() -> Self {
917 Self {
918 summaries: vec![PluginSummary {
919 name: "demo".to_string(),
920 id: "demo@1.0.0".to_string(),
921 state_label: "active".to_string(),
922 scope: "user".to_string(),
923 trust_status: "trusted".to_string(),
924 compatibility: "full".to_string(),
925 inventory: "skills=1 mcp=0".to_string(),
926 active: true,
927 trusted: true,
928 enabled: true,
929 }],
930 detail: Some(PluginDetail {
931 name: "demo".to_string(),
932 id: "demo@1.0.0".to_string(),
933 inventory_summary: "skills=1 mcp=0".to_string(),
934 version: "1.0.0".to_string(),
935 origin: "local".to_string(),
936 scope: "user".to_string(),
937 state_label: "active".to_string(),
938 trust_status: "trusted".to_string(),
939 compatibility: "full".to_string(),
940 content_hash: "abc".to_string(),
941 capability_hash: "def".to_string(),
942 canonical_root: PathBuf::from("/plugins/demo"),
943 active: true,
944 trusted: true,
945 enabled: true,
946 unsupported_labels: Vec::new(),
947 supported_labels: vec!["skills".to_string()],
948 skills: vec!["demo:demo-skill".to_string()],
949 filesystem_roots: Vec::new(),
950 network_hosts: Vec::new(),
951 stdio_mcp_servers: 0,
952 lifecycle_mutation: false,
953 mcp_servers: Vec::new(),
954 diagnostics: Vec::new(),
955 }),
956 installed: false,
957 managed_candidates: Vec::new(),
958 }
959 }
960 }
961
962 impl CommandPluginContext for FakePlugin {
963 fn summaries(&self) -> Result<Vec<PluginSummary>, String> {
964 Ok(self.summaries.clone())
965 }
966
967 fn detail(&self, selector: &str) -> Result<PluginDetail, String> {
968 if selector == "demo" {
969 self.detail
970 .clone()
971 .ok_or_else(|| "missing detail".to_string())
972 } else {
973 Err(format!("no plugin named {selector}"))
974 }
975 }
976
977 fn registry_diagnostics(&self) -> Vec<PluginDiagnostic> {
978 Vec::new()
979 }
980
981 fn validation_is_clean(&self) -> bool {
982 true
983 }
984
985 fn len(&self) -> usize {
986 self.summaries.len()
987 }
988
989 fn reload(&mut self) -> Result<usize, String> {
990 Ok(self.summaries.len())
991 }
992
993 fn is_empty(&self) -> bool {
994 self.summaries.is_empty()
995 }
996
997 fn reload_nudge(&mut self) -> Option<String> {
998 None
999 }
1000
1001 fn state_path(&self) -> Option<PathBuf> {
1002 Some(PathBuf::from("/plugins/state.json"))
1003 }
1004
1005 fn suggest(&self, task: &str) -> Result<Vec<PluginSuggestion>, String> {
1006 if task.len() < 3 {
1007 return Err("task too short".to_string());
1008 }
1009 Ok(vec![PluginSuggestion {
1010 name: "demo".to_string(),
1011 state_label: "active".to_string(),
1012 description: "Demo bundle".to_string(),
1013 why: vec![task.to_string()],
1014 next_step: "Already active: /plugin show demo".to_string(),
1015 }])
1016 }
1017
1018 fn trust(&mut self, _selector: &str, token: &str) -> Result<(), String> {
1019 if token == "abc.def" {
1020 Ok(())
1021 } else {
1022 Err("Review token does not match this bundle content and capability set".to_string())
1023 }
1024 }
1025
1026 fn enable(&mut self, _selector: &str) -> Result<(), String> {
1027 Ok(())
1028 }
1029
1030 fn disable(&mut self, _selector: &str) -> Result<(), String> {
1031 Ok(())
1032 }
1033
1034 fn revoke_trust(&mut self, _selector: &str) -> Result<(), String> {
1035 Ok(())
1036 }
1037
1038 fn install(
1039 &mut self,
1040 _source: &str,
1041 expected_content_hash: Option<&str>,
1042 ) -> Result<PluginMutationReceipt, String> {
1043 if let Some(expected) = expected_content_hash
1044 && expected != "abc"
1045 {
1046 return Err("content hash mismatch".to_string());
1047 }
1048 self.installed = true;
1049 Ok(PluginMutationReceipt {
1050 name: "demo".to_string(),
1051 path: Some(PathBuf::from("/plugins/demo")),
1052 content_hash: Some("abc".to_string()),
1053 installed_content_hash: Some("abc".to_string()),
1054 outcome: PluginMutationOutcome::Installed,
1055 })
1056 }
1057
1058 fn update(&mut self, _selector: &str) -> Result<PluginMutationReceipt, String> {
1059 Ok(PluginMutationReceipt {
1060 name: "demo".to_string(),
1061 path: None,
1062 content_hash: None,
1063 installed_content_hash: None,
1064 outcome: PluginMutationOutcome::NoChange,
1065 })
1066 }
1067
1068 fn uninstall(&mut self, _selector: &str) -> Result<PluginMutationReceipt, String> {
1069 Ok(PluginMutationReceipt {
1070 name: "demo".to_string(),
1071 path: None,
1072 content_hash: None,
1073 installed_content_hash: None,
1074 outcome: PluginMutationOutcome::Uninstalled,
1075 })
1076 }
1077
1078 fn uninstall_path(&mut self, _name: &str, _plugins_dir: &Path) -> Result<(), String> {
1079 Ok(())
1080 }
1081
1082 fn export(&self, _selector: &str, target: &Path) -> Result<PluginExportReceipt, String> {
1083 Ok(PluginExportReceipt {
1084 exported_name: "demo".to_string(),
1085 target: target.to_path_buf(),
1086 display_name: Some("Demo Bundle".to_string()),
1087 wrote_mcp_json: false,
1088 files_copied: 2,
1089 skills_normalized: false,
1090 })
1091 }
1092
1093 fn legacy_scan(&self) -> Result<Option<PluginLegacyScan>, String> {
1094 Ok(None)
1095 }
1096
1097 fn managed_scan(&self, _home_override: Option<&Path>) -> Result<PluginManagedScan, String> {
1098 Ok(PluginManagedScan {
1099 root: PathBuf::from("/kimi/managed"),
1100 candidates: self.managed_candidates.clone(),
1101 rejected: Vec::new(),
1102 })
1103 }
1104
1105 fn managed_install(
1106 &mut self,
1107 canonical_path: &Path,
1108 expected_content_hash: &str,
1109 ) -> Result<PluginMutationReceipt, String> {
1110 if expected_content_hash != "abc" {
1111 return Err("Kimi candidate changed".to_string());
1112 }
1113 Ok(PluginMutationReceipt {
1114 name: "kimi-demo".to_string(),
1115 path: Some(canonical_path.to_path_buf()),
1116 content_hash: Some("abc".to_string()),
1117 installed_content_hash: Some("abc".to_string()),
1118 outcome: PluginMutationOutcome::Installed,
1119 })
1120 }
1121
1122 fn marketplace_state(&self) -> Result<PluginMarketplaceState, String> {
1123 Ok(PluginMarketplaceState {
1124 official: Some(PluginMarketplaceCatalog {
1125 id: "official".to_string(),
1126 source_path: None,
1127 display_name: None,
1128 description: Some("Built into this release".to_string()),
1129 format: "codewhale".to_string(),
1130 tier: "official".to_string(),
1131 publisher: Some("Codewhale".to_string()),
1132 total_candidates: 1,
1133 warning_count: 0,
1134 candidates: Vec::new(),
1135 diagnostics: Vec::new(),
1136 }),
1137 stored: Vec::new(),
1138 })
1139 }
1140
1141 fn marketplace_add(
1142 &mut self,
1143 name: &str,
1144 _path: &Path,
1145 ) -> Result<PluginMarketplaceAddReceipt, String> {
1146 if name == "official" {
1147 return Err(
1148 "`official` is the catalog built into Codewhale; pick another name.".to_string(),
1149 );
1150 }
1151 Ok(PluginMarketplaceAddReceipt {
1152 name: name.to_string(),
1153 candidate_count: 0,
1154 warning_count: 0,
1155 catalog: PluginMarketplaceCatalog {
1156 id: name.to_string(),
1157 source_path: None,
1158 display_name: None,
1159 description: None,
1160 format: "kimi".to_string(),
1161 tier: "community".to_string(),
1162 publisher: None,
1163 total_candidates: 0,
1164 warning_count: 0,
1165 candidates: Vec::new(),
1166 diagnostics: Vec::new(),
1167 },
1168 })
1169 }
1170
1171 fn marketplace_remove(&mut self, _name: &str) -> Result<bool, String> {
1172 Ok(true)
1173 }
1174
1175 fn marketplace_install(
1176 &mut self,
1177 _catalog: &str,
1178 _candidate: &str,
1179 ) -> Result<PluginMutationReceipt, String> {
1180 Ok(PluginMutationReceipt {
1181 name: "market-demo".to_string(),
1182 path: None,
1183 content_hash: None,
1184 installed_content_hash: None,
1185 outcome: PluginMutationOutcome::Installed,
1186 })
1187 }
1188 }
1189
1190 #[test]
1191 fn plugin_facet_is_object_safe_and_typed() {
1192 fn plugin(_: &dyn CommandPluginContext) {}
1193 plugin(&FakePlugin::new());
1194
1195 let plugin = FakePlugin::new();
1196 assert_eq!(plugin.len(), 1);
1197 assert!(!plugin.is_empty());
1198 assert!(plugin.validation_is_clean());
1199 let summaries = plugin.summaries().unwrap();
1200 assert_eq!(summaries[0].name, "demo");
1201 assert_eq!(summaries[0].state_label, "active");
1202 }
1203
1204 #[test]
1205 fn plugin_detail_preserves_semantic_values() {
1206 let plugin = FakePlugin::new();
1207 let detail = plugin.detail("demo").unwrap();
1208 assert_eq!(detail.content_hash, "abc");
1209 assert_eq!(detail.capability_hash, "def");
1210 assert_eq!(detail.compatibility, "full");
1211 assert!(detail.active);
1212 assert_eq!(detail.skills, vec!["demo:demo-skill"]);
1213 // Unknown selector fails safely.
1214 assert!(plugin.detail("nope").is_err());
1215 }
1216
1217 #[test]
1218 fn plugin_mutation_receipts_distinguish_outcomes() {
1219 let mut plugin = FakePlugin::new();
1220 let installed = plugin.install("path:/demo", Some("abc")).unwrap();
1221 assert_eq!(installed.outcome, PluginMutationOutcome::Installed);
1222 assert_eq!(installed.installed_content_hash.as_deref(), Some("abc"));
1223
1224 // Exact-hash mismatch fails before any install side effect.
1225 let err = plugin.install("path:/demo", Some("wrong")).unwrap_err();
1226 assert!(err.contains("content hash mismatch"));
1227
1228 let uninstalled = plugin.uninstall("demo").unwrap();
1229 assert_eq!(uninstalled.outcome, PluginMutationOutcome::Uninstalled);
1230
1231 plugin.trust("demo", "abc.def").unwrap();
1232 let trust_err = plugin.trust("demo", "bad.token").unwrap_err();
1233 assert!(trust_err.contains("Review token does not match"));
1234 }
1235
1236 #[test]
1237 fn plugin_managed_and_marketplace_values_are_portable() {
1238 let mut plugin = FakePlugin::new();
1239 let scan = plugin.managed_scan(None).unwrap();
1240 assert_eq!(scan.root, PathBuf::from("/kimi/managed"));
1241 assert!(scan.candidates.is_empty());
1242
1243 plugin.managed_candidates.push(PluginManagedCandidate {
1244 name: "kimi-demo".to_string(),
1245 version: "1.0.0".to_string(),
1246 license: Some("MIT".to_string()),
1247 canonical_path: PathBuf::from("/kimi/managed/kimi-demo"),
1248 content_hash: "abc".to_string(),
1249 capability_hash: "def".to_string(),
1250 inventory: "skills=1".to_string(),
1251 applicable: true,
1252 });
1253 let scan = plugin.managed_scan(None).unwrap();
1254 assert_eq!(scan.candidates[0].name, "kimi-demo");
1255 assert_eq!(scan.candidates[0].license.as_deref(), Some("MIT"));
1256
1257 let state = plugin.marketplace_state().unwrap();
1258 let official = state.official.as_ref().expect("fake official catalog");
1259 assert_eq!(official.id, "official");
1260 assert_eq!(official.tier, "official");
1261 assert!(state.stored.is_empty());
1262
1263 let add = plugin
1264 .marketplace_add("custom", Path::new("/catalog.json"))
1265 .unwrap();
1266 assert_eq!(add.name, "custom");
1267 assert_eq!(add.catalog.format, "kimi");
1268
1269 let err = plugin
1270 .marketplace_add("official", Path::new("/x.json"))
1271 .unwrap_err();
1272 assert!(err.contains("built into Codewhale"));
1273 }
1274
1275 #[test]
1276 fn plugin_suggest_is_read_only_and_safe() {
1277 let plugin = FakePlugin::new();
1278 let err = plugin.suggest("ab").unwrap_err();
1279 assert!(err.contains("too short"));
1280 let suggestions = plugin.suggest("translate").unwrap();
1281 assert_eq!(suggestions[0].name, "demo");
1282 assert_eq!(
1283 suggestions[0].next_step,
1284 "Already active: /plugin show demo"
1285 );
1286 }
1287
1288 #[test]
1289 fn plugin_facet_transports_through_envelope_when_declared() {
1290 let mut plugin = FakePlugin::new();
1291 let parts = CommandContexts::empty()
1292 .with_plugin(&mut plugin)
1293 .into_parts();
1294 assert!(parts.plugin.is_some());
1295 assert!(parts.session.is_none());
1296 assert!(parts.memory.is_none());
1297
1298 // Undeclared slots stay absent when the plugin facet is carried alone.
1299 let mut workspace = Workspace;
1300 let parts = CommandContexts::empty()
1301 .with_plugin(&mut plugin)
1302 .with_workspace(&mut workspace)
1303 .into_parts();
1304 assert!(parts.plugin.is_some());
1305 assert!(parts.workspace.is_some());
1306 assert!(parts.presentation.is_none());
1307 }
1308
1309 #[test]
1310 fn envelope_rejects_duplicate_plugin_slot_deterministically() {
1311 let mut a = FakePlugin::new();
1312 let mut b = FakePlugin::new();
1313 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1314 CommandContexts::empty()
1315 .with_plugin(&mut a)
1316 .with_plugin(&mut b);
1317 }));
1318 assert!(result.is_err(), "duplicate plugin slot must assert");
1319 }
1320
1321 #[test]
1322 fn plugin_capability_bit_is_stable_and_distinct() {
1323 let plugin = CommandCapabilities::PLUGIN;
1324 assert_eq!(plugin, CommandCapabilities::PLUGIN);
1325 assert!(plugin.contains(CommandCapabilities::PLUGIN));
1326 assert!(!plugin.contains(CommandCapabilities::MEMORY));
1327 assert!(!plugin.contains(CommandCapabilities::PROJECT));
1328 assert!(!plugin.contains(CommandCapabilities::SKILL_GROUP));
1329 assert!(!plugin.contains(CommandCapabilities::WORKSPACE));
1330
1331 let plugin_workspace = CommandCapabilities::PLUGIN.union(CommandCapabilities::WORKSPACE);
1332 assert!(plugin_workspace.contains(CommandCapabilities::PLUGIN));
1333 assert!(plugin_workspace.contains(CommandCapabilities::WORKSPACE));
1334 assert!(!plugin_workspace.contains(CommandCapabilities::MEMORY));
1335
1336 // The plugin group declares exactly WORKSPACE | PRESENTATION | PLUGIN.
1337 let exact = CommandCapabilities::WORKSPACE
1338 .union(CommandCapabilities::PRESENTATION)
1339 .union(CommandCapabilities::PLUGIN);
1340 assert!(exact.contains(CommandCapabilities::PLUGIN));
1341 assert!(exact.contains(CommandCapabilities::PRESENTATION));
1342 assert!(!exact.contains(CommandCapabilities::MEDIA));
1343 assert!(!exact.contains(CommandCapabilities::MEMORY));
1344 assert!(!exact.contains(CommandCapabilities::PROJECT));
1345 assert!(!exact.contains(CommandCapabilities::SKILL_GROUP));
1346 assert!(!exact.contains(CommandCapabilities::SKILLS));
1347 }
1348
1349 // FEAT-022: skill-group facet (CommandSkillGroupContext)
1350 // ---------------------------------------------------------------------------
1351
1352 struct FakeSkillGroup {
1353 projection: SkillRegistryProjection,
1354 activation_result: Result<SkillActivationOutcome, SkillActivationError>,
1355 receipt: SkillMutationReceipt,
1356 remote: Result<RemoteRegistryOutcome, String>,
1357 sync: Result<SkillSyncOutcome, String>,
1358 review: Result<ReviewOutcome, String>,
1359 snapshots: Vec<SnapshotEntry>,
1360 restore_ok: bool,
1361 approval: CommandApprovalState,
1362 }
1363
1364 impl FakeSkillGroup {
1365 fn new() -> Self {
1366 Self {
1367 projection: SkillRegistryProjection {
1368 workspace: "/ws".into(),
1369 skills_dir: "/ws/.codewhale/skills".into(),
1370 mode_label: "compatible".into(),
1371 dirs: vec!["/ws/.codewhale/skills".into()],
1372 entries: vec![SkillEntry {
1373 name: "demo".into(),
1374 description: "Demo skill".into(),
1375 source: SkillSourceKind::Native,
1376 path: Some("/ws/.codewhale/skills/demo/SKILL.md".into()),
1377 bundled_tier: None,
1378 }],
1379 warnings: vec!["one warning".into()],
1380 total: 1,
1381 },
1382 activation_result: Ok(SkillActivationOutcome {
1383 name: "demo".into(),
1384 description: "Demo skill".into(),
1385 }),
1386 receipt: SkillMutationReceipt {
1387 name: "demo".into(),
1388 safe_target_path: "/ws/.codewhale/skills/demo".into(),
1389 outcome: SkillMutationOutcome::Installed,
1390 },
1391 remote: Ok(RemoteRegistryOutcome::Loaded {
1392 entries: vec![RemoteSkillEntry {
1393 name: "demo".into(),
1394 description: Some("Remote demo".into()),
1395 source: "github.com/acme/skills".into(),
1396 }],
1397 }),
1398 sync: Ok(SkillSyncOutcome::Done {
1399 total: 1,
1400 downloaded: 1,
1401 fresh: 0,
1402 failed: 0,
1403 entries: vec![SkillSyncEntry::Downloaded {
1404 name: "demo".into(),
1405 path: "/cache/demo".into(),
1406 }],
1407 }),
1408 review: Ok(ReviewOutcome::Ready),
1409 snapshots: vec![SnapshotEntry {
1410 id: "abcdef123456".into(),
1411 label: "pre-turn:1".into(),
1412 timestamp: 1_700_000_000,
1413 }],
1414 restore_ok: true,
1415 approval: CommandApprovalState {
1416 yolo: true,
1417 trust_mode: false,
1418 },
1419 }
1420 }
1421 }
1422
1423 impl CommandSkillGroupContext for FakeSkillGroup {
1424 fn skill_registry_projection(&self) -> SkillRegistryProjection {
1425 self.projection.clone()
1426 }
1427
1428 fn activate_skill(
1429 &mut self,
1430 _name: &str,
1431 ) -> Result<SkillActivationOutcome, SkillActivationError> {
1432 self.activation_result.clone()
1433 }
1434
1435 fn install_skill(
1436 &mut self,
1437 _scope: Option<SkillTargetScope>,
1438 _spec: &str,
1439 ) -> Result<SkillMutationReceipt, String> {
1440 Ok(self.receipt.clone())
1441 }
1442
1443 fn update_skill(
1444 &mut self,
1445 _scope: Option<SkillTargetScope>,
1446 _name: &str,
1447 ) -> Result<SkillMutationReceipt, String> {
1448 Ok(self.receipt.clone())
1449 }
1450
1451 fn uninstall_skill(
1452 &mut self,
1453 _scope: Option<SkillTargetScope>,
1454 _name: &str,
1455 ) -> Result<SkillMutationReceipt, String> {
1456 Ok(self.receipt.clone())
1457 }
1458
1459 fn trust_skill(
1460 &mut self,
1461 _scope: Option<SkillTargetScope>,
1462 _name: &str,
1463 ) -> Result<SkillMutationReceipt, String> {
1464 Ok(self.receipt.clone())
1465 }
1466
1467 fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
1468 self.remote.clone()
1469 }
1470
1471 fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String> {
1472 Ok(vec![SkillRecommendation {
1473 name: format!("rec-{task}"),
1474 description: Some("Recommended".into()),
1475 matched_terms: vec!["term".into()],
1476 }])
1477 }
1478
1479 fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
1480 self.sync.clone()
1481 }
1482
1483 fn run_review(&mut self) -> Result<ReviewOutcome, String> {
1484 self.review.clone()
1485 }
1486
1487 fn snapshot_list(&mut self, _limit: usize) -> Result<Vec<SnapshotEntry>, String> {
1488 Ok(self.snapshots.clone())
1489 }
1490
1491 fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
1492 if self.restore_ok {
1493 Ok(())
1494 } else {
1495 Err("Restore failed: boom".into())
1496 }
1497 }
1498
1499 fn approval_state(&self) -> CommandApprovalState {
1500 self.approval
1501 }
1502 }
1503
1504 #[test]
1505 fn skill_group_facet_is_object_safe_and_typed() {
1506 fn project(_: &dyn CommandSkillGroupContext) {}
1507 project(&FakeSkillGroup::new());
1508
1509 let group = FakeSkillGroup::new();
1510 let projection = group.skill_registry_projection();
1511 assert_eq!(projection.total, 1);
1512 assert_eq!(projection.entries[0].name, "demo");
1513 assert!(group.approval_state().yolo);
1514 }
1515
1516 #[test]
1517 fn skill_registry_projection_preserves_semantic_values() {
1518 let group = FakeSkillGroup::new();
1519 let projection = group.skill_registry_projection();
1520 assert_eq!(projection.workspace, "/ws");
1521 assert_eq!(projection.skills_dir, "/ws/.codewhale/skills");
1522 assert_eq!(projection.mode_label, "compatible");
1523 assert_eq!(projection.dirs, vec!["/ws/.codewhale/skills"]);
1524 assert_eq!(projection.warnings, vec!["one warning"]);
1525 assert_eq!(projection.entries.len(), 1);
1526 let entry = &projection.entries[0];
1527 assert_eq!(entry.name, "demo");
1528 assert_eq!(entry.description, "Demo skill");
1529 assert_eq!(entry.source, SkillSourceKind::Native);
1530 assert_eq!(
1531 entry.path.as_deref(),
1532 Some("/ws/.codewhale/skills/demo/SKILL.md")
1533 );
1534 assert_eq!(entry.bundled_tier, None);
1535 }
1536
1537 #[test]
1538 fn skill_bundled_tier_headings_are_stable() {
1539 assert_eq!(SkillBundledTier::CoreAgentic.heading(), "Core agentic");
1540 assert_eq!(
1541 SkillBundledTier::FormatTooling.heading(),
1542 "Format & tooling"
1543 );
1544 }
1545
1546 #[test]
1547 fn skill_mutation_receipt_preserves_outcome_variants() {
1548 let installed = FakeSkillGroup::new().receipt;
1549 assert_eq!(installed.name, "demo");
1550 assert_eq!(installed.outcome, SkillMutationOutcome::Installed);
1551
1552 let denied = SkillMutationReceipt {
1553 outcome: SkillMutationOutcome::NetworkDenied("acme.com".into()),
1554 ..installed.clone()
1555 };
1556 assert_eq!(
1557 denied.outcome,
1558 SkillMutationOutcome::NetworkDenied("acme.com".into())
1559 );
1560
1561 let approval = SkillMutationReceipt {
1562 outcome: SkillMutationOutcome::NeedsApproval("acme.com".into()),
1563 ..installed.clone()
1564 };
1565 assert_eq!(
1566 approval.outcome,
1567 SkillMutationOutcome::NeedsApproval("acme.com".into())
1568 );
1569
1570 assert_ne!(installed.outcome, denied.outcome);
1571 assert_ne!(installed.outcome, approval.outcome);
1572 assert_ne!(denied.outcome, approval.outcome);
1573 }
1574
1575 #[test]
1576 fn skill_source_kind_variants_are_distinguishable() {
1577 let native = SkillSourceKind::Native;
1578 let plugin = SkillSourceKind::Plugin {
1579 plugin_name: "acme".into(),
1580 plugin_id: "acme-1".into(),
1581 };
1582 assert_ne!(native, plugin);
1583 assert_eq!(
1584 plugin,
1585 SkillSourceKind::Plugin {
1586 plugin_name: "acme".into(),
1587 plugin_id: "acme-1".into(),
1588 }
1589 );
1590 }
1591
1592 #[test]
1593 fn remote_registry_outcome_variants_are_distinguishable() {
1594 let loaded = RemoteRegistryOutcome::Loaded {
1595 entries: vec![RemoteSkillEntry {
1596 name: "demo".into(),
1597 description: None,
1598 source: "acme".into(),
1599 }],
1600 };
1601 let approval = RemoteRegistryOutcome::NeedsApproval("acme.com".into());
1602 let denied = RemoteRegistryOutcome::Denied("acme.com".into());
1603 assert_ne!(loaded, approval);
1604 assert_ne!(loaded, denied);
1605 assert_ne!(approval, denied);
1606 }
1607
1608 #[test]
1609 fn skill_sync_outcome_preserves_all_entry_variants() {
1610 let outcome = SkillSyncOutcome::Done {
1611 total: 4,
1612 downloaded: 1,
1613 fresh: 1,
1614 failed: 2,
1615 entries: vec![
1616 SkillSyncEntry::Downloaded {
1617 name: "a".into(),
1618 path: "/cache/a".into(),
1619 },
1620 SkillSyncEntry::Fresh { name: "b".into() },
1621 SkillSyncEntry::Failed {
1622 name: "c".into(),
1623 reason: "boom".into(),
1624 },
1625 SkillSyncEntry::Denied {
1626 name: "d".into(),
1627 host: "acme.com".into(),
1628 },
1629 SkillSyncEntry::NeedsApproval {
1630 name: "e".into(),
1631 host: "acme.com".into(),
1632 },
1633 ],
1634 };
1635 let SkillSyncOutcome::Done {
1636 total,
1637 downloaded,
1638 fresh,
1639 failed,
1640 entries,
1641 } = &outcome
1642 else {
1643 panic!("expected Done");
1644 };
1645 assert_eq!(*total, 4);
1646 assert_eq!(*downloaded, 1);
1647 assert_eq!(*fresh, 1);
1648 assert_eq!(*failed, 2);
1649 assert_eq!(entries.len(), 5);
1650 assert!(matches!(entries[0], SkillSyncEntry::Downloaded { .. }));
1651 assert!(matches!(entries[1], SkillSyncEntry::Fresh { .. }));
1652 assert!(matches!(entries[2], SkillSyncEntry::Failed { .. }));
1653 assert!(matches!(entries[3], SkillSyncEntry::Denied { .. }));
1654 assert!(matches!(entries[4], SkillSyncEntry::NeedsApproval { .. }));
1655 }
1656
1657 #[test]
1658 fn skill_sync_registry_policy_variants_are_distinguishable() {
1659 let approval = SkillSyncOutcome::RegistryNeedsApproval("acme.com".into());
1660 let denied = SkillSyncOutcome::RegistryDenied("acme.com".into());
1661 assert_ne!(approval, denied);
1662 assert!(matches!(
1663 approval,
1664 SkillSyncOutcome::RegistryNeedsApproval(host) if host == "acme.com"
1665 ));
1666 assert!(matches!(
1667 denied,
1668 SkillSyncOutcome::RegistryDenied(host) if host == "acme.com"
1669 ));
1670 }
1671
1672 #[test]
1673 fn skill_activation_error_variants_are_distinguishable() {
1674 let mut group = FakeSkillGroup::new();
1675 group.activation_result = Err(SkillActivationError::NotFound {
1676 requested: "missing".into(),
1677 available: vec!["demo".into()],
1678 warnings: vec![],
1679 });
1680 let not_found = group.activate_skill("missing").unwrap_err();
1681 match &not_found {
1682 SkillActivationError::NotFound {
1683 requested,
1684 available,
1685 ..
1686 } => {
1687 assert_eq!(requested, "missing");
1688 assert_eq!(available, &vec!["demo".to_string()]);
1689 }
1690 _ => panic!("expected NotFound"),
1691 }
1692
1693 let mut group = FakeSkillGroup::new();
1694 group.activation_result = Err(SkillActivationError::PluginRejected {
1695 name: "plug".into(),
1696 reason: "authority revoked".into(),
1697 });
1698 let rejected = group.activate_skill("plug").unwrap_err();
1699 match rejected {
1700 SkillActivationError::PluginRejected { name, reason } => {
1701 assert_eq!(name, "plug");
1702 assert_eq!(reason, "authority revoked");
1703 }
1704 _ => panic!("expected PluginRejected"),
1705 }
1706 }
1707
1708 #[test]
1709 fn review_outcome_variants_are_distinguishable() {
1710 let mut group = FakeSkillGroup::new();
1711 group.review = Ok(ReviewOutcome::NotFound {
1712 skills_dir: "/ws/skills".into(),
1713 global_dir: "/home/u/.codewhale/skills".into(),
1714 warnings: vec!["w".into()],
1715 });
1716 let outcome = group.run_review().unwrap();
1717 match outcome {
1718 ReviewOutcome::NotFound {
1719 skills_dir,
1720 global_dir,
1721 warnings,
1722 } => {
1723 assert_eq!(skills_dir, "/ws/skills");
1724 assert_eq!(global_dir, "/home/u/.codewhale/skills");
1725 assert_eq!(warnings, vec!["w".to_string()]);
1726 }
1727 _ => panic!("expected NotFound"),
1728 }
1729 }
1730
1731 #[test]
1732 fn snapshot_and_approval_values_preserve_semantics() {
1733 let mut group = FakeSkillGroup::new();
1734 let snapshots = group.snapshot_list(20).unwrap();
1735 assert_eq!(snapshots.len(), 1);
1736 assert_eq!(snapshots[0].id, "abcdef123456");
1737 assert_eq!(snapshots[0].label, "pre-turn:1");
1738 assert_eq!(snapshots[0].timestamp, 1_700_000_000);
1739
1740 let approval = group.approval_state();
1741 assert!(approval.yolo);
1742 assert!(!approval.trust_mode);
1743 }
1744
1745 #[test]
1746 fn skill_group_facet_transports_through_envelope_when_declared() {
1747 let mut group = FakeSkillGroup::new();
1748 let parts = CommandContexts::empty()
1749 .with_skill_group(&mut group)
1750 .into_parts();
1751 assert!(parts.skill_group.is_some());
1752 assert!(parts.session.is_none());
1753 assert!(parts.project.is_none());
1754
1755 // /skill combines skill_group with SKILLS for baseline cache refreshes.
1756 let mut skills = Skills;
1757 let parts = CommandContexts::empty()
1758 .with_skill_group(&mut group)
1759 .with_skills(&mut skills)
1760 .into_parts();
1761 assert!(parts.skill_group.is_some());
1762 assert!(parts.skills.is_some());
1763 assert!(parts.workspace.is_none());
1764 }
1765
1766 #[test]
1767 fn envelope_rejects_duplicate_skill_group_slot_deterministically() {
1768 let mut a = FakeSkillGroup::new();
1769 let mut b = FakeSkillGroup::new();
1770 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1771 CommandContexts::empty()
1772 .with_skill_group(&mut a)
1773 .with_skill_group(&mut b);
1774 }));
1775 assert!(result.is_err(), "duplicate skill_group slot must assert");
1776 }
1777
1778 /// Regression: the shared FEAT-015 `CommandSkillsContext` surface is unchanged
1779 /// (getters + cache refresh only, no setter) and still transports through the
1780 /// envelope alongside the new skill-group facet (D2).
1781 #[test]
1782 fn shared_skills_facet_surface_remains_read_only_and_transportable() {
1783 let mut skills = Skills;
1784 let active = skills.active_skill();
1785 assert_eq!(active, None);
1786 assert_eq!(skills.active_skill_provenance(), None);
1787 skills.refresh_skill_cache();
1788
1789 let mut group = FakeSkillGroup::new();
1790 let parts = CommandContexts::empty()
1791 .with_skills(&mut skills)
1792 .with_skill_group(&mut group)
1793 .into_parts();
1794 assert!(parts.skills.is_some());
1795 assert!(parts.skill_group.is_some());
1796 }
1797
1798 // ---------------------------------------------------------------------------
1799 // FEAT-023: session lifecycle contract (D2/D3/D6).
1800 // ---------------------------------------------------------------------------
1801
1802 #[test]
1803 fn lifecycle_capability_is_stable_distinct_and_non_conflicting() {
1804 let lifecycle = CommandCapabilities::SESSION_LIFECYCLE;
1805 for existing in [
1806 CommandCapabilities::NONE,
1807 CommandCapabilities::SESSION,
1808 CommandCapabilities::MODEL,
1809 CommandCapabilities::COST,
1810 CommandCapabilities::MODE_POLICY,
1811 CommandCapabilities::SYSTEM_PROMPT,
1812 CommandCapabilities::SKILLS,
1813 CommandCapabilities::WORKSPACE,
1814 CommandCapabilities::PRESENTATION,
1815 CommandCapabilities::MEDIA,
1816 CommandCapabilities::MEMORY,
1817 CommandCapabilities::PROJECT,
1818 CommandCapabilities::SKILL_GROUP,
1819 CommandCapabilities::PLUGIN,
1820 ] {
1821 assert_ne!(lifecycle, existing, "SESSION_LIFECYCLE must not collide");
1822 }
1823 assert!(!CommandCapabilities::NONE.contains(lifecycle));
1824 assert!(lifecycle.contains(lifecycle));
1825 assert!(
1826 lifecycle
1827 .union(CommandCapabilities::SESSION)
1828 .contains(lifecycle)
1829 );
1830 assert!(
1831 lifecycle
1832 .union(CommandCapabilities::SESSION)
1833 .contains(CommandCapabilities::SESSION)
1834 );
1835 }
1836
1837 /// Deterministic fake lifecycle facet: every delegate returns canned portable
1838 /// values or error text so the contract transport is exercised exactly.
1839 #[derive(Default)]
1840 struct FakeLifecycle {
1841 blocked: bool,
1842 leaf_hint: Option<String>,
1843 branch_outcome: Option<SessionBranchOutcome>,
1844 branch_error: Option<String>,
1845 tree: Option<Result<TreeBodyProjection, String>>,
1846 save: Option<Result<SessionSaveReceipt, String>>,
1847 fork_active: Option<Result<SessionForkReceipt, String>>,
1848 fork_from: Option<Result<SessionForkFromReceipt, String>>,
1849 fresh: Option<Result<SessionNewReceipt, String>>,
1850 load: Option<Result<PathBuf, String>>,
1851 picker: Option<String>,
1852 archived: Option<Result<SessionArchiveReceipt, String>>,
1853 prune: Option<Result<usize, String>>,
1854 }
1855
1856 impl CommandSessionLifecycleContext for FakeLifecycle {
1857 fn transition_blocked(&self) -> bool {
1858 self.blocked
1859 }
1860 fn branch_current_leaf_hint(&self) -> Option<String> {
1861 self.leaf_hint.clone()
1862 }
1863 fn branch_to(&mut self, entry_id: &str) -> Result<SessionBranchOutcome, String> {
1864 if let Some(err) = &self.branch_error {
1865 return Err(err.clone());
1866 }
1867 self.branch_outcome
1868 .clone()
1869 .ok_or_else(|| format!("unexpected branch_to({entry_id}) on empty fake"))
1870 }
1871 fn tree_body(&self) -> Result<TreeBodyProjection, String> {
1872 self.tree
1873 .clone()
1874 .unwrap_or(Ok(TreeBodyProjection::NoSession))
1875 }
1876 fn save_session(
1877 &mut self,
1878 explicit_path: Option<String>,
1879 ) -> Result<SessionSaveReceipt, String> {
1880 self.save
1881 .clone()
1882 .ok_or_else(|| format!("unexpected save_session({explicit_path:?}) on empty fake"))?
1883 }
1884 fn fork_active(&mut self) -> Result<SessionForkReceipt, String> {
1885 self.fork_active
1886 .clone()
1887 .ok_or_else(|| "unexpected fork_active() on empty fake".to_string())?
1888 }
1889 fn fork_from(&mut self, id: &str) -> Result<SessionForkFromReceipt, String> {
1890 self.fork_from
1891 .clone()
1892 .ok_or_else(|| format!("unexpected fork_from({id}) on empty fake"))?
1893 }
1894 fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
1895 self.fresh
1896 .clone()
1897 .ok_or_else(|| format!("unexpected fresh_session({force}) on empty fake"))?
1898 }
1899 fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
1900 self.load
1901 .clone()
1902 .ok_or_else(|| format!("unexpected load_session({path}) on empty fake"))?
1903 }
1904 fn open_picker(&mut self, preselected: Option<String>) {
1905 self.picker = preselected;
1906 }
1907 fn set_archived(
1908 &mut self,
1909 session_id: &str,
1910 archived: bool,
1911 ) -> Result<SessionArchiveReceipt, String> {
1912 self.archived.clone().ok_or_else(|| {
1913 format!("unexpected set_archived({session_id}, {archived}) on empty fake")
1914 })?
1915 }
1916 fn prune_sessions(&mut self, days: u64) -> Result<usize, String> {
1917 self.prune
1918 .clone()
1919 .ok_or_else(|| format!("unexpected prune_sessions({days}) on empty fake"))?
1920 }
1921 }
1922
1923 fn lifecycle_sync_payload(session_id: Option<&str>) -> SessionSyncPayload {
1924 SessionSyncPayload {
1925 session_id: session_id.map(str::to_string),
1926 messages: vec![Message {
1927 role: Role::User,
1928 content: vec![ContentBlock::Text {
1929 text: "hello lifecycle".to_string(),
1930 cache_control: None,
1931 }],
1932 }],
1933 system_prompt: Some(SystemPrompt::Text("prompt".to_string())),
1934 model: "lifecycle-model".to_string(),
1935 workspace: PathBuf::from("/workspace/lifecycle"),
1936 mode: CommandMode::Plan,
1937 }
1938 }
1939
1940 #[test]
1941 fn lifecycle_facet_is_object_safe_and_transports_every_outcome() {
1942 // Object safety: usable behind a single `dyn` reference.
1943 fn accepts_dyn(_: &dyn CommandSessionLifecycleContext) {}
1944 fn accepts_dyn_mut(_: &mut dyn CommandSessionLifecycleContext) {}
1945
1946 let mut fake = FakeLifecycle {
1947 blocked: true,
1948 leaf_hint: Some("entry-42".to_string()),
1949 branch_outcome: Some(SessionBranchOutcome {
1950 leaf_display: "entry-43".to_string(),
1951 journal_entries_before: 7,
1952 sync: lifecycle_sync_payload(Some("branched-session")),
1953 }),
1954 tree: Some(Ok(TreeBodyProjection::Journal {
1955 rendered: "rendered journal".to_string(),
1956 })),
1957 save: Some(Ok(SessionSaveReceipt {
1958 display_path: "/tmp/session.json".to_string(),
1959 truncated_id: "abc123".to_string(),
1960 })),
1961 fork_active: Some(Ok(SessionForkReceipt {
1962 parent_label: "parent".to_string(),
1963 fork_label: "child".to_string(),
1964 sync: lifecycle_sync_payload(Some("child")),
1965 })),
1966 fork_from: Some(Ok(SessionForkFromReceipt {
1967 parent_label: "source".to_string(),
1968 fork_label: "sibling".to_string(),
1969 spawn_depth: 3,
1970 sync: lifecycle_sync_payload(Some("sibling")),
1971 })),
1972 fresh: Some(Ok(SessionNewReceipt {
1973 truncated_id: "new-id".to_string(),
1974 sync: lifecycle_sync_payload(Some("new-id")),
1975 })),
1976 load: Some(Ok(PathBuf::from("/tmp/loaded.json"))),
1977 archived: Some(Ok(SessionArchiveReceipt {
1978 truncated_id: "arch-1".to_string(),
1979 title: "Archive Title".to_string(),
1980 })),
1981 prune: Some(Ok(3)),
1982 ..FakeLifecycle::default()
1983 };
1984 accepts_dyn(&fake);
1985 accepts_dyn_mut(&mut fake);
1986
1987 assert!(fake.transition_blocked());
1988 assert_eq!(fake.branch_current_leaf_hint().as_deref(), Some("entry-42"));
1989 let branch = fake.branch_to("entry-43").expect("branch ok");
1990 assert_eq!(branch.leaf_display, "entry-43");
1991 assert_eq!(branch.journal_entries_before, 7);
1992 assert_eq!(branch.sync.session_id.as_deref(), Some("branched-session"));
1993 assert_eq!(branch.sync.messages.len(), 1);
1994 assert_eq!(branch.sync.mode, CommandMode::Plan);
1995 match fake.tree_body().expect("tree ok") {
1996 TreeBodyProjection::Journal { rendered } => assert_eq!(rendered, "rendered journal"),
1997 other => panic!("expected Journal projection, got {other:?}"),
1998 }
1999 let save = fake
2000 .save_session(Some("/tmp/session.json".to_string()))
2001 .expect("save ok");
2002 assert_eq!(save.display_path, "/tmp/session.json");
2003 assert_eq!(save.truncated_id, "abc123");
2004 let active = fake.fork_active().expect("active fork ok");
2005 assert_eq!(active.parent_label, "parent");
2006 assert_eq!(active.fork_label, "child");
2007 assert_eq!(active.sync.session_id.as_deref(), Some("child"));
2008 assert_eq!(active.sync.messages.len(), 1);
2009 assert_eq!(active.sync.mode, CommandMode::Plan);
2010 let explicit = fake.fork_from("source").expect("explicit fork ok");
2011 assert_eq!(explicit.spawn_depth, 3);
2012 assert_eq!(
2013 explicit.sync.workspace,
2014 PathBuf::from("/workspace/lifecycle")
2015 );
2016 let fresh = fake.fresh_session(true).expect("fresh ok");
2017 assert_eq!(fresh.truncated_id, "new-id");
2018 assert_eq!(fresh.sync.messages.len(), 1);
2019 let loaded = fake.load_session("loaded.json").expect("load ok");
2020 assert_eq!(loaded, PathBuf::from("/tmp/loaded.json"));
2021 fake.open_picker(Some("arch-1".to_string()));
2022 assert_eq!(fake.picker.as_deref(), Some("arch-1"));
2023 let archived = fake.set_archived("arch-1", true).expect("archive ok");
2024 assert_eq!(archived.truncated_id, "arch-1");
2025 assert_eq!(archived.title, "Archive Title");
2026 assert_eq!(fake.prune_sessions(30).expect("prune ok"), 3);
2027 }
2028
2029 #[test]
2030 fn lifecycle_error_text_and_empty_states_transport_exactly() {
2031 let mut fake = FakeLifecycle {
2032 branch_error: Some("could not load session x: boom".to_string()),
2033 tree: Some(Err("could not open sessions directory: boom".to_string())),
2034 save: Some(Err("Failed to save session: boom".to_string())),
2035 load: Some(Err("Failed to read session file: boom".to_string())),
2036 archived: Some(Err("archive failed: boom".to_string())),
2037 prune: Some(Err("prune failed: boom".to_string())),
2038 ..FakeLifecycle::default()
2039 };
2040 assert_eq!(
2041 fake.branch_to("x").unwrap_err(),
2042 "could not load session x: boom"
2043 );
2044 assert_eq!(
2045 fake.tree_body().unwrap_err(),
2046 "could not open sessions directory: boom"
2047 );
2048 assert_eq!(
2049 fake.save_session(None).unwrap_err(),
2050 "Failed to save session: boom"
2051 );
2052 assert_eq!(
2053 fake.load_session("missing.json").unwrap_err(),
2054 "Failed to read session file: boom"
2055 );
2056 assert_eq!(
2057 fake.set_archived("a", false).unwrap_err(),
2058 "archive failed: boom"
2059 );
2060 assert_eq!(fake.prune_sessions(7).unwrap_err(), "prune failed: boom");
2061
2062 let mut empty = FakeLifecycle::default();
2063 assert!(!empty.transition_blocked());
2064 assert_eq!(empty.branch_current_leaf_hint(), None);
2065 assert!(matches!(
2066 empty.tree_body().expect("default tree"),
2067 TreeBodyProjection::NoSession
2068 ));
2069 empty.open_picker(None);
2070 assert_eq!(empty.picker, None);
2071 }
2072
2073 #[test]
2074 fn envelope_lifecycle_slot_is_independent_and_rejects_duplicates() {
2075 let mut first = FakeLifecycle::default();
2076 let mut second = FakeLifecycle::default();
2077
2078 let parts = CommandContexts::empty()
2079 .with_lifecycle(&mut first)
2080 .into_parts();
2081 assert!(
2082 parts.lifecycle.is_some(),
2083 "lifecycle slot must be present when declared"
2084 );
2085 assert!(
2086 parts.session.is_none() && parts.plugin.is_none() && parts.skill_group.is_none(),
2087 "unrelated slots must stay absent (exact exposure)"
2088 );
2089
2090 let bare = CommandContexts::empty().into_parts();
2091 assert!(
2092 bare.lifecycle.is_none(),
2093 "undeclared lifecycle stays absent"
2094 );
2095
2096 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2097 CommandContexts::empty()
2098 .with_lifecycle(&mut first)
2099 .with_lifecycle(&mut second);
2100 }));
2101 assert!(
2102 result.is_err(),
2103 "duplicate lifecycle slot must assert deterministically"
2104 );
2105
2106 // Reading through the dyn facet works after insertion.
2107 first.blocked = true;
2108 let inserted = CommandContexts::empty().with_lifecycle(&mut first);
2109 let lifecycle = inserted.into_parts().lifecycle.expect("inserted lifecycle");
2110 assert!(lifecycle.transition_blocked());
2111 }
2112
2113 // ---------------------------------------------------------------------------
2114 // FEAT-024: session control contract (D2/D3/D6/D7).
2115 // ---------------------------------------------------------------------------
2116
2117 #[test]
2118 fn control_capability_is_stable_distinct_and_non_conflicting() {
2119 let control = CommandCapabilities::SESSION_CONTROL;
2120 for existing in [
2121 CommandCapabilities::NONE,
2122 CommandCapabilities::SESSION,
2123 CommandCapabilities::MODEL,
2124 CommandCapabilities::COST,
2125 CommandCapabilities::MODE_POLICY,
2126 CommandCapabilities::SYSTEM_PROMPT,
2127 CommandCapabilities::SKILLS,
2128 CommandCapabilities::WORKSPACE,
2129 CommandCapabilities::PRESENTATION,
2130 CommandCapabilities::MEDIA,
2131 CommandCapabilities::MEMORY,
2132 CommandCapabilities::PROJECT,
2133 CommandCapabilities::SKILL_GROUP,
2134 CommandCapabilities::PLUGIN,
2135 CommandCapabilities::SESSION_LIFECYCLE,
2136 ] {
2137 assert_ne!(control, existing, "SESSION_CONTROL must not collide");
2138 }
2139 assert!(!CommandCapabilities::NONE.contains(control));
2140 assert!(control.contains(control));
2141 assert!(
2142 control
2143 .union(CommandCapabilities::PRESENTATION)
2144 .contains(control)
2145 );
2146 assert!(
2147 control
2148 .union(CommandCapabilities::PRESENTATION)
2149 .contains(CommandCapabilities::PRESENTATION)
2150 );
2151 assert!(!CommandCapabilities::SESSION_LIFECYCLE.contains(control));
2152 assert!(!control.contains(CommandCapabilities::SESSION_LIFECYCLE));
2153 // Storage remains u16-backed by construction: bit 14 (1 << 14 = 16384)
2154 // fits the backing `u16` without the speculative widening FEAT-023's
2155 // maintainer review ruled out.
2156 }
2157
2158 /// Deterministic fake control facet: every delegate returns canned portable
2159 /// values or error text so the contract transport is exercised exactly.
2160 #[derive(Default)]
2161 struct FakeControl {
2162 blocked: bool,
2163 relay: Option<RelayProjection>,
2164 resume: Option<Result<ResumeSource, String>>,
2165 import: Option<Result<ResumeImportReceipt, String>>,
2166 sanitized_title: Option<String>,
2167 rename: Option<Result<SessionTitleReceipt, String>>,
2168 title_report: Option<TitleReport>,
2169 set_title: Option<Result<(), String>>,
2170 clear_title: Option<Result<(), String>>,
2171 remote_status: Option<String>,
2172 remote_link: Option<Option<RemoteLink>>,
2173 browser_open: Option<RemoteOpenOutcome>,
2174 start_info: Option<RemoteStartInfo>,
2175 stop_refusal: Option<Option<String>>,
2176 hosted: Option<Option<HostedWorkTarget>>,
2177 }
2178
2179 impl CommandSessionControlContext for FakeControl {
2180 fn transition_blocked(&self) -> bool {
2181 self.blocked
2182 }
2183 fn relay_projection(&self) -> RelayProjection {
2184 self.relay
2185 .clone()
2186 .expect("unexpected relay_projection() on empty fake")
2187 }
2188 fn open_resume_picker(&mut self) {}
2189 fn resolve_resume_source(&mut self, raw: &str) -> Result<ResumeSource, String> {
2190 self.resume.clone().unwrap_or_else(|| {
2191 Err(format!(
2192 "unexpected resolve_resume_source({raw}) on empty fake"
2193 ))
2194 })
2195 }
2196 fn import_session_file(&mut self, path: PathBuf) -> Result<ResumeImportReceipt, String> {
2197 self.import.clone().unwrap_or_else(|| {
2198 Err(format!(
2199 "unexpected import_session_file({path:?}) on empty fake"
2200 ))
2201 })
2202 }
2203 fn sanitize_session_title(&self, raw: &str) -> String {
2204 self.sanitized_title
2205 .clone()
2206 .unwrap_or_else(|| raw.to_string())
2207 }
2208 fn rename_session(&mut self, title: &str) -> Result<SessionTitleReceipt, String> {
2209 self.rename
2210 .clone()
2211 .unwrap_or_else(|| Err(format!("unexpected rename_session({title}) on empty fake")))
2212 }
2213 fn title_report(&self) -> TitleReport {
2214 self.title_report
2215 .clone()
2216 .expect("unexpected title_report() on empty fake")
2217 }
2218 fn set_window_title(&mut self, title: String) -> Result<(), String> {
2219 self.set_title.clone().unwrap_or_else(|| {
2220 Err(format!(
2221 "unexpected set_window_title({title}) on empty fake"
2222 ))
2223 })
2224 }
2225 fn clear_window_title(&mut self) -> Result<(), String> {
2226 self.clear_title
2227 .clone()
2228 .unwrap_or_else(|| Err("unexpected clear_window_title() on empty fake".to_string()))
2229 }
2230 fn remote_status(&self) -> String {
2231 self.remote_status
2232 .clone()
2233 .expect("unexpected remote_status() on empty fake")
2234 }
2235 fn remote_link(&self) -> Option<RemoteLink> {
2236 self.remote_link
2237 .clone()
2238 .expect("unexpected remote_link() on empty fake")
2239 }
2240 fn remote_browser_open(&self) -> RemoteOpenOutcome {
2241 self.browser_open
2242 .clone()
2243 .expect("unexpected remote_browser_open() on empty fake")
2244 }
2245 fn remote_start_info(&self) -> RemoteStartInfo {
2246 self.start_info
2247 .clone()
2248 .expect("unexpected remote_start_info() on empty fake")
2249 }
2250 fn remote_stop_refusal(&self) -> Option<String> {
2251 self.stop_refusal
2252 .clone()
2253 .expect("unexpected remote_stop_refusal() on empty fake")
2254 }
2255 fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget> {
2256 self.hosted
2257 .clone()
2258 .expect("unexpected resolve_hosted_work_target() on empty fake")
2259 }
2260 }
2261
2262 fn control_relay_projection() -> RelayProjection {
2263 RelayProjection {
2264 compact_template: "# Session relay".to_string(),
2265 workspace: "/workspace/control".to_string(),
2266 mode: "operate".to_string(),
2267 model: "control-model".to_string(),
2268 goal_objective: Some("ship the slice".to_string()),
2269 goal_token_budget: Some(42_000),
2270 todos: TodoProjection::Body("- [ ] port relay".to_string()),
2271 plan: PlanProjection::Sections(PlanSections {
2272 title: Some("Plan title".to_string()),
2273 items: vec![PlanStep {
2274 status: PlanStepStatus::InProgress,
2275 text: "port the control slice".to_string(),
2276 }],
2277 ..PlanSections::default()
2278 }),
2279 }
2280 }
2281
2282 #[test]
2283 fn control_facet_is_object_safe_and_transports_every_outcome() {
2284 // Object safety: usable behind a single `dyn` reference.
2285 fn accepts_dyn(_: &dyn CommandSessionControlContext) {}
2286 fn accepts_dyn_mut(_: &mut dyn CommandSessionControlContext) {}
2287
2288 let mut fake = FakeControl {
2289 blocked: true,
2290 relay: Some(control_relay_projection()),
2291 resume: Some(Ok(ResumeSource::Session {
2292 load_path: Some(PathBuf::from("/tmp/sessions/abc123.json")),
2293 truncated_id: "abc123".to_string(),
2294 title: "Control Session".to_string(),
2295 })),
2296 import: Some(Ok(ResumeImportReceipt {
2297 truncated_id: "imp-9".to_string(),
2298 entry_count: 12,
2299 leaf_display: "leaf-3".to_string(),
2300 sync: lifecycle_sync_payload(Some("imp-9")),
2301 })),
2302 sanitized_title: Some("Renamed".to_string()),
2303 rename: Some(Ok(SessionTitleReceipt {
2304 title: "Renamed".to_string(),
2305 })),
2306 title_report: Some(TitleReport {
2307 effective: "task-7".to_string(),
2308 source: TitleSource::Session,
2309 }),
2310 set_title: Some(Ok(())),
2311 clear_title: Some(Ok(())),
2312 remote_status: Some("live".to_string()),
2313 remote_link: Some(Some(RemoteLink {
2314 url: "https://remote.example/s".to_string(),
2315 computer_url: Some("https://remote.example/c".to_string()),
2316 })),
2317 browser_open: Some(RemoteOpenOutcome::Opened {
2318 url: "https://remote.example/s".to_string(),
2319 }),
2320 start_info: Some(RemoteStartInfo { connecting: true }),
2321 stop_refusal: Some(None),
2322 hosted: Some(Some(HostedWorkTarget {
2323 url: "https://app.codewhale.net/work?repo=A%2FB".to_string(),
2324 repo: "A/B".to_string(),
2325 branch: "main".to_string(),
2326 })),
2327 };
2328 accepts_dyn(&fake);
2329 accepts_dyn_mut(&mut fake);
2330
2331 assert!(fake.transition_blocked());
2332 let relay = fake.relay_projection();
2333 assert_eq!(relay.model, "control-model");
2334 assert_eq!(relay.goal_token_budget, Some(42_000));
2335 assert!(matches!(relay.todos, TodoProjection::Body(_)));
2336 match relay.plan {
2337 PlanProjection::Sections(sections) => {
2338 assert_eq!(sections.title.as_deref(), Some("Plan title"));
2339 assert_eq!(sections.items.len(), 1);
2340 assert_eq!(sections.items[0].status, PlanStepStatus::InProgress);
2341 }
2342 other => panic!("expected Sections plan, got {other:?}"),
2343 }
2344 let resolved = fake
2345 .resolve_resume_source("abc123")
2346 .expect("resume resolution ok");
2347 match resolved {
2348 ResumeSource::Session {
2349 load_path, title, ..
2350 } => {
2351 assert_eq!(load_path, Some(PathBuf::from("/tmp/sessions/abc123.json")));
2352 assert_eq!(title, "Control Session");
2353 }
2354 other => panic!("expected Session resolution, got {other:?}"),
2355 }
2356 let imported = fake
2357 .import_session_file(PathBuf::from("/tmp/import.json"))
2358 .expect("import ok");
2359 assert_eq!(imported.truncated_id, "imp-9");
2360 assert_eq!(imported.entry_count, 12);
2361 assert_eq!(imported.leaf_display, "leaf-3");
2362 assert_eq!(fake.sanitize_session_title("raw"), "Renamed");
2363 let renamed = fake.rename_session("Renamed").expect("rename ok");
2364 assert_eq!(renamed.title, "Renamed");
2365 let report = fake.title_report();
2366 assert_eq!(report.effective, "task-7");
2367 assert!(matches!(report.source, TitleSource::Session));
2368 fake.set_window_title("task-7".to_string()).expect("set ok");
2369 fake.clear_window_title().expect("clear ok");
2370 assert_eq!(fake.remote_status(), "live");
2371 let link = fake.remote_link().expect("link present");
2372 assert_eq!(link.url, "https://remote.example/s");
2373 assert!(matches!(
2374 fake.remote_browser_open(),
2375 RemoteOpenOutcome::Opened { .. }
2376 ));
2377 assert!(fake.remote_start_info().connecting);
2378 assert_eq!(fake.remote_stop_refusal(), None);
2379 let hosted = fake.resolve_hosted_work_target().expect("target present");
2380 assert_eq!(hosted.repo, "A/B");
2381 assert_eq!(hosted.branch, "main");
2382 }
2383
2384 #[test]
2385 fn control_error_and_empty_states_transport_exactly() {
2386 let mut fake = FakeControl {
2387 blocked: false,
2388 resume: Some(Err("could not open sessions directory: boom".to_string())),
2389 import: Some(Err(
2390 "File x.json is not a recognized session export".to_string()
2391 )),
2392 rename: Some(Err("Could not save session: boom".to_string())),
2393 set_title: Some(Err("Could not save session: boom".to_string())),
2394 clear_title: Some(Err("Could not save session: boom".to_string())),
2395 remote_link: Some(None),
2396 browser_open: Some(RemoteOpenOutcome::NoLink),
2397 stop_refusal: Some(Some(
2398 "stop refused while a remote turn is active".to_string(),
2399 )),
2400 hosted: Some(None),
2401 ..FakeControl::default()
2402 };
2403 assert!(!fake.transition_blocked());
2404 assert_eq!(
2405 fake.resolve_resume_source("x").unwrap_err(),
2406 "could not open sessions directory: boom"
2407 );
2408 assert_eq!(
2409 fake.import_session_file(PathBuf::from("x.json"))
2410 .unwrap_err(),
2411 "File x.json is not a recognized session export"
2412 );
2413 assert_eq!(
2414 fake.rename_session("t").unwrap_err(),
2415 "Could not save session: boom"
2416 );
2417 assert_eq!(
2418 fake.set_window_title("task".to_string()).unwrap_err(),
2419 "Could not save session: boom"
2420 );
2421 assert_eq!(
2422 fake.clear_window_title().unwrap_err(),
2423 "Could not save session: boom"
2424 );
2425 assert_eq!(fake.remote_link(), None);
2426 assert!(matches!(
2427 fake.remote_browser_open(),
2428 RemoteOpenOutcome::NoLink
2429 ));
2430 assert_eq!(
2431 fake.remote_stop_refusal().as_deref(),
2432 Some("stop refused while a remote turn is active")
2433 );
2434 assert_eq!(fake.resolve_hosted_work_target(), None);
2435
2436 // Empty-state variants: absent to-do/plan and no effective title transport.
2437 fake.relay = Some(RelayProjection {
2438 todos: TodoProjection::Absent,
2439 plan: PlanProjection::Absent,
2440 ..control_relay_projection()
2441 });
2442 let relay = fake.relay_projection();
2443 assert!(matches!(relay.todos, TodoProjection::Absent));
2444 assert!(matches!(relay.plan, PlanProjection::Absent));
2445 fake.title_report = Some(TitleReport {
2446 effective: "unset".to_string(),
2447 source: TitleSource::None,
2448 });
2449 assert!(matches!(fake.title_report().source, TitleSource::None));
2450 fake.clear_title = Some(Ok(()));
2451 fake.clear_window_title().expect("cleared");
2452 }
2453
2454 #[test]
2455 fn envelope_control_slot_is_independent_and_rejects_duplicates() {
2456 let mut first = FakeControl::default();
2457 let mut second = FakeControl::default();
2458 let mut lifecycle = FakeLifecycle::default();
2459
2460 let parts = CommandContexts::empty()
2461 .with_control(&mut first)
2462 .with_lifecycle(&mut lifecycle)
2463 .into_parts();
2464 assert!(
2465 parts.control.is_some(),
2466 "control slot must be present when declared"
2467 );
2468 assert!(
2469 parts.lifecycle.is_some(),
2470 "lifecycle slot may coexist with control"
2471 );
2472 assert!(
2473 parts.session.is_none()
2474 && parts.plugin.is_none()
2475 && parts.skill_group.is_none()
2476 && parts.presentation.is_none(),
2477 "unrelated slots must stay absent (exact exposure)"
2478 );
2479
2480 let bare = CommandContexts::empty().into_parts();
2481 assert!(bare.control.is_none(), "undeclared control stays absent");
2482
2483 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2484 CommandContexts::empty()
2485 .with_control(&mut first)
2486 .with_control(&mut second);
2487 }));
2488 assert!(
2489 result.is_err(),
2490 "duplicate control slot must assert deterministically"
2491 );
2492
2493 // Reading through the dyn facet works after insertion.
2494 first.blocked = true;
2495 let inserted = CommandContexts::empty().with_control(&mut first);
2496 let control = inserted.into_parts().control.expect("inserted control");
2497 assert!(control.transition_blocked());
2498 }
2499
2500 #[test]
2501 fn control_surface_does_not_widen_session_or_lifecycle_facets() {
2502 // The basic session and lifecycle facets still expose exactly their own
2503 // method surface alongside the new control slot: all three may populate an
2504 // envelope at once without colliding, and control does not add behavior to
2505 // the existing facets.
2506 let mut session = Session;
2507 let mut lifecycle = FakeLifecycle::default();
2508 let mut control = FakeControl {
2509 blocked: true,
2510 ..FakeControl::default()
2511 };
2512
2513 let mut parts = CommandContexts::empty()
2514 .with_session(&mut session)
2515 .with_lifecycle(&mut lifecycle)
2516 .with_control(&mut control)
2517 .into_parts();
2518 assert_eq!(
2519 parts.session.as_deref().unwrap().session_id().as_deref(),
2520 Some("session")
2521 );
2522 assert!(!parts.lifecycle.as_deref_mut().unwrap().transition_blocked());
2523 assert!(parts.control.as_deref_mut().unwrap().transition_blocked());
2524 }
2525
2526 // ---------------------------------------------------------------------------
2527 // FEAT-025: session export contract (D1/D3/D5/D6/D7/D8/D9).
2528 // ---------------------------------------------------------------------------
2529
2530 #[test]
2531 fn export_capability_is_stable_distinct_and_non_conflicting() {
2532 let export = CommandCapabilities::SESSION_EXPORT;
2533 let existing = [
2534 CommandCapabilities::SESSION,
2535 CommandCapabilities::MODEL,
2536 CommandCapabilities::COST,
2537 CommandCapabilities::MODE_POLICY,
2538 CommandCapabilities::SYSTEM_PROMPT,
2539 CommandCapabilities::SKILLS,
2540 CommandCapabilities::WORKSPACE,
2541 CommandCapabilities::PRESENTATION,
2542 CommandCapabilities::MEDIA,
2543 CommandCapabilities::MEMORY,
2544 CommandCapabilities::PROJECT,
2545 CommandCapabilities::SKILL_GROUP,
2546 CommandCapabilities::PLUGIN,
2547 CommandCapabilities::SESSION_LIFECYCLE,
2548 CommandCapabilities::SESSION_CONTROL,
2549 ];
2550 let mut union = CommandCapabilities::NONE;
2551 for capability in existing {
2552 assert_ne!(
2553 export, capability,
2554 "SESSION_EXPORT must not collide with an existing capability"
2555 );
2556 union = union.union(capability);
2557 }
2558 assert!(
2559 !union.contains(export),
2560 "SESSION_EXPORT must be a bit outside every existing capability (bits 0-14)"
2561 );
2562 assert!(!CommandCapabilities::NONE.contains(export));
2563 assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE));
2564 assert!(export.contains(export));
2565 assert!(
2566 export
2567 .union(CommandCapabilities::SESSION_CONTROL)
2568 .contains(export)
2569 );
2570 assert!(
2571 export
2572 .union(CommandCapabilities::SESSION_CONTROL)
2573 .contains(CommandCapabilities::SESSION_CONTROL)
2574 );
2575 assert!(!CommandCapabilities::SESSION_CONTROL.contains(export));
2576 assert!(!export.contains(CommandCapabilities::SESSION_CONTROL));
2577 // Storage remains `u16`-backed: bit 15 (1 << 15 = 32768) fits without the
2578 // speculative widening FEAT-023's maintainer review ruled out.
2579 assert_eq!(
2580 std::mem::size_of::<CommandCapabilities>(),
2581 std::mem::size_of::<u16>(),
2582 "CommandCapabilities storage must stay u16"
2583 );
2584 }
2585
2586 /// Canary: after FEAT-025 the `u16` capability space is *exactly* full.
2587 ///
2588 /// This is deliberate capacity documentation, not a health check. When FEAT-026
2589 /// (session structcopy) adds its own facet it must widen the backing storage to
2590 /// `u32`, and this test is expected to be updated in that commit. Until then it
2591 /// guarantees that no capability bit is silently reused, and that anyone who
2592 /// adds a seventeenth capability is told why `1 << 16` on a `u16` will not do.
2593 #[test]
2594 fn export_capability_space_is_exactly_full() {
2595 let all = [
2596 CommandCapabilities::SESSION,
2597 CommandCapabilities::MODEL,
2598 CommandCapabilities::COST,
2599 CommandCapabilities::MODE_POLICY,
2600 CommandCapabilities::SYSTEM_PROMPT,
2601 CommandCapabilities::SKILLS,
2602 CommandCapabilities::WORKSPACE,
2603 CommandCapabilities::PRESENTATION,
2604 CommandCapabilities::MEDIA,
2605 CommandCapabilities::MEMORY,
2606 CommandCapabilities::PROJECT,
2607 CommandCapabilities::SKILL_GROUP,
2608 CommandCapabilities::PLUGIN,
2609 CommandCapabilities::SESSION_LIFECYCLE,
2610 CommandCapabilities::SESSION_CONTROL,
2611 CommandCapabilities::SESSION_EXPORT,
2612 ];
2613
2614 let mut union = CommandCapabilities::NONE;
2615 for (index, capability) in all.iter().enumerate() {
2616 assert_eq!(
2617 capability.bits_for_test(),
2618 1u16 << index,
2619 "capability {index} must occupy exactly bit {index}"
2620 );
2621 union = union.union(*capability);
2622 }
2623
2624 assert_eq!(
2625 all.len(),
2626 u16::BITS as usize,
2627 "the declared capability count must consume the whole u16 space"
2628 );
2629 assert_eq!(
2630 union.bits_for_test(),
2631 u16::MAX,
2632 "bits 0-15 are fully allocated; FEAT-026 must widen the storage to u32"
2633 );
2634 }
2635
2636 /// Deterministic fake export facet: every delegate returns canned portable
2637 /// values or host error text, and effectful delegates record their calls so a
2638 /// later phase can assert sequencing without a real host.
2639 #[derive(Default)]
2640 struct FakeExport {
2641 projection: Option<ConversationExportProjection>,
2642 turn: Option<TurnHandoffProjection>,
2643 terminal_paste: bool,
2644 recovery: Option<Option<PathBuf>>,
2645 clipboard: Option<Result<(), String>>,
2646 resolved: Option<Result<PathBuf, String>>,
2647 write: Option<Result<(), String>>,
2648 calls: RefCell<Vec<String>>,
2649 }
2650
2651 impl CommandSessionExportContext for FakeExport {
2652 fn conversation_projection(&self) -> ConversationExportProjection {
2653 self.calls
2654 .borrow_mut()
2655 .push("conversation_projection".to_string());
2656 self.projection
2657 .clone()
2658 .expect("unexpected conversation_projection() on empty fake")
2659 }
2660 fn turn_handoff_projection(&self) -> TurnHandoffProjection {
2661 self.calls
2662 .borrow_mut()
2663 .push("turn_handoff_projection".to_string());
2664 self.turn
2665 .clone()
2666 .expect("unexpected turn_handoff_projection() on empty fake")
2667 }
2668 fn clipboard_requires_terminal_paste(&self) -> bool {
2669 self.calls
2670 .borrow_mut()
2671 .push("clipboard_requires_terminal_paste".to_string());
2672 self.terminal_paste
2673 }
2674 fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
2675 self.calls
2676 .borrow_mut()
2677 .push(format!("write_recovery_copy:{markdown}"));
2678 self.recovery
2679 .clone()
2680 .expect("unexpected write_recovery_copy() on empty fake")
2681 }
2682 fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
2683 self.calls
2684 .borrow_mut()
2685 .push(format!("write_clipboard:{markdown}"));
2686 self.clipboard
2687 .clone()
2688 .unwrap_or_else(|| Err("unexpected write_clipboard() on empty fake".to_string()))
2689 }
2690 fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
2691 self.calls
2692 .borrow_mut()
2693 .push(format!("resolve_export_path:{raw}"));
2694 self.resolved.clone().unwrap_or_else(|| {
2695 Err(format!(
2696 "unexpected resolve_export_path({raw}) on empty fake"
2697 ))
2698 })
2699 }
2700 fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
2701 self.calls.borrow_mut().push(format!(
2702 "write_export_file:{}:{}:{force}",
2703 path.display(),
2704 contents.len()
2705 ));
2706 self.write
2707 .clone()
2708 .unwrap_or_else(|| Err("unexpected write_export_file() on empty fake".to_string()))
2709 }
2710 }
2711
2712 fn export_metadata() -> ExportMetadata {
2713 ExportMetadata {
2714 session_label: "abc123".to_string(),
2715 provider: "deepseek".to_string(),
2716 model: "deepseek-chat".to_string(),
2717 mode: "ACT".to_string(),
2718 workspace_name: "workspace".to_string(),
2719 message_count: 2,
2720 exported_at_unix: 1_760_000_000,
2721 }
2722 }
2723
2724 fn export_recorded_snapshot() -> RestoreSnapshot {
2725 RestoreSnapshot {
2726 id: "0123456789abcdef".to_string(),
2727 label: "pre-turn:3: fix parser".to_string(),
2728 timestamp_unix: 1_759_999_000,
2729 kind: "pre-turn".to_string(),
2730 sequence: Some(3),
2731 prompt_snippet: Some("fix parser".to_string()),
2732 }
2733 }
2734
2735 #[test]
2736 fn export_facet_is_object_safe_and_transports_every_outcome() {
2737 // Object safety: usable behind a single `dyn` reference.
2738 fn accepts_dyn(_: &dyn CommandSessionExportContext) {}
2739 fn accepts_dyn_mut(_: &mut dyn CommandSessionExportContext) {}
2740
2741 let mut fake = FakeExport {
2742 projection: Some(ConversationExportProjection {
2743 metadata: export_metadata(),
2744 transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
2745 is_user_role: false,
2746 role: "assistant".to_string(),
2747 prompt_snippet: Some("fix parser".to_string()),
2748 blocks: vec![
2749 ExportBlock::Text {
2750 text: "visible".to_string(),
2751 },
2752 ExportBlock::ImageReference {
2753 url: "https://example.test/a.png".to_string(),
2754 },
2755 ExportBlock::ImageOmitted,
2756 ExportBlock::InternalReasoning,
2757 ExportBlock::ToolCall {
2758 id: "tool-1".to_string(),
2759 name: "read".to_string(),
2760 caller: Some(ToolCallerProjection {
2761 caller_type: "direct".to_string(),
2762 tool_id: Some("caller-1".to_string()),
2763 }),
2764 input: serde_json::json!({"path": "a.txt"}),
2765 },
2766 ExportBlock::ToolResult {
2767 tool_use_id: "tool-1".to_string(),
2768 content: "ok".to_string(),
2769 is_error: false,
2770 structured: Some(serde_json::json!([{"type": "text", "text": "ok"}])),
2771 },
2772 ExportBlock::ServerToolCall {
2773 id: "server-1".to_string(),
2774 name: "web_search".to_string(),
2775 input: serde_json::json!({"q": "rust"}),
2776 },
2777 ExportBlock::ToolSearchResult {
2778 tool_use_id: "search-1".to_string(),
2779 content: serde_json::json!({"results": []}),
2780 },
2781 ExportBlock::CodeExecutionResult {
2782 tool_use_id: "code-1".to_string(),
2783 content: serde_json::json!({"stdout": "hi"}),
2784 },
2785 ],
2786 }]),
2787 restore_points: RestorePointProjection::Recorded {
2788 snapshots: vec![export_recorded_snapshot()],
2789 },
2790 }),
2791 turn: Some(TurnHandoffProjection {
2792 markdown: "# turn handoff".to_string(),
2793 workspace_path: "/workspace/example".to_string(),
2794 }),
2795 terminal_paste: true,
2796 recovery: Some(Some(PathBuf::from(
2797 "/home/u/.codewhale/exports/last-copy.md",
2798 ))),
2799 clipboard: Some(Ok(())),
2800 resolved: Some(Ok(PathBuf::from("/workspace/example/out.md"))),
2801 write: Some(Ok(())),
2802 ..FakeExport::default()
2803 };
2804 accepts_dyn(&fake);
2805 accepts_dyn_mut(&mut fake);
2806
2807 let projection = fake.conversation_projection();
2808 assert_eq!(projection.metadata.session_label, "abc123");
2809 assert_eq!(projection.metadata.provider, "deepseek");
2810 assert_eq!(projection.metadata.model, "deepseek-chat");
2811 assert_eq!(projection.metadata.mode, "ACT");
2812 assert_eq!(projection.metadata.workspace_name, "workspace");
2813 assert_eq!(projection.metadata.message_count, 2);
2814 assert_eq!(projection.metadata.exported_at_unix, 1_760_000_000);
2815 let TranscriptProjection::Authoritative(messages) = projection.transcript else {
2816 panic!("expected authoritative transcript");
2817 };
2818 assert_eq!(messages.len(), 1);
2819 assert_eq!(messages[0].role, "assistant");
2820 assert_eq!(messages[0].prompt_snippet.as_deref(), Some("fix parser"));
2821 assert_eq!(messages[0].blocks.len(), 9);
2822 let ExportBlock::ToolCall {
2823 caller: Some(caller),
2824 input,
2825 ..
2826 } = &messages[0].blocks[4]
2827 else {
2828 panic!("expected tool call with caller");
2829 };
2830 assert_eq!(caller.caller_type, "direct");
2831 assert_eq!(caller.tool_id.as_deref(), Some("caller-1"));
2832 assert_eq!(input["path"], "a.txt");
2833 let ExportBlock::ToolResult {
2834 is_error,
2835 structured,
2836 ..
2837 } = &messages[0].blocks[5]
2838 else {
2839 panic!("expected tool result");
2840 };
2841 assert!(!is_error);
2842 assert!(structured.is_some());
2843 let RestorePointProjection::Recorded { snapshots } = projection.restore_points else {
2844 panic!("expected recorded restore points");
2845 };
2846 assert_eq!(snapshots.len(), 1);
2847 assert_eq!(snapshots[0].id, "0123456789abcdef");
2848 assert_eq!(snapshots[0].label, "pre-turn:3: fix parser");
2849 assert_eq!(snapshots[0].timestamp_unix, 1_759_999_000);
2850 assert_eq!(snapshots[0].kind, "pre-turn");
2851 assert_eq!(snapshots[0].sequence, Some(3));
2852 assert_eq!(snapshots[0].prompt_snippet.as_deref(), Some("fix parser"));
2853
2854 let turn = fake.turn_handoff_projection();
2855 assert_eq!(turn.markdown, "# turn handoff");
2856 assert_eq!(turn.workspace_path, "/workspace/example");
2857
2858 assert!(fake.clipboard_requires_terminal_paste());
2859 assert_eq!(
2860 fake.write_recovery_copy("# md"),
2861 Some(PathBuf::from("/home/u/.codewhale/exports/last-copy.md"))
2862 );
2863 assert!(fake.write_clipboard("# md").is_ok());
2864 assert_eq!(
2865 fake.resolve_export_path("out.md").expect("resolved"),
2866 PathBuf::from("/workspace/example/out.md")
2867 );
2868 assert!(
2869 fake.write_export_file(Path::new("/workspace/example/out.md"), b"# md", false)
2870 .is_ok()
2871 );
2872 // Effectful delegates were exercised exactly once each, in call order.
2873 let expected: Vec<String> = [
2874 "conversation_projection",
2875 "turn_handoff_projection",
2876 "clipboard_requires_terminal_paste",
2877 "write_recovery_copy:# md",
2878 "write_clipboard:# md",
2879 "resolve_export_path:out.md",
2880 "write_export_file:/workspace/example/out.md:4:false",
2881 ]
2882 .into_iter()
2883 .map(str::to_string)
2884 .collect();
2885 assert_eq!(fake.calls.borrow().as_slice(), expected.as_slice());
2886 }
2887
2888 #[test]
2889 fn export_error_and_empty_states_transport_exactly() {
2890 let fake = FakeExport {
2891 projection: Some(ConversationExportProjection {
2892 metadata: export_metadata(),
2893 transcript: TranscriptProjection::HistoryFallback(vec![
2894 HistoryEntry::Sanitized {
2895 role: "user".to_string(),
2896 body: "visible history".to_string(),
2897 },
2898 HistoryEntry::Literal {
2899 role: "system".to_string(),
2900 body: "[internal context omitted]".to_string(),
2901 },
2902 ]),
2903 restore_points: RestorePointProjection::Unreadable {
2904 reason: "permission denied".to_string(),
2905 },
2906 }),
2907 recovery: Some(None),
2908 clipboard: Some(Err("clipboard unavailable".to_string())),
2909 resolved: Some(Err("export paths may not contain `..`".to_string())),
2910 write: Some(Err("destination already exists".to_string())),
2911 ..FakeExport::default()
2912 };
2913
2914 let projection = fake.conversation_projection();
2915 let TranscriptProjection::HistoryFallback(entries) = projection.transcript else {
2916 panic!("expected history fallback");
2917 };
2918 assert_eq!(entries.len(), 2);
2919 assert!(matches!(
2920 &entries[0],
2921 HistoryEntry::Sanitized { role, body }
2922 if role == "user" && body == "visible history"
2923 ));
2924 assert!(matches!(
2925 &entries[1],
2926 HistoryEntry::Literal { role, body }
2927 if role == "system" && body == "[internal context omitted]"
2928 ));
2929 let RestorePointProjection::Unreadable { reason } = projection.restore_points else {
2930 panic!("expected unreadable restore points");
2931 };
2932 assert_eq!(reason, "permission denied");
2933
2934 assert!(!fake.clipboard_requires_terminal_paste());
2935 assert_eq!(fake.write_recovery_copy("# md"), None);
2936 assert_eq!(
2937 fake.write_clipboard("# md").unwrap_err(),
2938 "clipboard unavailable"
2939 );
2940 assert_eq!(
2941 fake.resolve_export_path("../out.md").unwrap_err(),
2942 "export paths may not contain `..`"
2943 );
2944 assert_eq!(
2945 fake.write_export_file(Path::new("/tmp/out.md"), b"x", false)
2946 .unwrap_err(),
2947 "destination already exists"
2948 );
2949 }
2950
2951 #[test]
2952 fn export_projection_distinguishes_restore_states() {
2953 let states = [
2954 RestorePointProjection::None,
2955 RestorePointProjection::Unreadable {
2956 reason: "boom".to_string(),
2957 },
2958 RestorePointProjection::Recorded { snapshots: vec![] },
2959 RestorePointProjection::Recorded {
2960 snapshots: vec![export_recorded_snapshot()],
2961 },
2962 ];
2963 assert!(matches!(&states[0], RestorePointProjection::None));
2964 assert!(matches!(
2965 &states[1],
2966 RestorePointProjection::Unreadable { reason } if reason == "boom"
2967 ));
2968 let RestorePointProjection::Recorded { snapshots } = &states[2] else {
2969 panic!("expected recorded state");
2970 };
2971 assert!(snapshots.is_empty(), "existing-but-empty stays distinct");
2972 let RestorePointProjection::Recorded { snapshots } = &states[3] else {
2973 panic!("expected recorded state");
2974 };
2975 assert_eq!(snapshots.len(), 1);
2976 }
2977
2978 #[test]
2979 fn export_projection_omission_markers_carry_no_hidden_payload() {
2980 // D9: the projection has no field for a reasoning body, reasoning
2981 // signature, or inline/local image payload. Omission markers are data-free
2982 // unit variants, so prohibited payloads cannot be transported even by
2983 // accident.
2984 let block = ExportBlock::InternalReasoning;
2985 let ExportBlock::InternalReasoning = block else {
2986 panic!("internal reasoning must be a payload-free marker");
2987 };
2988 let block = ExportBlock::ImageOmitted;
2989 let ExportBlock::ImageOmitted = block else {
2990 panic!("omitted image must be a payload-free marker");
2991 };
2992
2993 const HIDDEN_REASONING: &str = "signed-thinking-secret-body";
2994 const HIDDEN_SIGNATURE: &str = "sig_1234567890";
2995 const HIDDEN_IMAGE: &str = "data:image/png;base64,QUJD";
2996
2997 let projection = ConversationExportProjection {
2998 metadata: export_metadata(),
2999 transcript: TranscriptProjection::Authoritative(vec![ExportMessage {
3000 is_user_role: false,
3001 role: "assistant".to_string(),
3002 prompt_snippet: None,
3003 blocks: vec![ExportBlock::InternalReasoning, ExportBlock::ImageOmitted],
3004 }]),
3005 restore_points: RestorePointProjection::None,
3006 };
3007 let rendered = format!("{projection:?}");
3008 assert!(!rendered.contains(HIDDEN_REASONING));
3009 assert!(!rendered.contains(HIDDEN_SIGNATURE));
3010 assert!(!rendered.contains(HIDDEN_IMAGE));
3011 assert!(rendered.contains("InternalReasoning"));
3012 assert!(rendered.contains("ImageOmitted"));
3013 }
3014
3015 #[test]
3016 fn envelope_export_slot_is_independent_and_rejects_duplicates() {
3017 let mut first = FakeExport::default();
3018 let mut second = FakeExport::default();
3019 let mut control = FakeControl::default();
3020
3021 let parts = CommandContexts::empty()
3022 .with_export(&mut first)
3023 .with_control(&mut control)
3024 .into_parts();
3025 assert!(
3026 parts.export.is_some(),
3027 "export slot must be present when declared"
3028 );
3029 assert!(
3030 parts.control.is_some(),
3031 "control slot may coexist with export"
3032 );
3033 assert!(
3034 parts.session.is_none()
3035 && parts.lifecycle.is_none()
3036 && parts.plugin.is_none()
3037 && parts.skill_group.is_none(),
3038 "unrelated slots must stay absent (exact exposure)"
3039 );
3040
3041 let bare = CommandContexts::empty().into_parts();
3042 assert!(bare.export.is_none(), "undeclared export stays absent");
3043
3044 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3045 CommandContexts::empty()
3046 .with_export(&mut first)
3047 .with_export(&mut second);
3048 }));
3049 assert!(
3050 result.is_err(),
3051 "duplicate export slot must assert deterministically"
3052 );
3053
3054 // Reading through the dyn facet works after insertion.
3055 let mut projection = FakeExport {
3056 terminal_paste: true,
3057 ..FakeExport::default()
3058 };
3059 let inserted = CommandContexts::empty().with_export(&mut projection);
3060 let export = inserted.into_parts().export.expect("inserted export");
3061 assert!(export.clipboard_requires_terminal_paste());
3062 }
3063
3063 lines RUST