@@ -368,3 +368,119 @@ def test_conftest_import_does_not_load_server():
368368 assert result .returncode == 0 , (
369369 f"Importing tests.infra.conftest triggered autoskillit.server import:\n { result .stderr } "
370370 )
371+
372+
373+ # ---------------------------------------------------------------------------
374+ # PHK-FMT-DISPATCH-SHAPE: Per-shape field obligation tests
375+ # ---------------------------------------------------------------------------
376+
377+
378+ def test_shape_field_obligations_per_kind ():
379+ """Per-shape required-field sets must be enforced for dispatch_food_truck.
380+
381+ The _FMT_DISPATCH_FOOD_TRUCK_RENDERED frozenset is a flat union of all 18 fields
382+ and cannot express per-shape obligations. This test defines required-field sets
383+ per shape ('completed' vs 'rejected') and asserts the formatter renders them
384+ when given an envelope with the matching 'kind' discriminator.
385+
386+ This is the shape-aware coverage contract that protects against future
387+ regressions where a formatter silently drops fields for a specific shape.
388+ """
389+ from autoskillit .hooks .formatters ._fmt_dispatch import (
390+ _FMT_DISPATCH_COMPLETED_REQUIRED ,
391+ _FMT_DISPATCH_REJECTED_REQUIRED ,
392+ )
393+ from tests .infra ._pretty_output_helpers import _wrap_for_claude_code
394+
395+ completed_required = _FMT_DISPATCH_COMPLETED_REQUIRED
396+ rejected_required = _FMT_DISPATCH_REJECTED_REQUIRED
397+
398+ completed_payload = {
399+ "kind" : "completed" ,
400+ "success" : True ,
401+ "dispatch_status" : "success" ,
402+ "dispatch_id" : "d-shape-c" ,
403+ "dispatched_session_id" : "s-shape-c" ,
404+ "reason" : "ok" ,
405+ "token_usage" : {},
406+ "l3_payload" : None ,
407+ "l3_parse_source" : "" ,
408+ "lifespan_started" : False ,
409+ "stderr" : "" ,
410+ "elapsed_seconds" : 1.0 ,
411+ }
412+ event_c = {
413+ "tool_name" : "mcp__plugin_autoskillit_autoskillit__dispatch_food_truck" ,
414+ "tool_response" : _wrap_for_claude_code (completed_payload ),
415+ }
416+ out_c , _ = _run_hook (event = event_c )
417+ text_c = json .loads (out_c )["hookSpecificOutput" ]["updatedMCPToolOutput" ]
418+ completed_expected = {
419+ "dispatch_status" : "dispatch_status: success" ,
420+ "dispatch_id" : "dispatch_id: d-shape-c" ,
421+ "dispatched_session_id" : "dispatched_session_id: s-shape-c" ,
422+ "reason" : "reason: ok" ,
423+ }
424+ for field in completed_required :
425+ assert field in completed_expected , (
426+ f"Field { field !r} from _FMT_DISPATCH_COMPLETED_REQUIRED has no expected value — "
427+ f"add it to completed_expected and completed_payload"
428+ )
429+ assert completed_expected [field ] in text_c , (
430+ f"Completed shape missing { field !r} : expected { completed_expected [field ]!r} in output"
431+ )
432+
433+ rejected_payload = {
434+ "kind" : "rejected" ,
435+ "success" : False ,
436+ "error" : "fleet_quota_exhausted" ,
437+ "user_visible_message" : "quota hit" ,
438+ "details" : None ,
439+ }
440+ event_r = {
441+ "tool_name" : "mcp__plugin_autoskillit_autoskillit__dispatch_food_truck" ,
442+ "tool_response" : _wrap_for_claude_code (rejected_payload ),
443+ }
444+ out_r , _ = _run_hook (event = event_r )
445+ text_r = json .loads (out_r )["hookSpecificOutput" ]["updatedMCPToolOutput" ]
446+ rejected_expected = {
447+ "error" : "fleet_quota_exhausted" ,
448+ "user_visible_message" : "quota hit" ,
449+ }
450+ for field in rejected_required :
451+ assert field in rejected_expected , (
452+ f"Field { field !r} from _FMT_DISPATCH_REJECTED_REQUIRED has no expected value — "
453+ f"add it to rejected_expected and rejected_payload"
454+ )
455+ assert rejected_expected [field ] in text_r , (
456+ f"Rejected shape missing { field !r} : expected { rejected_expected [field ]!r} in output"
457+ )
458+
459+
460+ def test_json_producer_includes_completed_failure ():
461+ """_dispatch_food_truck_json_producer must include a DispatchCompleted(success=False)
462+ shape so the coverage contract exercises both success states and the union of keys
463+ includes 'kind'.
464+
465+ Asserts on the individual completed_failure envelope (not the merged dict)
466+ to catch regressions where this specific shape drops 'kind'.
467+ """
468+ from autoskillit .fleet .state_types import DispatchCompleted , DispatchStatus
469+ from tests .infra .conftest import _dispatch_food_truck_json_producer
470+
471+ keys = _dispatch_food_truck_json_producer ().keys ()
472+ assert "kind" in keys , (
473+ f"_dispatch_food_truck_json_producer must emit 'kind' field: { sorted (keys )} "
474+ )
475+ failure = DispatchCompleted (
476+ success = False ,
477+ dispatch_status = DispatchStatus .FAILURE ,
478+ dispatch_id = "d-verify" ,
479+ dispatched_session_id = "s-verify" ,
480+ reason = "verify_kind" ,
481+ )
482+ failure_env = json .loads (failure .to_envelope ())
483+ assert "kind" in failure_env , (
484+ f"DispatchCompleted(success=False).to_envelope() must emit 'kind': "
485+ f"{ sorted (failure_env .keys ())} "
486+ )
0 commit comments