#!/usr/bin/env python3 """无工具正文写手 adapter 的隔离、合同与失败关闭测试。""" from __future__ import annotations import copy import hashlib import json import pathlib import subprocess import sys import unittest from decimal import Decimal PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT_DIR = PROJECT_ROOT / ".claude" / "skills" / "write-next-chapter" / "scripts" READ_CONTEXT_DIR = PROJECT_ROOT / ".claude" / "skills" / "assemble-context" / "scripts" TEST_CONTEXT_DIR = PROJECT_ROOT / "tests" / "skills" / "assemble-context" sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(READ_CONTEXT_DIR)) sys.path.insert(0, str(TEST_CONTEXT_DIR)) from test_writer_contract import valid_context, valid_draft # noqa: E402 from writer_contract import build_writer_creative_input, canonical_json, retrieval_identity # noqa: E402 from run_writer import ( # noqa: E402 WriterAdapterError, build_writer_execution_profile, calculate_dynamic_output_contract, run_writer, ) def _bound_context() -> dict: """构造带一条事实证据且可供 adapter 绑定检查的合法上下文。""" context = valid_context() context["factEvidence"] = [ { "evidenceId": "fact-1", "fact": "林澈仍在圣蒂曼城内", "sourceType": "canonical_state", "sourceRef": { "sourceId": "state:8", "sourceVersion": "state-v1", "sourceType": "canonical_state", }, "contentSha256": "sha256:" + "a" * 64, "riskLevel": "high", } ] context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) return context def _writer_draft(_context: dict, *, body: str | None = None) -> dict: """构造 writer 模型允许返回的单字段草稿。""" return valid_draft(body=body if body is not None else "文" * 4000) class FakeRunner: """记录参数并返回预设结果,确保测试不会调用真实模型。""" def __init__(self, completed: subprocess.CompletedProcess[str] | BaseException): self.completed = completed self.calls: list[tuple[list[str], dict]] = [] def __call__(self, command: list[str], **kwargs): self.calls.append((command, kwargs)) if isinstance(self.completed, BaseException): raise self.completed return self.completed FULL_MODEL_ID = "claude-opus-4-1-20250805" def _profile(*, timeout_seconds: float = 30): """构造绑定完整模型和 WriterDraft v2 schema 的测试 profile。""" executable = pathlib.Path("/usr/bin/true") return build_writer_execution_profile( claude_executable_path=str(executable), claude_executable_sha256=hashlib.sha256(executable.read_bytes()).hexdigest(), claude_cli_version="2.1.211", resolved_model_id=FULL_MODEL_ID, effort="high", max_budget_usd_per_call=Decimal("1.500000"), timeout_seconds=timeout_seconds, max_context_chars=200000, system_prompt="只返回 WriterDraft v2:一个只含 candidateBody 的对象。", ) def _success_runner(context: dict, output: dict | None = None) -> FakeRunner: """返回模拟 Claude structured_output 成功 envelope 的 runner。""" payload = output if output is not None else _writer_draft(context) stdout = json.dumps( { "type": "result", "subtype": "success", "is_error": False, # CLI 2.1.211 正常结束的 terminal_reason 是 "completed"(subtype 才是 "success") "terminal_reason": "completed", "stop_reason": "end_turn", "api_error_status": None, "total_cost_usd": "0.120000", "usage": {"input_tokens": 100, "output_tokens": 20}, "modelUsage": { FULL_MODEL_ID: { "inputTokens": 100, "outputTokens": 20, "costUSD": "0.120000", } }, "structured_output": payload, "result": "禁止作为业务 fallback", }, ensure_ascii=False, ) return FakeRunner(subprocess.CompletedProcess(["claude"], 0, stdout=stdout, stderr="")) class RunWriterTest(unittest.TestCase): def test_adapter_uses_verified_isolation_flags_and_stdin_only(self): context = _bound_context() runner = _success_runner(context) result = run_writer( context, profile=_profile(), runner=runner, binding_verifier=lambda _profile: None, ) self.assertEqual(result["candidateVersion"], 1) self.assertEqual(result["schemaVersion"], "candidate-envelope-v2") command, kwargs = runner.calls[0] required_pairs = { "--output-format": "json", "--model": FULL_MODEL_ID, "--effort": "high", "--max-budget-usd": "1.500000", "--tools": "", } self.assertIn("--print", command) self.assertIn("--bare", command) self.assertIn("--no-session-persistence", command) self.assertIn("--disable-slash-commands", command) self.assertIn("--strict-mcp-config", command) self.assertIn("--json-schema", command) self.assertIn("--system-prompt", command) for flag, value in required_pairs.items(): self.assertEqual(command[command.index(flag) + 1], value) self.assertEqual(command[0], "/usr/bin/sandbox-exec") self.assertIn("/usr/bin/true", command) creative_input = build_writer_creative_input(context) self.assertEqual(kwargs["input"], canonical_json(creative_input)) self.assertTrue(kwargs["text"]) self.assertTrue(kwargs["capture_output"]) self.assertNotIn("shell", kwargs) self.assertNotIn(canonical_json(creative_input), command) self.assertNotIn("PATH", kwargs["env"]) serialized_input = kwargs["input"] for forbidden in ( "runId", "authorizationSnapshot", "retrievalManifest", "contextSnapshot", "candidateVersion", "acceptanceEligible", "contentSha256", ): self.assertNotIn(forbidden, serialized_input) def test_timeout_nonzero_non_json_and_schema_error_fail_closed(self): context = _bound_context() invalid_schema = _writer_draft(context) invalid_schema["candidateSha256"] = "sha256:" + "a" * 64 cases = { "WRITER_TIMEOUT": FakeRunner(subprocess.TimeoutExpired(["claude"], 1)), "WRITER_NONZERO_EXIT": FakeRunner( subprocess.CompletedProcess(["claude"], 7, stdout="", stderr="拒绝执行") ), "WRITER_RECEIPT_INVALID": FakeRunner( subprocess.CompletedProcess(["claude"], 0, stdout="not-json", stderr="") ), "WRITER_SCHEMA_INVALID": _success_runner(context, invalid_schema), } for expected_code, runner in cases.items(): with self.subTest(expected_code=expected_code), self.assertRaises(WriterAdapterError) as raised: run_writer( context, profile=_profile(timeout_seconds=1), runner=runner, binding_verifier=lambda _profile: None, ) self.assertEqual(raised.exception.code, expected_code) self.assertFalse(raised.exception.acceptance_eligible) self.assertNotIn("拒绝执行", str(raised.exception.details)) def test_candidate_length_must_fit_dynamic_context_range(self): context = _bound_context() too_short = _writer_draft(context, body="短" * 3599) with self.assertRaises(WriterAdapterError) as raised: run_writer( context, profile=_profile(), runner=_success_runner(context, too_short), binding_verifier=lambda _profile: None, ) self.assertEqual(raised.exception.code, "candidate_length_out_of_range") self.assertEqual(raised.exception.details["actualHanChars"], 3599) def test_model_cannot_supply_candidate_hash_or_other_envelope_fields(self): context = _bound_context() output = _writer_draft(context) output["candidateSha256"] = "sha256:" + "a" * 64 with self.assertRaises(WriterAdapterError) as raised: run_writer( context, profile=_profile(), runner=_success_runner(context, output), binding_verifier=lambda _profile: None, ) self.assertEqual(raised.exception.code, "WRITER_SCHEMA_INVALID") def test_dynamic_output_contract_uses_outline_density_and_frozen_history(self): contract = calculate_dynamic_output_contract( fine_outline={ "hardConstraints": ["事件一", "事件二", "事件三"], "foreshadowingActions": [], "requiredScenes": [], }, recent_chapter_bodies=["文" * 2501, "文" * 2502, "文" * 2503, "文" * 2504], ) self.assertEqual(contract["targetChars"], 2100) self.assertEqual(contract["minChars"], 2000) self.assertEqual(contract["maxChars"], 2730) self.assertFalse(contract["frontmatterRequired"]) self.assertNotIn("newSettingDeclarationRequired", contract) def test_candidate_envelope_metadata_is_bound_by_adapter(self): context = _bound_context() result = run_writer( copy.deepcopy(context), profile=_profile(), runner=_success_runner(context), binding_verifier=lambda _profile: None, ) self.assertEqual(result["runId"], context["runId"]) self.assertEqual(result["attempt"], context["attempt"]) self.assertEqual(result["contextSnapshotId"], context["contextSnapshot"]["manifestId"]) self.assertEqual(result["contextSnapshotSha256"], context["contextSnapshot"]["contextSha256"]) self.assertEqual(result["acceptanceEligible"], context["acceptanceEligible"]) self.assertEqual( result["candidateSha256"], "sha256:" + hashlib.sha256(result["candidateBody"].encode("utf-8")).hexdigest(), ) def test_writer_profile_binds_v2_schema_and_prompt_ids(self): profile = _profile() self.assertEqual(profile.profile_version, "claude-offline-writer-v2") self.assertEqual(profile.json_schema_id, "writer-draft-v2") self.assertEqual(profile.system_prompt_id, "writer-system-prompt-v2") self.assertEqual(profile.json_schema["required"], ["candidateBody"]) self.assertFalse(profile.json_schema["additionalProperties"]) def test_real_default_requires_explicit_frozen_profile(self): """未显式传入冻结 profile 时,真实默认入口必须失败关闭。""" with self.assertRaises(WriterAdapterError) as raised: run_writer(_bound_context()) self.assertEqual(raised.exception.code, "WRITER_PROFILE_REQUIRED") def test_run_identity_and_persistence_forward_to_runtime(self): """生产记账:运行号默认随上下文,调用方与存证入口显式转发给 runtime。""" context = _bound_context() runner = _success_runner(context) events = [] result = run_writer( context, profile=_profile(), runner=runner, binding_verifier=lambda _profile: None, persist_call=events.append, ) self.assertIn("candidateBody", result) self.assertEqual(len(events), 1) event = events[0] self.assertEqual(event["run_id"], context["runId"]) self.assertEqual(event["caller"], "writer") self.assertEqual(event["purpose"], "production_generation") self.assertIn("businessInput", event["prompt"]) self.assertIn("candidateBody", event["response"]) def test_result_field_is_never_business_fallback(self): """structured_output 缺失时,即使 result 含合法 WriterDraft 也必须拒绝。""" context = _bound_context() envelope = { "type": "result", "subtype": "success", "is_error": False, # 与 CLI 2.1.211 真实 envelope 一致;若失配 RECEIPT_INVALID 会抢占主码 "terminal_reason": "completed", "stop_reason": "end_turn", "api_error_status": None, "total_cost_usd": "0.120000", "usage": {"input_tokens": 100, "output_tokens": 20}, "modelUsage": { FULL_MODEL_ID: { "inputTokens": 100, "outputTokens": 20, "costUSD": "0.120000", } }, "result": json.dumps(_writer_draft(context), ensure_ascii=False), } runner = FakeRunner( subprocess.CompletedProcess(["claude"], 0, stdout=json.dumps(envelope), stderr="") ) with self.assertRaises(WriterAdapterError) as raised: run_writer( context, profile=_profile(), runner=runner, binding_verifier=lambda _profile: None, ) self.assertEqual(raised.exception.code, "WRITER_SCHEMA_INVALID") if __name__ == "__main__": unittest.main()