"""Determinism and Main-Prompt separation tests for the Booking Skill packager.""" from __future__ import annotations import importlib.util import json import tempfile import unittest import zipfile from pathlib import Path REPOSITORY_ROOT = Path(__file__).resolve().parents[2] PACKAGER_PATH = REPOSITORY_ROOT / "scripts" / "package_booking_desk_event_skill.py" def load_packager(): spec = importlib.util.spec_from_file_location("booking_skill_packager", PACKAGER_PATH) if spec is None or spec.loader is None: raise RuntimeError("Booking Skill packager module cannot be loaded") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class BookingDeskEventSkillPackageTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.packager = load_packager() def test_archive_manifest_are_deterministic_hold_and_prompt_free(self) -> None: with tempfile.TemporaryDirectory(prefix="booking-skill-package-test-") as temporary_directory: root = Path(temporary_directory) source = self.create_source(root / "source") first = root / "first" / "booking-desk-event.skill" second = root / "second" / "booking-desk-event.skill" first_members = self.packager.write_archive(source, first) second_members = self.packager.write_archive(source, second) self.assertEqual(first.read_bytes(), second.read_bytes()) self.assertEqual(first_members, second_members) manifest = self.packager.build_manifest(source, first, first_members) self.assertEqual("booking-agent-skill-v2-rc4", manifest["release"]) self.assertEqual("HOLD", manifest["publication_status"]) self.assertEqual("INTERNAL_ONLY", manifest["distribution"]) self.assertEqual("zh-CN", manifest["instruction_language"]) self.assertEqual("Booking Agent Skill V2", manifest["skill_release_version"]) self.assertEqual("booking-business-agent-compact-input-v1", manifest["input_contract"]) self.assertEqual("booking-business-agent-compact-decision-v1", manifest["output_contract"]) self.assertFalse(manifest["main_prompt_packaged"]) self.assertEqual( self.packager.sha256(source / "prompts/main-prompt.md"), manifest["main_prompt_sha256"], ) self.assertEqual(49, manifest["scenario_count"]) self.assertFalse(any("prompts" in member for member in first_members)) with zipfile.ZipFile(first) as archive: self.assertNotIn("booking-desk-event/prompts/main-prompt.md", archive.namelist()) def test_manifest_check_is_read_only_and_rejects_stale_bytes(self) -> None: with tempfile.TemporaryDirectory(prefix="booking-skill-manifest-test-") as temporary_directory: root = Path(temporary_directory) source = self.create_source(root / "source") archive = root / "booking-desk-event.skill" members = self.packager.write_archive(source, archive) manifest = self.packager.build_manifest(source, archive, members) path = root / "manifest.json" self.packager.write_or_check_manifest(path, manifest, False) before = path.read_bytes() before_mtime = path.stat().st_mtime_ns self.packager.write_or_check_manifest(path, manifest, True) self.assertEqual(before, path.read_bytes()) self.assertEqual(before_mtime, path.stat().st_mtime_ns) stale = dict(manifest) stale["publication_status"] = "RELEASED" with self.assertRaisesRegex(ValueError, "stale or missing"): self.packager.write_or_check_manifest(path, stale, True) def test_rejects_any_source_member_outside_the_archive_allowlist(self) -> None: with tempfile.TemporaryDirectory(prefix="booking-skill-forbidden-test-") as temporary_directory: source = self.create_source(Path(temporary_directory) / "source") (source / "scripts").mkdir() (source / "scripts" / "unexpected.py").write_text("pass\n", encoding="utf-8") with self.assertRaisesRegex(ValueError, "forbidden top-level"): self.packager.source_files(source) def test_main_prompt_is_concise_and_keeps_contract_defenses_out(self) -> None: prompt = (REPOSITORY_ROOT / "agent-skills/booking-desk-event/prompts/main-prompt.md").read_text(encoding="utf-8") self.assertLessEqual(len(prompt.splitlines()), 5) self.assertIn("$booking-desk-event", prompt) self.assertIn("booking-business-agent-compact-decision-v1", prompt) self.assertIn("必要规则", prompt) self.assertIn("不调用其他工具", prompt) self.assertIn("每项只判断一次", prompt) self.assertIn("立即返回", prompt) self.assertNotIn("你是 Wyndham-RSVN", prompt) self.assertNotIn("source_input_hash", prompt) self.assertNotIn("decision_origin", prompt) def test_skill_requires_compact_decision_schema_before_output(self) -> None: skill = (REPOSITORY_ROOT / "agent-skills/booking-desk-event/SKILL.md").read_text(encoding="utf-8") schema_path = "schemas/booking-business-agent-compact-decision-v1.schema.json" self.assertIn(f"`{schema_path}`。输出 Schema", skill) self.assertIn("完整读取", skill) self.assertIn("输出 Schema 是字段和允许值的唯一权威", skill) self.assertIn("信息系统在 Layer 5B", skill) schema = json.loads((REPOSITORY_ROOT / "agent-skills/booking-desk-event" / schema_path) .read_text(encoding="utf-8")) self.assertNotIn("source_input_hash", schema["required"]) self.assertNotIn("source_input_hash", schema["properties"]) def test_narrow_qbd_booking_precedence_rules_are_explicit(self) -> None: root = REPOSITORY_ROOT / "agent-skills/booking-desk-event" skill = (root / "SKILL.md").read_text(encoding="utf-8") evidence = (root / "references/01-input-output-evidence.md").read_text(encoding="utf-8") splitting = (root / "references/02-target-and-source-splitting.md").read_text(encoding="utf-8") update = (root / "references/11-update-booking.md").read_text(encoding="utf-8") allotment = (root / "references/15-allotment.md").read_text(encoding="utf-8") general = (root / "references/90-general-risk-ignored.md").read_text(encoding="utf-8") self.assertIn("每项只判断一次", skill) self.assertIn("只执行一次 Schema 字段检查", skill) self.assertIn("不要创建待办", skill) self.assertIn("`D1`、`D2`", evidence) self.assertIn("只使用自己的 `unit_ref`", evidence) self.assertIn("`linked_relationship_refs` 只允许引用 `GROUP_CODE_REPLACEMENT`", evidence) self.assertIn("`ALLOTMENT_SOURCE_TO_ACTUAL`", evidence) self.assertIn("只使用该正文的 `body_ref`", evidence) self.assertIn("只汇总一次", splitting) self.assertIn("立即冻结", splitting) self.assertIn("第一道门槛成立后", update) self.assertIn("重新讨论行类型", update) self.assertIn("ตัด Allotment ", allotment) self.assertIn("固定视为明确的整块取消", allotment) self.assertIn("不搜索 actual 决定", allotment) self.assertIn("不要求与 actual 订单同码", allotment) self.assertIn("`source_target_ref` 必须填写材料中的 source Tour Code 原值", allotment) self.assertIn("不得填写 `T1`、`T10`", allotment) self.assertIn("整块取消与同封物理单元决定可以同时存在", allotment) self.assertIn("QBD 固定 OP 提醒", general) self.assertIn("未归类原文", general) def test_trace_schema_keeps_only_semantic_decision_fields(self) -> None: schema = json.loads(( REPOSITORY_ROOT / "agent-skills/booking-desk-event/schemas/booking-business-agent-compact-decision-v1.schema.json" ).read_text(encoding="utf-8")) trace_item = schema["$defs"]["traceItem"] expected = { "service_type", "service_text", "room_type", "quantity", "source_refs", } self.assertEqual(expected, set(trace_item["required"])) self.assertEqual(expected, set(trace_item["properties"])) self.assertNotIn("parameters", trace_item["properties"]) self.assertEqual(["GENERAL_SERVICE", "EXTRA_BED"], trace_item["properties"]["service_type"]["enum"]) def test_agent_readable_skill_materials_are_chinese(self) -> None: skill_root = REPOSITORY_ROOT / "agent-skills/booking-desk-event" paths = [skill_root / "SKILL.md", skill_root / "agents/openai.yaml"] paths.extend(sorted((skill_root / "references").glob("*.md"))) self.assertEqual(13, len(paths)) for path in paths: with self.subTest(path=path.name): self.assertRegex(path.read_text(encoding="utf-8"), r"[\u4e00-\u9fff]") def create_source(self, root: Path) -> Path: for directory in ("agents", "schemas", "references", "fixtures", "prompts"): (root / directory).mkdir(parents=True, exist_ok=True) (root / "SKILL.md").write_text( "---\nname: booking-desk-event\ndescription: A sufficiently long synthetic skill description for tests.\n---\n", encoding="utf-8", ) (root / "agents/openai.yaml").write_text("interface:\n display_name: test\n", encoding="utf-8") (root / "prompts/main-prompt.md").write_text("independent prompt\n", encoding="utf-8") (root / "manifest.json").write_text(json.dumps({ "publication_status": "HOLD", "distribution": "INTERNAL_ONLY", "main_prompt_packaged": False, }) + "\n", encoding="utf-8") for name in ( "booking-business-agent-compact-input-v1.schema.json", "booking-business-agent-compact-decision-v1.schema.json"): (root / "schemas" / name).write_text("{}\n", encoding="utf-8") (root / "references/rule.md").write_text("synthetic rule\n", encoding="utf-8") scenarios = [{"scenario_id": f"scenario-{index}"} for index in range(49)] (root / "fixtures/scenario-matrix.json").write_text(json.dumps({ "scenario_count": len(scenarios), "scenarios": scenarios, }) + "\n", encoding="utf-8") return root if __name__ == "__main__": unittest.main()