返回 CodeWhale
tests.rs
根目录 / crates / tui / src / work_graph / tests.rs
1 //! Work-graph unit tests: one test per invariant V1–V10, plus the reducer
2 //! contract (determinism, idempotency dedup, fail-closed rejection, bounded
3 //! history) and serde round-trip.
4
5 use super::compat;
6 use super::*;
7
8 const SESSION: &str = "sess-test";
9
10 fn nid(disc: &str) -> WorkNodeId {
11 WorkNodeId::derive(SESSION, disc)
12 }
13
14 fn eid(disc: &str) -> WorkEdgeId {
15 WorkEdgeId::derive(SESSION, disc)
16 }
17
18 fn ctx(now: Ts) -> ChangeCtx {
19 ChangeCtx {
20 session_id: SESSION.to_string(),
21 now,
22 idempotency_key: None,
23 }
24 }
25
26 fn ctx_keyed(now: Ts, seq: u64) -> ChangeCtx {
27 ChangeCtx {
28 session_id: SESSION.to_string(),
29 now,
30 idempotency_key: Some(IdempotencyKey {
31 binding: BindingId::derive(SESSION, "binding:op"),
32 seq,
33 }),
34 }
35 }
36
37 fn mk_node(disc: &str, kind: NodeKind, state: NodeState, now: Ts) -> WorkNode {
38 WorkNode {
39 id: nid(disc),
40 kind,
41 title: disc.to_string(),
42 state,
43 acceptance: Vec::new(),
44 binding: None,
45 evidence: None,
46 provenance: Provenance::Import {
47 source_digest: "digest".to_string(),
48 ordinal: None,
49 },
50 created_at: now,
51 updated_at: now,
52 }
53 }
54
55 fn mk_edge(disc: &str, kind: EdgeKind, from: &str, to: &str) -> WorkEdge {
56 WorkEdge {
57 id: eid(disc),
58 kind,
59 from: nid(from),
60 to: nid(to),
61 }
62 }
63
64 fn effort_activity(
65 provider: impl Into<String>,
66 ts: Ts,
67 operation: Option<WorkNodeId>,
68 ) -> WorkActivityEvent {
69 let provider = provider.into();
70 WorkActivityEvent::ReasoningEffortChanged {
71 requested: ReasoningEffortTier::Low,
72 effective: ReasoningEffortTier::High,
73 provider_kind: Some(
74 crate::config::ApiProvider::parse(&provider)
75 .unwrap_or(crate::config::ApiProvider::Custom),
76 ),
77 provider,
78 endpoint_identity: Some("https://example.invalid/v1".to_string()),
79 model: Some("test-model".to_string()),
80 ts,
81 operation,
82 }
83 }
84
85 fn must(graph: &mut WorkGraph, change: WorkGraphChange, now: Ts) -> ChangeReceipt {
86 graph.apply(change, ctx(now)).expect("change should apply")
87 }
88
89 /// Objective ─Contains→ PlanStep ─Contains→ Operation (all Ready).
90 fn seeded() -> WorkGraph {
91 let mut graph = WorkGraph::new();
92 must(
93 &mut graph,
94 WorkGraphChange::AddNode {
95 node: mk_node("root", NodeKind::Objective, NodeState::Ready, 1),
96 },
97 1,
98 );
99 must(
100 &mut graph,
101 WorkGraphChange::AddNode {
102 node: mk_node("step", NodeKind::PlanStep, NodeState::Ready, 2),
103 },
104 2,
105 );
106 must(
107 &mut graph,
108 WorkGraphChange::AddEdge {
109 edge: mk_edge("c:root:step", EdgeKind::Contains, "root", "step"),
110 },
111 3,
112 );
113 must(
114 &mut graph,
115 WorkGraphChange::AddNode {
116 node: mk_node("op", NodeKind::Operation, NodeState::Ready, 4),
117 },
118 4,
119 );
120 must(
121 &mut graph,
122 WorkGraphChange::AddEdge {
123 edge: mk_edge("c:step:op", EdgeKind::Contains, "step", "op"),
124 },
125 5,
126 );
127 graph
128 }
129
130 fn set_state(graph: &mut WorkGraph, disc: &str, state: NodeState, now: Ts) {
131 must(
132 graph,
133 WorkGraphChange::UpdateNode {
134 id: nid(disc),
135 patch: WorkNodePatch {
136 state: Some(state),
137 ..WorkNodePatch::default()
138 },
139 },
140 now,
141 );
142 }
143
144 fn expect_code(
145 result: Result<ChangeReceipt, ValidationReport>,
146 code: ValidationCode,
147 ) -> ValidationReport {
148 let report = result.expect_err("change should be rejected");
149 assert!(report.contains_code(code), "expected {code:?} in {report}",);
150 report
151 }
152
153 #[test]
154 fn v1_depends_on_cycles_rejected() {
155 let mut graph = seeded();
156 must(
157 &mut graph,
158 WorkGraphChange::AddNode {
159 node: mk_node("step2", NodeKind::PlanStep, NodeState::Ready, 6),
160 },
161 6,
162 );
163 must(
164 &mut graph,
165 WorkGraphChange::AddEdge {
166 edge: mk_edge("d:step:step2", EdgeKind::DependsOn, "step", "step2"),
167 },
168 7,
169 );
170 let result = graph.apply(
171 WorkGraphChange::AddEdge {
172 edge: mk_edge("d:step2:step", EdgeKind::DependsOn, "step2", "step"),
173 },
174 ctx(8),
175 );
176 expect_code(result, ValidationCode::V1);
177 }
178
179 #[test]
180 fn v2_live_operation_requires_plan_ancestry() {
181 let mut graph = seeded();
182 // Attached operation may go live.
183 set_state(&mut graph, "op", NodeState::Active, 6);
184
185 // An orphaned live operation is rejected outright.
186 let result = graph.apply(
187 WorkGraphChange::AddNode {
188 node: mk_node("orphan-op", NodeKind::Operation, NodeState::Active, 7),
189 },
190 ctx(7),
191 );
192 expect_code(result, ValidationCode::V2);
193 }
194
195 #[test]
196 fn v3_binding_only_on_operation_nodes() {
197 let mut graph = seeded();
198 let result = graph.apply(
199 WorkGraphChange::BindOperation {
200 node: nid("step"),
201 binding: OperationBinding {
202 external: "task:task_0123456789abcdef".to_string(),
203 durable: true,
204 last_observation: None,
205 },
206 },
207 ctx(6),
208 );
209 expect_code(result, ValidationCode::V3);
210 }
211
212 #[test]
213 fn v4_verified_requires_satisfying_evidence() {
214 let mut graph = seeded();
215
216 // Verified with empty acceptance: rejected.
217 let result = graph.apply(
218 WorkGraphChange::UpdateNode {
219 id: nid("op"),
220 patch: WorkNodePatch {
221 state: Some(NodeState::Verified),
222 ..WorkNodePatch::default()
223 },
224 },
225 ctx(6),
226 );
227 expect_code(result, ValidationCode::V4);
228
229 // Give the node an acceptance requirement; Verified without evidence is
230 // still rejected — cannot-verify never fails open into done.
231 must(
232 &mut graph,
233 WorkGraphChange::UpdateNode {
234 id: nid("op"),
235 patch: WorkNodePatch {
236 acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind {
237 kind: EvidenceKindTag::TestSummary,
238 }]),
239 ..WorkNodePatch::default()
240 },
241 },
242 7,
243 );
244 let result = graph.apply(
245 WorkGraphChange::UpdateNode {
246 id: nid("op"),
247 patch: WorkNodePatch {
248 state: Some(NodeState::Verified),
249 ..WorkNodePatch::default()
250 },
251 },
252 ctx(8),
253 );
254 expect_code(result, ValidationCode::V4);
255 // The failed attempt changed nothing; the caller's recourse is Blocked,
256 // never silently-verified.
257 assert_eq!(
258 graph.snapshot().node(&nid("op")).unwrap().state,
259 NodeState::Ready
260 );
261
262 // Wrong-kind evidence does not satisfy the requirement.
263 must(
264 &mut graph,
265 WorkGraphChange::AttachEvidence {
266 node: nid("op"),
267 evidence: EvidenceRef::new(EvidenceKind::ToolRun, "tool-call:1", None, false).unwrap(),
268 },
269 9,
270 );
271 let result = graph.apply(
272 WorkGraphChange::UpdateNode {
273 id: nid("op"),
274 patch: WorkNodePatch {
275 state: Some(NodeState::Verified),
276 ..WorkNodePatch::default()
277 },
278 },
279 ctx(10),
280 );
281 expect_code(result, ValidationCode::V4);
282
283 // Satisfying evidence unlocks Verified.
284 must(
285 &mut graph,
286 WorkGraphChange::AttachEvidence {
287 node: nid("op"),
288 evidence: EvidenceRef::new(EvidenceKind::TestSummary, "test-run:42", Some(2048), true)
289 .unwrap(),
290 },
291 11,
292 );
293 set_state(&mut graph, "op", NodeState::Verified, 12);
294 assert_eq!(
295 graph.snapshot().node(&nid("op")).unwrap().state,
296 NodeState::Verified
297 );
298 }
299
300 #[test]
301 fn v5_blocked_requires_visible_cause() {
302 let mut graph = seeded();
303 let result = graph.apply(
304 WorkGraphChange::UpdateNode {
305 id: nid("op"),
306 patch: WorkNodePatch {
307 state: Some(NodeState::Blocked),
308 ..WorkNodePatch::default()
309 },
310 },
311 ctx(6),
312 );
313 expect_code(result, ValidationCode::V5);
314
315 must(
316 &mut graph,
317 WorkGraphChange::AddNode {
318 node: mk_node("blocker", NodeKind::Blocker, NodeState::Ready, 7),
319 },
320 7,
321 );
322 must(
323 &mut graph,
324 WorkGraphChange::AddEdge {
325 edge: mk_edge("b:blocker:op", EdgeKind::Blocks, "blocker", "op"),
326 },
327 8,
328 );
329 set_state(&mut graph, "op", NodeState::Blocked, 9);
330 assert_eq!(
331 graph.snapshot().node(&nid("op")).unwrap().state,
332 NodeState::Blocked
333 );
334 }
335
336 #[test]
337 fn v6_binding_external_identity() {
338 let mut graph = seeded();
339
340 // Unknown scheme: rejected.
341 let result = graph.apply(
342 WorkGraphChange::BindOperation {
343 node: nid("op"),
344 binding: OperationBinding {
345 external: "mystery:xyz".to_string(),
346 durable: true,
347 last_observation: None,
348 },
349 },
350 ctx(6),
351 );
352 expect_code(result, ValidationCode::V6);
353
354 // Well-formed externals bind; fleet's two-part form parses too.
355 assert!(external_identity_is_well_formed("fleet:run_1/task_2"));
356 assert!(!external_identity_is_well_formed("fleet:run-only"));
357 must(
358 &mut graph,
359 WorkGraphChange::BindOperation {
360 node: nid("op"),
361 binding: OperationBinding {
362 external: "shell:shell_ab12cd34".to_string(),
363 durable: false,
364 last_observation: None,
365 },
366 },
367 7,
368 );
369
370 // A second operation cannot claim the same external identity.
371 must(
372 &mut graph,
373 WorkGraphChange::AddNode {
374 node: mk_node("op2", NodeKind::Operation, NodeState::Ready, 8),
375 },
376 8,
377 );
378 let result = graph.apply(
379 WorkGraphChange::BindOperation {
380 node: nid("op2"),
381 binding: OperationBinding {
382 external: "shell:shell_ab12cd34".to_string(),
383 durable: false,
384 last_observation: None,
385 },
386 },
387 ctx(9),
388 );
389 expect_code(result, ValidationCode::V6);
390 }
391
392 #[test]
393 fn v7_reference_nodes_stay_inert() {
394 let mut graph = seeded();
395 let result = graph.apply(
396 WorkGraphChange::AddNode {
397 node: mk_node("rt", NodeKind::RuntimeRef, NodeState::Active, 6),
398 },
399 ctx(6),
400 );
401 expect_code(result, ValidationCode::V7);
402
403 // Inert reference nodes are fine.
404 must(
405 &mut graph,
406 WorkGraphChange::AddNode {
407 node: mk_node("lane", NodeKind::LaneRef, NodeState::Ready, 7),
408 },
409 7,
410 );
411 }
412
413 #[test]
414 fn v8_revision_and_history_monotonic() {
415 // Applied changes increment the revision exactly once each and receipts
416 // land in order.
417 let graph = seeded();
418 assert_eq!(graph.snapshot().revision, 5);
419 let revisions: Vec<u64> = graph
420 .snapshot()
421 .history
422 .iter()
423 .map(|r| r.revision)
424 .collect();
425 assert_eq!(revisions, vec![1, 2, 3, 4, 5]);
426
427 // A snapshot whose history revisions are out of order fails validation.
428 let mut bad = graph.snapshot().clone();
429 let mut receipt = bad.history.last().unwrap().clone();
430 receipt.revision = 2; // duplicate/regressing revision
431 bad.history.push_bounded(receipt);
432 let report = validate(&bad).expect_err("out-of-order history must fail");
433 assert!(report.contains_code(ValidationCode::V8));
434 }
435
436 #[test]
437 fn v9_terminal_states_only_move_via_supersede() {
438 let mut graph = seeded();
439 set_state(&mut graph, "op", NodeState::Cancelled, 6);
440
441 // Patching a terminal node's state is rejected.
442 let result = graph.apply(
443 WorkGraphChange::UpdateNode {
444 id: nid("op"),
445 patch: WorkNodePatch {
446 state: Some(NodeState::Active),
447 ..WorkNodePatch::default()
448 },
449 },
450 ctx(7),
451 );
452 expect_code(result, ValidationCode::V9);
453
454 // ...but re-asserting the state it already holds is a no-op, not an error.
455 // `work_update` replaces the whole list on every call, so a cancelled item
456 // is re-sent as cancelled every time; rejecting that made the tool unusable
457 // once anything was cancelled.
458 let terminal_state = graph.snapshot().node(&nid("op")).expect("op node").state;
459 let replay = graph.apply(
460 WorkGraphChange::UpdateNode {
461 id: nid("op"),
462 patch: WorkNodePatch {
463 state: Some(terminal_state),
464 ..WorkNodePatch::default()
465 },
466 },
467 ctx(8),
468 );
469 assert!(
470 replay.is_ok(),
471 "re-sending a terminal node's own state must be accepted: {replay:?}"
472 );
473
474 // Explicit Supersede is the sanctioned path.
475 must(
476 &mut graph,
477 WorkGraphChange::AddNode {
478 node: mk_node("op-new", NodeKind::Operation, NodeState::Ready, 8),
479 },
480 8,
481 );
482 must(
483 &mut graph,
484 WorkGraphChange::Supersede {
485 old: nid("op"),
486 replacement: nid("op-new"),
487 },
488 9,
489 );
490 let snapshot = graph.snapshot();
491 assert_eq!(
492 snapshot.node(&nid("op")).unwrap().state,
493 NodeState::Superseded
494 );
495 assert!(snapshot.edges.iter().any(|e| {
496 matches!(e.kind, EdgeKind::Supersedes) && e.from == nid("op-new") && e.to == nid("op")
497 }));
498 }
499
500 #[test]
501 fn v10_projections_are_pure_functions_of_snapshot() {
502 // Type-level enforcement: projections coerce to plain fns over an
503 // immutable snapshot reference — no mutable access, no ambient state.
504 let _plan: fn(&WorkGraphSnapshot) -> compat::PlanProjection = compat::project_plan;
505 let _todos: fn(&WorkGraphSnapshot) -> compat::TodoProjection = compat::project_todos;
506 }
507
508 #[test]
509 fn compat_todo_binding_rejects_non_plan_step_node() {
510 let mut bad = seeded().snapshot().clone();
511 bad.compat.todos.push(CompatTodoBinding {
512 legacy_id: 1,
513 node: nid("root"),
514 plan_index: None,
515 });
516
517 let report = validate(&bad).expect_err("compat To-do rows must bind to PlanStep nodes");
518 assert!(report.contains_code(ValidationCode::Structural));
519 }
520
521 /// A representative change sequence exercising nodes, edges, bindings,
522 /// reconciliation, evidence, proposals, and supersede.
523 fn scripted_run() -> WorkGraph {
524 let mut graph = seeded();
525 must(
526 &mut graph,
527 WorkGraphChange::BindOperation {
528 node: nid("op"),
529 binding: OperationBinding {
530 external: "task:task_0123456789abcdef".to_string(),
531 durable: true,
532 last_observation: None,
533 },
534 },
535 6,
536 );
537 graph
538 .apply(
539 WorkGraphChange::ReconcileOperation {
540 node: nid("op"),
541 obs: OperationObservation::OwnerReported {
542 state: OwnerState::Running,
543 seq: 1,
544 at: 7,
545 output: None,
546 },
547 },
548 ctx_keyed(7, 1),
549 )
550 .expect("reconcile applies");
551 must(
552 &mut graph,
553 WorkGraphChange::UpdateNode {
554 id: nid("op"),
555 patch: WorkNodePatch {
556 acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind {
557 kind: EvidenceKindTag::ToolRun,
558 }]),
559 ..WorkNodePatch::default()
560 },
561 },
562 8,
563 );
564 graph
565 .apply(
566 WorkGraphChange::ReconcileOperation {
567 node: nid("op"),
568 obs: OperationObservation::OwnerReported {
569 state: OwnerState::Completed,
570 seq: 2,
571 at: 9,
572 output: None,
573 },
574 },
575 ctx_keyed(9, 2),
576 )
577 .expect("reconcile applies");
578 must(
579 &mut graph,
580 WorkGraphChange::AttachEvidence {
581 node: nid("op"),
582 evidence: EvidenceRef::new(EvidenceKind::ToolRun, "tool-call:99", Some(512), false)
583 .unwrap(),
584 },
585 10,
586 );
587 set_state(&mut graph, "op", NodeState::Verified, 11);
588
589 let proposal = WorkGraphProposal {
590 id: ProposalId::derive(SESSION, "proposal:1"),
591 added_nodes: vec![mk_node("step2", NodeKind::PlanStep, NodeState::Ready, 12)],
592 added_edges: vec![mk_edge("c:root:step2", EdgeKind::Contains, "root", "step2")],
593 updated_nodes: vec![ProposedNodeUpdate {
594 id: nid("step"),
595 patch: WorkNodePatch {
596 title: Some("step (revised)".to_string()),
597 ..WorkNodePatch::default()
598 },
599 }],
600 removed_nodes: Vec::new(),
601 removed_edges: Vec::new(),
602 replacement_compat: None,
603 };
604 must(
605 &mut graph,
606 WorkGraphChange::ProposePlanDiff { proposal },
607 12,
608 );
609 must(
610 &mut graph,
611 WorkGraphChange::AcceptPlanDiff {
612 proposal_id: ProposalId::derive(SESSION, "proposal:1"),
613 approval: ApprovalRef {
614 reference: "user-review:1".to_string(),
615 },
616 },
617 13,
618 );
619 graph
620 }
621
622 #[test]
623 fn reducer_is_deterministic_including_ids() {
624 let first = scripted_run();
625 let second = scripted_run();
626 assert_eq!(first.snapshot(), second.snapshot());
627 // Byte-identical serialization: same IDs, same ordering, same receipts.
628 let a = serde_json::to_string(first.snapshot()).unwrap();
629 let b = serde_json::to_string(second.snapshot()).unwrap();
630 assert_eq!(a, b);
631 }
632
633 #[test]
634 fn idempotency_key_dedup_is_a_no_op() {
635 let mut graph = scripted_run();
636 let before = graph.snapshot().clone();
637
638 // Same (binding, seq) as an already-applied observation: acknowledged,
639 // nothing changes — not revision, not history, not node state.
640 let receipt = graph
641 .apply(
642 WorkGraphChange::ReconcileOperation {
643 node: nid("op"),
644 obs: OperationObservation::OwnerReported {
645 state: OwnerState::Failed,
646 seq: 2,
647 at: 99,
648 output: None,
649 },
650 },
651 ctx_keyed(99, 2),
652 )
653 .expect("duplicate is acknowledged, not errored");
654 assert!(receipt.no_op);
655 assert_eq!(receipt.revision, before.revision);
656 assert_eq!(graph.snapshot(), &before);
657 }
658
659 #[test]
660 fn rejected_change_leaves_snapshot_untouched() {
661 let mut graph = seeded();
662 let before = graph.snapshot().clone();
663 let result = graph.apply(
664 WorkGraphChange::UpdateNode {
665 id: nid("op"),
666 patch: WorkNodePatch {
667 state: Some(NodeState::Verified), // V4: no acceptance, no evidence
668 ..WorkNodePatch::default()
669 },
670 },
671 ctx(6),
672 );
673 expect_code(result, ValidationCode::V4);
674 assert_eq!(graph.snapshot(), &before);
675
676 // Same fail-closed contract on the pure entry point.
677 let report = apply(
678 &before,
679 WorkGraphChange::RemoveEdge {
680 id: eid("does-not-exist"),
681 },
682 ctx(7),
683 )
684 .expect_err("missing edge is rejected");
685 assert!(report.contains_code(ValidationCode::Structural));
686 }
687
688 #[test]
689 fn history_stays_bounded_at_cap() {
690 let mut graph = seeded();
691 let seeded_revision = graph.snapshot().revision;
692 let extra = HISTORY_CAP as u64 + 44;
693 for i in 0..extra {
694 must(
695 &mut graph,
696 WorkGraphChange::UpdateNode {
697 id: nid("step"),
698 patch: WorkNodePatch {
699 title: Some(format!("step v{i}")),
700 ..WorkNodePatch::default()
701 },
702 },
703 100 + i as Ts,
704 );
705 }
706 let snapshot = graph.snapshot();
707 assert_eq!(snapshot.revision, seeded_revision + extra);
708 assert_eq!(snapshot.history.len(), HISTORY_CAP);
709 // Oldest receipts were evicted FIFO; the window ends at the current
710 // revision and starts exactly HISTORY_CAP entries back.
711 let first = snapshot.history.iter().next().unwrap().revision;
712 let last = snapshot.history.last().unwrap().revision;
713 assert_eq!(last, snapshot.revision);
714 assert_eq!(first, snapshot.revision - (HISTORY_CAP as u64 - 1));
715 }
716
717 #[test]
718 fn activity_receipts_reject_bad_provider_identity_and_oversized_snapshots() {
719 let mut graph = seeded();
720 for provider in [
721 String::new(),
722 "bad route".to_string(),
723 "bad\nroute".to_string(),
724 "x".repeat(129),
725 ] {
726 let before = graph.snapshot().clone();
727 let result = graph.apply(
728 WorkGraphChange::RecordActivity {
729 event: effort_activity(provider, 10, None),
730 },
731 ctx(10),
732 );
733 expect_code(result, ValidationCode::Structural);
734 assert_eq!(graph.snapshot(), &before, "rejection must be atomic");
735 }
736
737 for offset in 0..(ACTIVITY_CAP + 8) {
738 let ts = 100 + offset as Ts;
739 must(
740 &mut graph,
741 WorkGraphChange::RecordActivity {
742 event: effort_activity("xiaomi-mimo", ts, None),
743 },
744 ts,
745 );
746 }
747 assert_eq!(graph.snapshot().activities.len(), ACTIVITY_CAP);
748 assert_eq!(
749 graph.snapshot().activities.iter().next(),
750 Some(&effort_activity("xiaomi-mimo", 108, None)),
751 "bounded writes evict the oldest receipt"
752 );
753
754 let mut wire = serde_json::to_value(graph.snapshot()).expect("serialize graph");
755 let activities = wire["activities"].as_array_mut().expect("activity array");
756 activities.push(activities.last().expect("bounded receipt").clone());
757 let oversized: WorkGraphSnapshot =
758 serde_json::from_value(wire).expect("deserialize untrusted oversized snapshot");
759 let report = validate(&oversized).expect_err("oversized activity must fail closed");
760 assert!(report.contains_code(ValidationCode::Structural));
761 }
762
763 #[test]
764 fn effective_only_reasoning_states_cannot_be_requested() {
765 for requested in [
766 ReasoningEffortTier::ThinkingEnabledGranularityUnavailable,
767 ReasoningEffortTier::Unavailable,
768 ] {
769 let mut graph = seeded();
770 let before = graph.snapshot().clone();
771 let result = graph.apply(
772 WorkGraphChange::RecordActivity {
773 event: WorkActivityEvent::ReasoningEffortChanged {
774 requested,
775 effective: requested,
776 provider_kind: Some(crate::config::ApiProvider::Zai),
777 provider: "zai".to_string(),
778 endpoint_identity: Some(crate::config::DEFAULT_ZAI_BASE_URL.to_string()),
779 model: Some(crate::config::ZAI_GLM_5_2_MODEL.to_string()),
780 ts: 10,
781 operation: None,
782 },
783 },
784 ctx(10),
785 );
786 expect_code(result, ValidationCode::Structural);
787 assert_eq!(graph.snapshot(), &before, "rejection must be atomic");
788 }
789 }
790
791 #[test]
792 fn activity_operation_links_require_live_operations_at_write_time() {
793 let mut graph = seeded();
794 for operation in [nid("missing"), nid("root")] {
795 let before = graph.snapshot().clone();
796 let result = graph.apply(
797 WorkGraphChange::RecordActivity {
798 event: effort_activity("xiaomi-mimo", 10, Some(operation)),
799 },
800 ctx(10),
801 );
802 expect_code(result, ValidationCode::Structural);
803 assert_eq!(graph.snapshot(), &before, "rejection must be atomic");
804 }
805
806 set_state(&mut graph, "op", NodeState::Active, 10);
807 set_state(&mut graph, "op", NodeState::Completed, 11);
808 let result = graph.apply(
809 WorkGraphChange::RecordActivity {
810 event: effort_activity("xiaomi-mimo", 12, Some(nid("op"))),
811 },
812 ctx(12),
813 );
814 expect_code(result, ValidationCode::Structural);
815
816 for operation in [nid("missing"), nid("root")] {
817 let mut malformed = graph.snapshot().clone();
818 malformed
819 .activities
820 .push_bounded(effort_activity("xiaomi-mimo", 13, Some(operation)));
821 let report = validate(&malformed).expect_err("invalid historical link must fail closed");
822 assert!(report.contains_code(ValidationCode::Structural));
823 }
824
825 let mut historical = seeded();
826 set_state(&mut historical, "op", NodeState::Active, 20);
827 must(
828 &mut historical,
829 WorkGraphChange::RecordActivity {
830 event: effort_activity("xiaomi-mimo", 21, Some(nid("op"))),
831 },
832 21,
833 );
834 set_state(&mut historical, "op", NodeState::Completed, 22);
835 validate(historical.snapshot()).expect("a formerly-live historical link remains valid");
836 }
837
838 #[test]
839 fn snapshot_serde_round_trips() {
840 let graph = scripted_run();
841 let json = serde_json::to_string(graph.snapshot()).unwrap();
842 let restored: WorkGraphSnapshot = serde_json::from_str(&json).unwrap();
843 assert_eq!(&restored, graph.snapshot());
844 validate(&restored).expect("restored snapshot still satisfies invariants");
845 }
846
847 #[test]
848 fn legacy_reasoning_activity_loads_with_effective_truth_unavailable() {
849 let mut wire = serde_json::to_value(effort_activity("moonshot", 10, None)).unwrap();
850 let object = wire.as_object_mut().expect("activity object");
851 object.remove("endpoint_identity");
852 object.remove("model");
853
854 let restored: WorkActivityEvent = serde_json::from_value(wire).expect("legacy activity loads");
855 let WorkActivityEvent::ReasoningEffortChanged {
856 effective,
857 endpoint_identity,
858 model,
859 ..
860 } = restored;
861 assert_eq!(effective, ReasoningEffortTier::Unavailable);
862 assert!(endpoint_identity.is_none());
863 assert!(model.is_none());
864 }
865
866 #[test]
867 fn legacy_reasoning_activity_missing_provider_kind_downgrades_effective_truth() {
868 let activity = WorkActivityEvent::ReasoningEffortChanged {
869 requested: ReasoningEffortTier::Max,
870 effective: ReasoningEffortTier::Max,
871 provider_kind: Some(crate::config::ApiProvider::Openai),
872 provider: "openai".to_string(),
873 endpoint_identity: Some(crate::config::DEFAULT_OPENAI_BASE_URL.to_string()),
874 model: Some(crate::config::DEFAULT_OPENAI_MODEL.to_string()),
875 ts: 10,
876 operation: None,
877 };
878 let mut wire = serde_json::to_value(activity).expect("serialize current activity");
879 wire.as_object_mut()
880 .expect("activity object")
881 .remove("provider_kind");
882
883 let restored: WorkActivityEvent =
884 serde_json::from_value(wire).expect("legacy activity remains loadable");
885 let WorkActivityEvent::ReasoningEffortChanged {
886 effective,
887 provider_kind,
888 endpoint_identity,
889 model,
890 ..
891 } = restored;
892 assert_eq!(provider_kind, None);
893 assert_eq!(effective, ReasoningEffortTier::Unavailable);
894 assert!(
895 endpoint_identity.is_some(),
896 "route provenance remains visible"
897 );
898 assert!(model.is_some(), "model provenance remains visible");
899 }
900
901 #[test]
902 fn restored_custom_builtin_slug_collisions_cannot_forge_concrete_tiers() {
903 for provider in ["openai", "zai"] {
904 let mut forged = seeded().into_snapshot();
905 forged
906 .activities
907 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
908 requested: ReasoningEffortTier::Max,
909 effective: ReasoningEffortTier::Max,
910 provider_kind: Some(crate::config::ApiProvider::Custom),
911 provider: provider.to_string(),
912 endpoint_identity: Some("https://gateway.example/v1".to_string()),
913 model: Some("vendor-model-x".to_string()),
914 ts: 10,
915 operation: None,
916 });
917 let wire = serde_json::to_string(&forged).expect("serialize forged custom route");
918 let restored: WorkGraphSnapshot =
919 serde_json::from_str(&wire).expect("restore forged custom route");
920 let report = validate(&restored)
921 .expect_err("custom identity collision cannot inherit built-in reasoning tiers");
922 assert!(
923 report.contains_code(ValidationCode::Structural),
924 "{provider}"
925 );
926
927 let mut truthful_snapshot = seeded().into_snapshot();
928 truthful_snapshot
929 .activities
930 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
931 requested: ReasoningEffortTier::Max,
932 effective: ReasoningEffortTier::Unavailable,
933 provider_kind: Some(crate::config::ApiProvider::Custom),
934 provider: provider.to_string(),
935 endpoint_identity: Some("https://gateway.example/v1".to_string()),
936 model: Some("vendor-model-x".to_string()),
937 ts: 10,
938 operation: None,
939 });
940 let truthful_wire =
941 serde_json::to_string(&truthful_snapshot).expect("serialize truthful custom route");
942 let truthful: WorkGraphSnapshot =
943 serde_json::from_str(&truthful_wire).expect("restore truthful custom route");
944 validate(&truthful).expect("custom slug collision remains valid only as unavailable");
945 }
946 }
947
948 #[test]
949 fn restored_genuine_openai_preserves_known_tier_with_exact_provenance() {
950 let mut snapshot = seeded().into_snapshot();
951 snapshot
952 .activities
953 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
954 requested: ReasoningEffortTier::Max,
955 effective: ReasoningEffortTier::Max,
956 provider_kind: Some(crate::config::ApiProvider::Openai),
957 provider: "openai".to_string(),
958 endpoint_identity: Some(crate::config::DEFAULT_OPENAI_BASE_URL.to_string()),
959 model: Some(crate::config::DEFAULT_OPENAI_MODEL.to_string()),
960 ts: 10,
961 operation: None,
962 });
963 let wire = serde_json::to_string(&snapshot).expect("serialize built-in OpenAI route");
964 let restored: WorkGraphSnapshot =
965 serde_json::from_str(&wire).expect("restore built-in OpenAI route");
966
967 validate(&restored).expect("kind plus exact identity and route provenance prove OpenAI");
968 }
969
970 #[test]
971 fn restored_builtin_kind_rejects_mismatched_exact_identity() {
972 let mut snapshot = seeded().into_snapshot();
973 snapshot
974 .activities
975 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
976 requested: ReasoningEffortTier::Max,
977 effective: ReasoningEffortTier::Max,
978 provider_kind: Some(crate::config::ApiProvider::Openai),
979 provider: "zai".to_string(),
980 endpoint_identity: Some(crate::config::DEFAULT_OPENAI_BASE_URL.to_string()),
981 model: Some(crate::config::DEFAULT_OPENAI_MODEL.to_string()),
982 ts: 10,
983 operation: None,
984 });
985
986 let report = validate(&snapshot).expect_err("kind and exact identity cannot disagree");
987 assert!(report.contains_code(ValidationCode::Structural));
988 assert!(report.violations.iter().any(|violation| {
989 violation
990 .message
991 .contains("provider identity does not match its recorded kind")
992 }));
993 }
994
995 #[test]
996 fn restored_zai_gateway_cannot_forge_effective_max() {
997 let mut snapshot = seeded().into_snapshot();
998 snapshot
999 .activities
1000 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
1001 requested: ReasoningEffortTier::Max,
1002 effective: ReasoningEffortTier::Max,
1003 provider_kind: Some(crate::config::ApiProvider::Zai),
1004 provider: "zai".to_string(),
1005 endpoint_identity: Some("https://gateway.example/v1".to_string()),
1006 model: Some(crate::config::ZAI_GLM_5_2_MODEL.to_string()),
1007 ts: 10,
1008 operation: None,
1009 });
1010 let wire = serde_json::to_string(&snapshot).expect("serialize forged snapshot");
1011 let restored: WorkGraphSnapshot = serde_json::from_str(&wire).expect("restore forged snapshot");
1012
1013 let report = validate(&restored).expect_err("gateway cannot prove effective max");
1014 assert!(report.contains_code(ValidationCode::Structural));
1015 assert!(report.violations.iter().any(|violation| {
1016 violation
1017 .message
1018 .contains("impossible for its recorded route")
1019 }));
1020 }
1021
1022 #[test]
1023 fn restored_zai_toggle_only_and_unknown_models_enforce_effective_truth() {
1024 for (model, truthful_effective) in [
1025 (
1026 crate::config::ZAI_GLM_5_1_MODEL,
1027 ReasoningEffortTier::ThinkingEnabledGranularityUnavailable,
1028 ),
1029 (
1030 crate::config::ZAI_GLM_5_TURBO_MODEL,
1031 ReasoningEffortTier::ThinkingEnabledGranularityUnavailable,
1032 ),
1033 ("glm-future-unknown", ReasoningEffortTier::Unavailable),
1034 ] {
1035 let mut forged = seeded().into_snapshot();
1036 forged
1037 .activities
1038 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
1039 requested: ReasoningEffortTier::Max,
1040 effective: ReasoningEffortTier::Max,
1041 provider_kind: Some(crate::config::ApiProvider::Zai),
1042 provider: "zai".to_string(),
1043 endpoint_identity: Some(crate::config::DEFAULT_ZAI_BASE_URL.to_string()),
1044 model: Some(model.to_string()),
1045 ts: 10,
1046 operation: None,
1047 });
1048 validate(&forged).expect_err("toggle-only/unknown Z.ai route cannot prove max");
1049
1050 let mut truthful = seeded().into_snapshot();
1051 truthful
1052 .activities
1053 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
1054 requested: ReasoningEffortTier::Max,
1055 effective: truthful_effective,
1056 provider_kind: Some(crate::config::ApiProvider::Zai),
1057 provider: "zai".to_string(),
1058 endpoint_identity: Some(crate::config::DEFAULT_ZAI_BASE_URL.to_string()),
1059 model: Some(model.to_string()),
1060 ts: 10,
1061 operation: None,
1062 });
1063 validate(&truthful).expect("truthful Z.ai effective state restores");
1064 }
1065 }
1066
1067 #[test]
1068 fn restored_named_gateway_cannot_forge_effective_max() {
1069 let mut snapshot = seeded().into_snapshot();
1070 snapshot
1071 .activities
1072 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
1073 requested: ReasoningEffortTier::Max,
1074 effective: ReasoningEffortTier::Max,
1075 provider_kind: Some(crate::config::ApiProvider::Custom),
1076 provider: "my-gateway".to_string(),
1077 endpoint_identity: Some("https://gateway.example/v1".to_string()),
1078 model: Some("vendor-model-x".to_string()),
1079 ts: 10,
1080 operation: None,
1081 });
1082 let wire = serde_json::to_string(&snapshot).expect("serialize forged snapshot");
1083 let restored: WorkGraphSnapshot = serde_json::from_str(&wire).expect("restore forged snapshot");
1084
1085 let report = validate(&restored).expect_err("unknown gateway cannot prove effective max");
1086 assert!(report.contains_code(ValidationCode::Structural));
1087 assert!(report.violations.iter().any(|violation| {
1088 violation
1089 .message
1090 .contains("impossible for its recorded route")
1091 }));
1092 }
1093
1094 #[test]
1095 fn restored_named_gateway_accepts_truthful_effective_unavailable() {
1096 let mut snapshot = seeded().into_snapshot();
1097 snapshot
1098 .activities
1099 .push_bounded(WorkActivityEvent::ReasoningEffortChanged {
1100 requested: ReasoningEffortTier::Max,
1101 effective: ReasoningEffortTier::Unavailable,
1102 provider_kind: Some(crate::config::ApiProvider::Custom),
1103 provider: "my-gateway".to_string(),
1104 endpoint_identity: Some("https://gateway.example/v1".to_string()),
1105 model: Some("vendor-model-x".to_string()),
1106 ts: 10,
1107 operation: None,
1108 });
1109 let wire = serde_json::to_string(&snapshot).expect("serialize truthful unknown snapshot");
1110 let restored: WorkGraphSnapshot =
1111 serde_json::from_str(&wire).expect("restore truthful unknown snapshot");
1112
1113 validate(&restored).expect("unknown route remains valid when effective truth is unavailable");
1114 }
1115
1116 #[test]
1117 fn pre_wg3_proposal_shape_deserializes_with_empty_delta_extensions() {
1118 let proposal = WorkGraphProposal {
1119 id: ProposalId::derive(SESSION, "legacy-proposal"),
1120 added_nodes: Vec::new(),
1121 added_edges: Vec::new(),
1122 updated_nodes: Vec::new(),
1123 removed_nodes: vec![nid("old-step")],
1124 removed_edges: Vec::new(),
1125 replacement_compat: Some(CompatProjectionState::default()),
1126 };
1127 let mut value = serde_json::to_value(proposal).expect("serialize current proposal");
1128 let object = value.as_object_mut().expect("proposal object");
1129 object.remove("removed_nodes");
1130 object.remove("replacement_compat");
1131
1132 let restored: WorkGraphProposal =
1133 serde_json::from_value(value).expect("deserialize pre-WG3 proposal");
1134 assert!(restored.removed_nodes.is_empty());
1135 assert!(restored.replacement_compat.is_none());
1136 }
1137
1138 #[test]
1139 fn evidence_ref_rejects_paths_and_key_material() {
1140 let err = |reference: &str| {
1141 EvidenceRef::new(EvidenceKind::ToolRun, reference, None, false)
1142 .expect_err("reference should be rejected")
1143 };
1144 assert_eq!(err(""), EvidenceRefError::EmptyReference);
1145 assert_eq!(err("/var/log/system.log"), EvidenceRefError::AbsolutePath);
1146 assert_eq!(err("\\\\server\\share"), EvidenceRefError::AbsolutePath);
1147 assert_eq!(err("C:\\temp\\out.txt"), EvidenceRefError::AbsolutePath);
1148 assert_eq!(err("~/notes.txt"), EvidenceRefError::HomeRelativePath);
1149 assert_eq!(
1150 err("two words"),
1151 EvidenceRefError::ContainsWhitespaceOrControl
1152 );
1153 assert_eq!(
1154 err("line\nbreak"),
1155 EvidenceRefError::ContainsWhitespaceOrControl
1156 );
1157 // Built at runtime so secret scanners never see a contiguous PEM marker
1158 // in source; the validator still receives the real prefix.
1159 let pem_marker = format!("-----{}-RSA-KEY", "BEGIN");
1160 assert_eq!(err(&pem_marker), EvidenceRefError::LooksLikeKeyMaterial);
1161
1162 // Deserialization re-runs constructor validation — serde is not a bypass.
1163 let smuggled =
1164 r#"{"kind":"tool_run","reference":"/etc/hosts","raw_bytes":null,"truncated":false}"#;
1165 assert!(serde_json::from_str::<EvidenceRef>(smuggled).is_err());
1166
1167 let ok = EvidenceRef::new(
1168 EvidenceKind::Artifact {
1169 digest: "sha256:abc123".to_string(),
1170 },
1171 "artifact:build-77",
1172 Some(4096),
1173 true,
1174 )
1175 .unwrap();
1176 assert_eq!(ok.reference(), "artifact:build-77");
1177 assert_eq!(ok.raw_bytes(), Some(4096));
1178 assert!(ok.truncated());
1179 }
1180
1181 #[test]
1182 fn web_citation_evidence_is_typed_inspectable_and_fail_closed() {
1183 let kind = EvidenceKind::WebCitation {
1184 ref_id: "web_0123456789abcdef".to_string(),
1185 url: "https://example.com/source".to_string(),
1186 retrieved_at: "2026-07-19T18:00:00Z".to_string(),
1187 };
1188 let evidence = EvidenceRef::new(kind, "web_0123456789abcdef", None, false)
1189 .expect("valid web citation evidence");
1190 assert_eq!(evidence.kind().tag(), EvidenceKindTag::WebCitation);
1191
1192 let serialized = serde_json::to_value(&evidence).expect("serialize citation evidence");
1193 let restored: EvidenceRef =
1194 serde_json::from_value(serialized).expect("deserialize citation evidence");
1195 assert_eq!(restored, evidence);
1196
1197 let mismatched = EvidenceRef::new(
1198 EvidenceKind::WebCitation {
1199 ref_id: "web_other".to_string(),
1200 url: "https://example.com/source".to_string(),
1201 retrieved_at: "2026-07-19T18:00:00Z".to_string(),
1202 },
1203 "web_0123456789abcdef",
1204 None,
1205 false,
1206 )
1207 .expect_err("mismatched ref must fail");
1208 assert_eq!(mismatched, EvidenceRefError::WebCitationReferenceMismatch);
1209
1210 let credentialed = EvidenceRef::new(
1211 EvidenceKind::WebCitation {
1212 ref_id: "web_0123456789abcdef".to_string(),
1213 url: "https://user:password@example.com/source".to_string(),
1214 retrieved_at: "2026-07-19T18:00:00Z".to_string(),
1215 },
1216 "web_0123456789abcdef",
1217 None,
1218 false,
1219 )
1220 .expect_err("credentialed URL must fail");
1221 assert_eq!(credentialed, EvidenceRefError::InvalidWebCitationUrl);
1222
1223 let query_credential = EvidenceRef::new(
1224 EvidenceKind::WebCitation {
1225 ref_id: "web_0123456789abcdef".to_string(),
1226 url: "https://example.com/source?access_token=sensitive".to_string(),
1227 retrieved_at: "2026-07-19T18:00:00Z".to_string(),
1228 },
1229 "web_0123456789abcdef",
1230 None,
1231 false,
1232 )
1233 .expect_err("credential-bearing query must fail");
1234 assert_eq!(query_credential, EvidenceRefError::InvalidWebCitationUrl);
1235 }
1236
1236 lines RUST