1use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::{Display, Formatter};
11
12use licoup_conversation::{
13 LICOUP_GUIDE_SKILL_ID, MembershipProfileSnapshot, ProfileResponsibility,
14};
15
16use crate::ports::{CandidateFilters, ModelFactsPort, rank_candidates, route_receipt};
17use licoup_workflow::{
18 PreflightDiagnostic, WorkflowDiagnosticCode, WorkflowDiagnosticRecovery,
19 WorkflowDiagnosticStage,
20};
21
22use licoup_workflow::{BindingKind, GraphStateKind, WorkflowDefinition, validate_workflow_value};
23
24use licoup_workflow_store::BindingValue;
25
26pub const ASSISTANT_TEMPORARY_DEFINITION_PREFIX: &str = "assistant-temporary";
27
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct PreflightReceipt {
31 pub conversation_id: String,
32 pub assistant_membership_id: String,
33 pub workflow_digest: String,
34 pub membership_ids: Vec<String>,
35 pub route_receipt: Value,
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct PreflightFailure {
41 pub code: String,
42 pub stage: String,
43 pub retryable: bool,
44 pub recovery: String,
45 pub diagnostics: Vec<PreflightDiagnostic>,
46}
47
48impl Display for PreflightFailure {
49 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
50 formatter.write_str(&self.code)
51 }
52}
53
54impl std::error::Error for PreflightFailure {}
55
56impl PreflightFailure {
57 pub(crate) fn new(code: &'static str, mut diagnostics: Vec<PreflightDiagnostic>) -> Self {
58 diagnostics.sort_by(|left, right| {
59 left.stage
60 .cmp(&right.stage)
61 .then_with(|| left.path.cmp(&right.path))
62 .then_with(|| left.code.cmp(&right.code))
63 .then_with(|| left.related_paths.cmp(&right.related_paths))
64 .then_with(|| left.membership_id.cmp(&right.membership_id))
65 });
66 diagnostics.dedup();
67 Self {
68 code: code.to_owned(),
69 stage: "assistant-workflow/preflight".to_owned(),
70 retryable: true,
71 recovery: "correct_graph_or_bindings_and_retry".to_owned(),
72 diagnostics,
73 }
74 }
75}
76
77#[derive(Clone, Debug)]
78pub struct AssistantPreflight {
79 pub definition: WorkflowDefinition,
80 pub bindings: Vec<BindingValue>,
81 pub receipt: PreflightReceipt,
82 pub profile_revisions: BTreeMap<String, i64>,
85}
86
87pub fn preflight_assistant_graph(
88 conversation_id: &str,
89 assistant_membership_id: &str,
90 workflow: &Value,
91 bindings: &[BindingValue],
92 snapshots: &[MembershipProfileSnapshot],
93 filters: &CandidateFilters,
94 model_facts: &dyn ModelFactsPort,
95 selection_policy_revision: &str,
96) -> Result<AssistantPreflight, PreflightFailure> {
97 let validation = validate_workflow_value(workflow);
98 let mut checks = validation.diagnostics;
99
100 let by_membership = snapshots
101 .iter()
102 .map(|snapshot| (snapshot.membership_id.as_str(), snapshot))
103 .collect::<BTreeMap<_, _>>();
104 let assistant = by_membership.get(assistant_membership_id).copied();
105 if assistant.is_none() {
106 checks.push(check_at(
107 "graph_assistant_membership_rejected",
108 Some("/assistantMembershipId"),
109 Some(assistant_membership_id),
110 ));
111 }
112 if assistant
113 .is_some_and(|assistant| assistant.responsibility != ProfileResponsibility::Assistant)
114 {
115 checks.push(check_at(
116 "graph_assistant_designation_rejected",
117 Some("/assistantMembershipId"),
118 Some(assistant_membership_id),
119 ));
120 }
121 if assistant.is_some_and(|assistant| {
122 !assistant
123 .skills
124 .iter()
125 .any(|skill| skill == LICOUP_GUIDE_SKILL_ID)
126 || !conversation_driver_available(assistant)
127 }) {
128 checks.push(check_at(
129 "graph_assistant_skill_unavailable",
130 Some("/assistantMembershipId"),
131 Some(assistant_membership_id),
132 ));
133 }
134 if assistant.is_some_and(|assistant| {
135 filters
136 .required_authority
137 .iter()
138 .any(|required| !assistant.authority.contains(required))
139 }) {
140 checks.push(check_at(
141 "graph_authority_rejected",
142 Some("/filters/requiredAuthority"),
143 Some(assistant_membership_id),
144 ));
145 }
146 if assistant.is_some_and(|assistant| assistant.model.is_none()) {
147 checks.push(check_at(
148 "graph_model_unavailable",
149 Some("/assistantMembershipId"),
150 Some(assistant_membership_id),
151 ));
152 }
153 if assistant.is_some_and(|assistant| assistant.readiness.as_deref() != Some("ready")) {
154 checks.push(check_at(
155 "graph_readiness_rejected",
156 Some("/assistantMembershipId"),
157 Some(assistant_membership_id),
158 ));
159 }
160 if assistant.is_some_and(|assistant| assistant.environment.is_none()) {
161 checks.push(check_at(
162 "graph_environment_unavailable",
163 Some("/assistantMembershipId"),
164 Some(assistant_membership_id),
165 ));
166 }
167
168 let Some(definition) = validation.definition else {
169 return Err(PreflightFailure::new(failure_code(&checks), checks));
170 };
171 if !definition
172 .metadata
173 .id
174 .starts_with(ASSISTANT_TEMPORARY_DEFINITION_PREFIX)
175 {
176 checks.push(diagnostic(
177 "graph_identity_not_assistant_temporary",
178 "assistant-workflow/preflight",
179 Some("/metadata/id"),
180 None,
181 None,
182 None,
183 ));
184 }
185 if !definition.runtimes.is_empty()
186 || definition
187 .states
188 .iter()
189 .any(|state| state.kind == GraphStateKind::Script)
190 {
191 checks.push(check_at(
192 "graph_runtime_asset_unavailable",
193 Some("/runtimes"),
194 None,
195 ));
196 }
197
198 let actor_slots = definition
199 .actor_slots
200 .iter()
201 .map(|slot| (slot.id.as_str(), slot))
202 .collect::<BTreeMap<_, _>>();
203 let required_slots = definition
204 .actor_slots
205 .iter()
206 .filter(|slot| slot.required && slot.kind == BindingKind::Actor)
207 .map(|slot| slot.id.as_str())
208 .collect::<BTreeSet<_>>();
209 let mut seen_bindings = BTreeSet::new();
210 let mut bound_slots = BTreeSet::new();
211 let mut bound_membership_ids = BTreeSet::new();
212 let mut canonical_bindings = Vec::new();
213 for (binding_index, binding) in bindings.iter().enumerate() {
214 let binding_pointer = format!("/bindings/{binding_index}");
215 if !seen_bindings.insert((binding.slot_id.as_str(), binding.ordinal)) {
216 checks.push(check_at(
217 "graph_binding_duplicate",
218 Some(binding_pointer.as_str()),
219 None,
220 ));
221 continue;
222 }
223 let Some(slot) = actor_slots.get(binding.slot_id.as_str()) else {
224 checks.push(check_at(
225 "graph_binding_unknown",
226 Some(format!("{binding_pointer}/slotId").as_str()),
227 None,
228 ));
229 continue;
230 };
231 if slot.kind != BindingKind::Actor || binding.ordinal >= 16 {
232 checks.push(check_at(
233 "graph_binding_kind_rejected",
234 Some(binding_pointer.as_str()),
235 None,
236 ));
237 continue;
238 }
239 if binding.ordinal == 0 {
240 bound_slots.insert(binding.slot_id.as_str());
241 }
242 let Some(member) = by_membership.get(binding.value_id.as_str()).copied() else {
243 checks.push(check_at(
244 "graph_membership_rejected",
245 Some(format!("{binding_pointer}/valueId").as_str()),
246 Some(binding.value_id.as_str()),
247 ));
248 continue;
249 };
250 bound_membership_ids.insert(member.membership_id.clone());
251 if member.membership_id != assistant_membership_id && member.model.is_none() {
252 checks.push(check_at(
253 "graph_model_unavailable",
254 Some(format!("{binding_pointer}/model").as_str()),
255 Some(member.membership_id.as_str()),
256 ));
257 } else if !binding.model.is_empty()
258 && member.model.as_deref() != Some(binding.model.as_str())
259 {
260 checks.push(check_at(
261 "graph_model_rejected",
262 Some(format!("{binding_pointer}/model").as_str()),
263 Some(member.membership_id.as_str()),
264 ));
265 }
266 if member.membership_id != assistant_membership_id
267 && member.readiness.as_deref() != Some("ready")
268 {
269 checks.push(check_at(
270 "graph_readiness_rejected",
271 Some(format!("{binding_pointer}/valueId").as_str()),
272 Some(member.membership_id.as_str()),
273 ));
274 }
275 if member.membership_id != assistant_membership_id && member.environment.is_none() {
276 checks.push(check_at(
277 "graph_environment_unavailable",
278 Some(format!("{binding_pointer}/valueId").as_str()),
279 Some(member.membership_id.as_str()),
280 ));
281 }
282 canonical_bindings.push(BindingValue {
283 slot_id: binding.slot_id.clone(),
284 ordinal: binding.ordinal,
285 value_id: member.membership_id.clone(),
286 model: member.model.clone().unwrap_or_default(),
287 reasoning_effort: binding.reasoning_effort.clone(),
288 revision: 0,
289 });
290 }
291 for slot in required_slots.difference(&bound_slots) {
292 let slot_index = definition
293 .actor_slots
294 .iter()
295 .position(|candidate| candidate.id == *slot)
296 .unwrap_or_default();
297 checks.push(check_at(
298 "graph_binding_incomplete",
299 Some(format!("/actorSlots/{slot_index}").as_str()),
300 None,
301 ));
302 }
303
304 let requested_memberships = filters
305 .membership_ids
306 .iter()
307 .map(String::as_str)
308 .collect::<BTreeSet<_>>();
309 if !requested_memberships.is_empty()
310 && bound_membership_ids
311 .iter()
312 .any(|membership_id| !requested_memberships.contains(membership_id.as_str()))
313 {
314 checks.push(check_at(
315 "graph_membership_rejected",
316 Some("/filters/membershipIds"),
317 None,
318 ));
319 }
320 let mut exact_filters = filters.clone();
321 exact_filters.membership_ids = bound_membership_ids.iter().cloned().collect();
322 let ranked = match rank_candidates(snapshots.to_vec(), &exact_filters) {
323 Ok(ranked) => ranked,
324 Err(_) => {
325 checks.push(check_at("graph_profile_rejected", Some("/filters"), None));
326 Vec::new()
327 }
328 };
329
330 if !checks.is_empty() {
331 return Err(PreflightFailure::new(failure_code(&checks), checks));
332 }
333 canonical_bindings.sort_by(|left, right| {
334 left.slot_id
335 .cmp(&right.slot_id)
336 .then_with(|| left.ordinal.cmp(&right.ordinal))
337 .then_with(|| left.value_id.cmp(&right.value_id))
338 });
339 let route_receipt = route_receipt(
344 conversation_id,
345 &ranked,
346 model_facts,
347 selection_policy_revision,
348 );
349 let digest_payload = json!({
350 "workflow": definition,
351 "bindings": canonical_bindings,
352 "routeReceipt": route_receipt,
353 });
354 let workflow_digest = sha256_hex(&serde_json::to_vec(&digest_payload).unwrap_or_default());
355 let membership_ids = bound_membership_ids.into_iter().collect::<Vec<_>>();
356 let profile_revisions = snapshots
357 .iter()
358 .filter(|snapshot| {
359 snapshot.membership_id == assistant_membership_id
360 || membership_ids.contains(&snapshot.membership_id)
361 })
362 .map(|snapshot| (snapshot.membership_id.clone(), snapshot.intent_revision))
363 .collect();
364 Ok(AssistantPreflight {
365 definition,
366 bindings: canonical_bindings,
367 receipt: PreflightReceipt {
368 conversation_id: conversation_id.to_owned(),
369 assistant_membership_id: assistant_membership_id.to_owned(),
370 workflow_digest,
371 membership_ids,
372 route_receipt,
373 },
374 profile_revisions,
375 })
376}
377
378fn failure_code(diagnostics: &[PreflightDiagnostic]) -> &'static str {
379 if diagnostics.len() == 1
380 && diagnostics[0].code == WorkflowDiagnosticCode::GraphIdentityNotAssistantTemporary
381 {
382 "graph_identity_rejected"
383 } else if diagnostics.iter().any(|diagnostic| {
384 matches!(
385 diagnostic.stage,
386 WorkflowDiagnosticStage::AssistantWorkflowPreflight
387 | WorkflowDiagnosticStage::AssistantWorkflowRevalidate
388 )
389 }) {
390 "graph_preflight_rejected"
391 } else {
392 "graph_invalid"
393 }
394}
395
396fn conversation_driver_available(snapshot: &MembershipProfileSnapshot) -> bool {
397 snapshot.capabilities.iter().any(|capability| {
398 matches!(
399 capability.as_str(),
400 "conversationDriver:supported" | "conversationDriver:ready"
401 )
402 })
403}
404
405fn check_at(
406 code: &str,
407 json_pointer: Option<&str>,
408 membership_id: Option<&str>,
409) -> PreflightDiagnostic {
410 diagnostic(
411 code,
412 "assistant-workflow/preflight",
413 json_pointer,
414 membership_id,
415 None,
416 None,
417 )
418}
419
420fn diagnostic(
421 code: &str,
422 stage: &str,
423 json_pointer: Option<&str>,
424 membership_id: Option<&str>,
425 actual: Option<u64>,
426 limit: Option<u64>,
427) -> PreflightDiagnostic {
428 let code =
429 WorkflowDiagnosticCode::from_wire(code).unwrap_or(WorkflowDiagnosticCode::WorkflowInvalid);
430 let recovery = diagnostic_recovery(code);
431 PreflightDiagnostic {
432 code,
433 stage: WorkflowDiagnosticStage::from_wire(stage)
434 .unwrap_or(WorkflowDiagnosticStage::WorkflowCompile),
435 path: json_pointer.map(str::to_owned),
436 related_paths: Vec::new(),
437 membership_id: membership_id
438 .filter(|value| valid_diagnostic_identity(value))
439 .map(str::to_owned),
440 actual,
441 limit,
442 expected: None,
443 actual_kind: None,
444 recovery: Some(recovery),
445 line: None,
446 column: None,
447 }
448}
449
450fn diagnostic_recovery(code: WorkflowDiagnosticCode) -> WorkflowDiagnosticRecovery {
451 use WorkflowDiagnosticCode as Code;
452 match code {
453 Code::WorkflowRequiredFieldMissing => WorkflowDiagnosticRecovery::AddRequiredField,
454 Code::WorkflowUnknownField => WorkflowDiagnosticRecovery::RemoveUnknownField,
455 Code::WorkflowBindingDuplicate
456 | Code::WorkflowStateDuplicate
457 | Code::WorkflowTransitionDuplicate => WorkflowDiagnosticRecovery::RemoveDuplicate,
458 Code::WorkflowRuntimeBindingInvalid
459 | Code::WorkflowActorBindingInvalid
460 | Code::WorkflowScriptRuntimeInvalid
461 | Code::WorkflowWorksetReferenceInvalid
462 | Code::WorkflowWorksetBindingInvalid
463 | Code::WorkflowInitialUnknown
464 | Code::WorkflowTransitionStateUnknown => WorkflowDiagnosticRecovery::CorrectReference,
465 Code::WorkflowStateLimit
466 | Code::WorkflowTransitionLimit
467 | Code::WorkflowBindingLimit
468 | Code::WorkflowRuntimeLimit
469 | Code::WorkflowParallelismInvalid
470 | Code::WorkflowWorksetLimitInvalid
471 | Code::WorkflowRetryLimitInvalid => WorkflowDiagnosticRecovery::ReduceResource,
472 Code::WorkflowGuardAmbiguous | Code::WorkflowRoutingInvalid => {
473 WorkflowDiagnosticRecovery::CorrectRouting
474 }
475 Code::WorkflowTopologyInvalid
476 | Code::WorkflowStateUnreachable
477 | Code::WorkflowTerminalUnreachable
478 | Code::WorkflowEffectCycle => WorkflowDiagnosticRecovery::CorrectTopology,
479 Code::GraphAssistantMembershipRejected
480 | Code::GraphAssistantDesignationRejected
481 | Code::GraphAssistantSkillUnavailable
482 | Code::GraphAuthorityRejected
483 | Code::GraphModelUnavailable
484 | Code::GraphModelRejected
485 | Code::GraphReadinessRejected
486 | Code::GraphEnvironmentUnavailable
487 | Code::GraphProfileRejected => WorkflowDiagnosticRecovery::UpdateAssistantProfile,
488 Code::GraphBindingDuplicate
489 | Code::GraphBindingUnknown
490 | Code::GraphBindingKindRejected
491 | Code::GraphBindingIncomplete
492 | Code::GraphMembershipRejected => WorkflowDiagnosticRecovery::UpdateBinding,
493 Code::ConversationStateUnavailable | Code::ConversationNotFound | Code::GraphRouteStale => {
494 WorkflowDiagnosticRecovery::RefreshConversationState
495 }
496 _ => WorkflowDiagnosticRecovery::CorrectField,
497 }
498}
499
500fn valid_diagnostic_identity(value: &str) -> bool {
501 !value.is_empty()
502 && value.len() <= 256
503 && value.chars().all(|character| {
504 character.is_ascii_alphanumeric() || matches!(character, ':' | '.' | '_' | '-')
505 })
506}
507
508pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
509 use sha2::{Digest, Sha256};
510 let digest = Sha256::digest(bytes);
511 digest.iter().map(|byte| format!("{byte:02x}")).collect()
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 const FIXTURE_SELECTION_POLICY_REVISION: &str = "selection-policy-fixture-1";
522 use licoup_workflow::{MAX_ACTIVE_EFFECTS, MAX_WORKSET_ITEMS};
523
524 fn workflow() -> Value {
525 json!({
526 "schema": "licoup.adaptive-flywheel.workflow.v1",
527 "metadata": {"id": "assistant-temporary", "name": "Temporary", "version": "1"},
528 "limits": {"maxParallelism": 2, "maxWorksetItems": 16, "maxAttempts": 2},
529 "actorSlots": [{"id": "actor", "kind": "actor", "label": "Actor", "required": true, "entry": true}],
530 "runtimes": [],
531 "worksets": [],
532 "initial": "run",
533 "states": [
534 {"id": "run", "kind": "actor", "label": "Run", "binding": "actor"},
535 {"id": "done", "kind": "succeed", "label": "Done"},
536 {"id": "failed", "kind": "fail", "label": "Failed"}
537 ],
538 "transitions": [
539 {"id": "done", "from": "run", "to": "done", "event": "success"},
540 {"id": "failed", "from": "run", "to": "failed", "event": "failure"}
541 ],
542 })
543 }
544
545 fn snapshot(id: &str, responsibility: ProfileResponsibility) -> MembershipProfileSnapshot {
546 MembershipProfileSnapshot {
547 conversation_id: "conversation:g".to_owned(),
548 membership_id: id.to_owned(),
549 agent_id: "agent:a".to_owned(),
550 intent_revision: 1,
551 responsibility,
552 required_capabilities: vec!["conversationDriver:supported".to_owned()],
553 preferred_capabilities: Vec::new(),
554 skill_references: if responsibility == ProfileResponsibility::Assistant {
555 vec![LICOUP_GUIDE_SKILL_ID.to_owned()]
556 } else {
557 Vec::new()
558 },
559 preferred_model: None,
560 preferred_reasoning_effort: None,
561 preferred_environment: None,
562 preferred_permission_mode: None,
563 model: Some("model-a".to_owned()),
564 capabilities: vec!["conversationDriver:supported".to_owned()],
565 skills: if responsibility == ProfileResponsibility::Assistant {
566 vec![LICOUP_GUIDE_SKILL_ID.to_owned()]
567 } else {
568 Vec::new()
569 },
570 environment: Some("local".to_owned()),
571 readiness: Some("ready".to_owned()),
572 price_input_usd_per_million_tokens: Some(1.0),
573 price_output_usd_per_million_tokens: Some(2.0),
574 intelligence_score: Some(8),
575 task_tags: Vec::new(),
576 reliability_class: Some("verified".to_owned()),
577 latency_class: Some(1),
578 authority: vec![
579 "conversation.act".to_owned(),
580 "conversation.read".to_owned(),
581 ],
582 }
583 }
584
585 #[test]
586 fn exact_memberships_and_owner_derived_model_are_frozen() {
587 let snapshots = vec![
588 snapshot("membership:assistant", ProfileResponsibility::Assistant),
589 snapshot("membership:actor", ProfileResponsibility::Member),
590 ];
591 let admitted = preflight_assistant_graph(
592 "conversation:g",
593 "membership:assistant",
594 &workflow(),
595 &[BindingValue {
596 slot_id: "actor".to_owned(),
597 ordinal: 0,
598 value_id: "membership:actor".to_owned(),
599 model: String::new(),
600 reasoning_effort: String::new(),
601 revision: 0,
602 }],
603 &snapshots,
604 &CandidateFilters::default(),
605 crate::ports::host_ports().model.as_ref(),
606 FIXTURE_SELECTION_POLICY_REVISION,
607 )
608 .unwrap();
609 assert_eq!(admitted.bindings[0].value_id, "membership:actor");
610 assert_eq!(admitted.bindings[0].model, "model-a");
611 assert_eq!(admitted.receipt.membership_ids, vec!["membership:actor"]);
612 }
613
614 #[test]
615 fn missing_skill_and_runtime_assets_fail_before_admission() {
616 let mut assistant = snapshot("membership:assistant", ProfileResponsibility::Assistant);
617 assistant.skills.clear();
618 let mut script = workflow();
619 script["actorSlots"].as_array_mut().unwrap().push(json!({
620 "id": "node",
621 "kind": "runtime",
622 "label": "Node",
623 "required": true
624 }));
625 script["runtimes"] = json!([{"id": "node", "kind": "node"}]);
626 let failure = preflight_assistant_graph(
627 "conversation:g",
628 "membership:assistant",
629 &script,
630 &[],
631 &[assistant],
632 &CandidateFilters::default(),
633 crate::ports::host_ports().model.as_ref(),
634 FIXTURE_SELECTION_POLICY_REVISION,
635 )
636 .unwrap_err();
637 assert!(
638 failure
639 .diagnostics
640 .iter()
641 .any(|check| check.code == "graph_assistant_skill_unavailable")
642 );
643 assert!(
644 failure
645 .diagnostics
646 .iter()
647 .any(|check| check.code == "graph_runtime_asset_unavailable")
648 );
649 }
650
651 #[test]
652 fn resource_and_identity_diagnostics_preserve_safe_repair_locations() {
653 let mut invalid = workflow();
654 invalid["metadata"]["id"] = json!("imported-workflow");
655 invalid["limits"]["maxParallelism"] = json!(0);
656 invalid["limits"]["maxWorksetItems"] = json!(MAX_WORKSET_ITEMS + 1);
657 let failure = preflight_assistant_graph(
658 "conversation:g",
659 "membership:assistant",
660 &invalid,
661 &[],
662 &[],
663 &CandidateFilters::default(),
664 crate::ports::host_ports().model.as_ref(),
665 FIXTURE_SELECTION_POLICY_REVISION,
666 )
667 .unwrap_err();
668 assert_eq!(failure.code, "graph_preflight_rejected");
669 assert!(failure.diagnostics.iter().any(|diagnostic| {
670 diagnostic.code == "graph_identity_not_assistant_temporary"
671 && diagnostic.path.as_deref() == Some("/metadata/id")
672 }));
673 assert!(failure.diagnostics.iter().any(|diagnostic| {
674 diagnostic.code == "workflow_parallelism_invalid"
675 && diagnostic.path.as_deref() == Some("/limits/maxParallelism")
676 && diagnostic.actual == Some(0)
677 && diagnostic.limit == Some(MAX_ACTIVE_EFFECTS as u64)
678 }));
679 assert!(failure.diagnostics.iter().any(|diagnostic| {
680 diagnostic.code == "workflow_workset_limit_invalid"
681 && diagnostic.path.as_deref() == Some("/limits/maxWorksetItems")
682 && diagnostic.actual == Some((MAX_WORKSET_ITEMS + 1) as u64)
683 && diagnostic.limit == Some(MAX_WORKSET_ITEMS as u64)
684 }));
685 }
686
687 #[test]
688 fn binding_diagnostics_identify_the_exact_request_item() {
689 let snapshots = vec![snapshot(
690 "membership:assistant",
691 ProfileResponsibility::Assistant,
692 )];
693 let failure = preflight_assistant_graph(
694 "conversation:g",
695 "membership:assistant",
696 &workflow(),
697 &[BindingValue {
698 slot_id: "actor".to_owned(),
699 ordinal: 0,
700 value_id: "membership:missing".to_owned(),
701 model: String::new(),
702 reasoning_effort: String::new(),
703 revision: 0,
704 }],
705 &snapshots,
706 &CandidateFilters::default(),
707 crate::ports::host_ports().model.as_ref(),
708 FIXTURE_SELECTION_POLICY_REVISION,
709 )
710 .unwrap_err();
711 assert!(failure.diagnostics.iter().any(|diagnostic| {
712 diagnostic.code == "graph_membership_rejected"
713 && diagnostic.path.as_deref() == Some("/bindings/0/valueId")
714 && diagnostic.membership_id.as_deref() == Some("membership:missing")
715 }));
716 }
717}