test_candidate_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. import asyncio
  2. import json
  3. import tempfile
  4. import unittest
  5. from unittest.mock import patch
  6. from pydantic import ValidationError
  7. from cyber_agent.application import (
  8. AgentApplication,
  9. AgentRole,
  10. ApplicationRegistry,
  11. ApplicationServices,
  12. ProviderRef,
  13. )
  14. from cyber_agent.application.candidate import (
  15. CandidateAdoptionReceipt,
  16. CandidateLedger,
  17. CandidatePointer,
  18. CandidateReviewAction,
  19. )
  20. from cyber_agent.application.quality import CandidateValidationRecord
  21. from cyber_agent.application.candidate_service import (
  22. CandidateService,
  23. CandidateStateError,
  24. )
  25. from cyber_agent.core.artifacts import (
  26. ArtifactRef,
  27. ValidationMaterial,
  28. material_content_hash,
  29. )
  30. from cyber_agent.core.task_protocol import TaskBrief, new_task_protocol
  31. from cyber_agent.core.validation import (
  32. ScopeValidationResult,
  33. ValidationCheck,
  34. ValidationResult,
  35. )
  36. from cyber_agent.tools.registry import ToolRegistry
  37. from cyber_agent.trace.models import Trace
  38. from cyber_agent.trace.store import (
  39. FileSystemTraceStore,
  40. TraceStoreCorruptionError,
  41. )
  42. class MemoryCandidateRepository:
  43. def __init__(self):
  44. self.contents = {}
  45. self.put_calls = 0
  46. self.merge_calls = 0
  47. self.bad_artifact = False
  48. self.adoptions = {}
  49. self.adoption_calls = 0
  50. async def put_version(self, request):
  51. self.put_calls += 1
  52. return self._save(request)
  53. async def merge(self, request):
  54. self.merge_calls += 1
  55. return self._save(request)
  56. def _save(self, request):
  57. key = request.operation_id
  58. self.contents.setdefault(key, request)
  59. persisted = self.contents[key]
  60. digest = material_content_hash(persisted.content)
  61. if self.bad_artifact:
  62. digest = "0" * 64
  63. return ArtifactRef(
  64. artifact_id=(
  65. f"candidate:{persisted.candidate_id}:{persisted.revision}"
  66. ),
  67. version=str(persisted.revision),
  68. content_hash=digest,
  69. kind="candidate.output",
  70. mime_type="application/json",
  71. )
  72. async def resolve(self, ref, root_trace_id, uid):
  73. request = next(
  74. item
  75. for item in self.contents.values()
  76. if (
  77. ref.artifact_id
  78. == f"candidate:{item.candidate_id}:{item.revision}"
  79. )
  80. )
  81. return ValidationMaterial(
  82. **ref.model_dump(),
  83. root_trace_id=root_trace_id,
  84. uid=uid,
  85. content=request.content,
  86. )
  87. async def load_version(self, candidate_ref):
  88. return next(
  89. item.content
  90. for item in self.contents.values()
  91. if item.candidate_id == candidate_ref.candidate_id
  92. and item.revision == candidate_ref.revision
  93. )
  94. async def commit_adoption(self, request):
  95. self.adoption_calls += 1
  96. self.adoptions.setdefault(request.operation_id, request.candidate_ref)
  97. return CandidateAdoptionReceipt(
  98. operation_id=request.operation_id,
  99. external_id=f"published:{request.candidate_ref.candidate_id}",
  100. )
  101. def application():
  102. return AgentApplication(
  103. application_id="candidate.test",
  104. application_version="1",
  105. root_role="writer",
  106. roles=(AgentRole(
  107. role_id="writer",
  108. model="fake",
  109. system_prompt="write",
  110. ),),
  111. artifact_resolver_ref=ProviderRef(
  112. provider_id="artifacts",
  113. provider_version="1",
  114. ),
  115. candidate_repository_ref=ProviderRef(
  116. provider_id="candidates",
  117. provider_version="1",
  118. ),
  119. )
  120. class CandidateServiceTest(unittest.IsolatedAsyncioTestCase):
  121. async def asyncSetUp(self):
  122. self.temp = tempfile.TemporaryDirectory()
  123. self.store = FileSystemTraceStore(self.temp.name)
  124. self.repository = MemoryCandidateRepository()
  125. self.binding = ApplicationRegistry().register(
  126. application(),
  127. ApplicationServices(
  128. tool_registry=ToolRegistry(),
  129. artifact_resolver=self.repository,
  130. candidate_repository=self.repository,
  131. ),
  132. )
  133. self.service = CandidateService(
  134. store=self.store,
  135. application_binding=self.binding,
  136. repository=self.repository,
  137. artifact_resolver=self.repository,
  138. )
  139. await self._create_root("root-a")
  140. async def asyncTearDown(self):
  141. self.temp.cleanup()
  142. def _context(self, root_trace_id, *, task_brief=None):
  143. return {
  144. "agent_mode": "recursive",
  145. "agent_mode_revision": 3,
  146. "root_trace_id": root_trace_id,
  147. "root_task_anchor_hash": "a" * 64,
  148. "application_ref": self.binding.application_ref.model_dump(mode="json"),
  149. "application_role_id": "writer",
  150. "application_role_hash": self.binding.role("writer").role_hash,
  151. "task_protocol": new_task_protocol(task_brief),
  152. }
  153. async def _create_root(self, trace_id, *, uid="user-1"):
  154. await self.store.create_trace(Trace(
  155. trace_id=trace_id,
  156. mode="agent",
  157. agent_type="writer",
  158. uid=uid,
  159. context=self._context(trace_id),
  160. ))
  161. async def _create_child(self, trace_id, root_trace_id="root-a"):
  162. await self.store.create_trace(Trace(
  163. trace_id=trace_id,
  164. mode="agent",
  165. agent_type="writer",
  166. uid="user-1",
  167. parent_trace_id=root_trace_id,
  168. context=self._context(
  169. root_trace_id,
  170. task_brief=TaskBrief(
  171. objective="write candidate",
  172. reason="the root needs an option",
  173. completion_criteria=["candidate is complete"],
  174. expected_outputs=["one candidate"],
  175. ),
  176. ),
  177. ))
  178. async def _record_validation(self, trace_id, candidate, outcome="passed"):
  179. plan_hash = material_content_hash({
  180. "candidate": candidate.model_dump(mode="json"),
  181. "outcome": outcome,
  182. })
  183. status = "passed" if outcome == "passed" else "failed"
  184. scope = ScopeValidationResult(
  185. validator_trace_id=f"{trace_id}@validator",
  186. scope="output",
  187. outcome=outcome,
  188. checks=[ValidationCheck(
  189. check_id="quality.rule",
  190. status=status,
  191. issue=None if status == "passed" else "needs revision",
  192. )],
  193. reason="checked",
  194. retry_from=None if outcome == "passed" else "output",
  195. plan_hash=plan_hash,
  196. )
  197. result = ValidationResult(
  198. evaluated_trace_id=trace_id,
  199. outcome=outcome,
  200. scope_results=[scope],
  201. issues=[] if outcome == "passed" else ["needs revision"],
  202. retry_from=None if outcome == "passed" else "output",
  203. plan_hash=plan_hash,
  204. )
  205. await self.service.record_validation(
  206. trace_id,
  207. CandidateValidationRecord(
  208. candidate_ref=candidate,
  209. plan_hash=plan_hash,
  210. validation_result=result.model_dump(mode="json"),
  211. validated_at_sequence=candidate.created_at_sequence,
  212. ),
  213. )
  214. async def test_create_fork_merge_and_idempotent_recovery(self):
  215. first, duplicate = await asyncio.gather(
  216. self.service.manage(
  217. "root-a",
  218. operation="create",
  219. content={"text": "A"},
  220. parent_refs=[],
  221. effective_at_sequence=1,
  222. ),
  223. self.service.manage(
  224. "root-a",
  225. operation="create",
  226. content={"text": "A"},
  227. parent_refs=[],
  228. effective_at_sequence=1,
  229. ),
  230. )
  231. self.assertEqual(first, duplicate)
  232. self.assertEqual(1, self.repository.put_calls)
  233. second = await self.service.manage(
  234. "root-a",
  235. operation="create",
  236. content={"text": "B"},
  237. parent_refs=[],
  238. effective_at_sequence=2,
  239. )
  240. fork = await self.service.manage(
  241. "root-a",
  242. operation="fork",
  243. content={"text": "A2"},
  244. parent_refs=[CandidatePointer(
  245. candidate_id=first.candidate_id,
  246. revision=first.revision,
  247. )],
  248. effective_at_sequence=3,
  249. )
  250. self.assertEqual(first.candidate_id, fork.candidate_id)
  251. self.assertEqual(2, fork.revision)
  252. merged = await self.service.manage(
  253. "root-a",
  254. operation="merge",
  255. content={"text": "AB"},
  256. parent_refs=[
  257. CandidatePointer(
  258. candidate_id=fork.candidate_id,
  259. revision=fork.revision,
  260. ),
  261. CandidatePointer(
  262. candidate_id=second.candidate_id,
  263. revision=second.revision,
  264. ),
  265. ],
  266. effective_at_sequence=4,
  267. )
  268. self.assertNotIn(
  269. merged.candidate_id,
  270. {first.candidate_id, second.candidate_id},
  271. )
  272. self.assertEqual(2, len(merged.parent_refs))
  273. ledger = CandidateLedger.model_validate(
  274. await self.store.get_candidate_ledger("root-a")
  275. )
  276. self.assertEqual(4, len(ledger.candidates))
  277. self.assertTrue(all(
  278. ledger.current_state(item) == "proposed"
  279. for item in ledger.candidates
  280. ))
  281. async def test_finalize_crash_retries_same_semantic_command_identity(self):
  282. original_replace = self.store.replace_candidate_ledger
  283. writes = 0
  284. async def fail_completed_publish(root_trace_id, ledger):
  285. nonlocal writes
  286. writes += 1
  287. if writes == 2:
  288. raise OSError("simulated ledger finalize crash")
  289. await original_replace(root_trace_id, ledger)
  290. with patch.object(
  291. self.store,
  292. "replace_candidate_ledger",
  293. side_effect=fail_completed_publish,
  294. ):
  295. with self.assertRaisesRegex(OSError, "finalize crash"):
  296. await self.service.manage(
  297. "root-a",
  298. operation="create",
  299. content={"text": "survives retry"},
  300. parent_refs=[],
  301. effective_at_sequence=2,
  302. )
  303. pending = CandidateLedger.model_validate(
  304. await self.store.get_candidate_ledger("root-a")
  305. )
  306. self.assertEqual("pending", pending.operations[0].status)
  307. operation_id = pending.operations[0].operation_id
  308. recovered = await self.service.manage(
  309. "root-a",
  310. operation="create",
  311. content={"text": "survives retry"},
  312. parent_refs=[],
  313. effective_at_sequence=99,
  314. )
  315. ledger = CandidateLedger.model_validate(
  316. await self.store.get_candidate_ledger("root-a")
  317. )
  318. self.assertEqual(operation_id, ledger.operations[0].operation_id)
  319. self.assertEqual("completed", ledger.operations[0].status)
  320. self.assertEqual(1, len(ledger.candidates))
  321. self.assertEqual(1, len(self.repository.contents))
  322. self.assertEqual(
  323. recovered,
  324. ledger.candidate(ledger.operations[0].candidate),
  325. )
  326. async def test_cross_root_and_cross_task_parent_access_is_rejected(self):
  327. first = await self.service.manage(
  328. "root-a",
  329. operation="create",
  330. content={"text": "A"},
  331. parent_refs=[],
  332. effective_at_sequence=1,
  333. )
  334. await self._create_root("root-b")
  335. with self.assertRaisesRegex(CandidateStateError, "not found"):
  336. await self.service.manage(
  337. "root-b",
  338. operation="fork",
  339. content={"text": "stolen"},
  340. parent_refs=[CandidatePointer(
  341. candidate_id=first.candidate_id,
  342. revision=first.revision,
  343. )],
  344. effective_at_sequence=2,
  345. )
  346. await self._create_child("child-a")
  347. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  348. await self.service.manage(
  349. "child-a",
  350. operation="fork",
  351. content={"text": "stolen"},
  352. parent_refs=[CandidatePointer(
  353. candidate_id=first.candidate_id,
  354. revision=first.revision,
  355. )],
  356. effective_at_sequence=3,
  357. )
  358. async def test_bad_artifact_fails_without_registering_candidate(self):
  359. self.repository.bad_artifact = True
  360. with self.assertRaisesRegex(CandidateStateError, "invalid ArtifactRef"):
  361. await self.service.manage(
  362. "root-a",
  363. operation="create",
  364. content={"text": "bad"},
  365. parent_refs=[],
  366. effective_at_sequence=1,
  367. )
  368. ledger = CandidateLedger.model_validate(
  369. await self.store.get_candidate_ledger("root-a")
  370. )
  371. self.assertEqual(0, len(ledger.candidates))
  372. self.assertEqual("failed", ledger.operations[0].status)
  373. async def test_report_validation_rejects_tamper_and_wrong_owner(self):
  374. candidate = await self.service.manage(
  375. "root-a",
  376. operation="create",
  377. content={"text": "A"},
  378. parent_refs=[],
  379. effective_at_sequence=1,
  380. )
  381. await self.service.validate_report_refs("root-a", [candidate])
  382. tampered = candidate.model_copy(update={
  383. "artifact_ref": candidate.artifact_ref.model_copy(update={
  384. "version": "tampered",
  385. }),
  386. })
  387. with self.assertRaisesRegex(CandidateStateError, "does not match"):
  388. await self.service.validate_report_refs("root-a", [tampered])
  389. await self._create_child("child-a")
  390. with self.assertRaisesRegex(CandidateStateError, "another application, root, or Task"):
  391. await self.service.validate_report_refs("child-a", [candidate])
  392. async def test_ledger_rejects_future_lineage_and_store_reports_corruption(self):
  393. candidate = await self.service.manage(
  394. "root-a",
  395. operation="create",
  396. content={"text": "A"},
  397. parent_refs=[],
  398. effective_at_sequence=1,
  399. )
  400. raw = (await self.store.get_candidate_ledger("root-a"))
  401. future = candidate.model_copy(update={
  402. "revision": 2,
  403. "parent_refs": (CandidatePointer(
  404. candidate_id=candidate.candidate_id,
  405. revision=3,
  406. ),),
  407. })
  408. raw["candidates"].append(future.model_dump(mode="json"))
  409. with self.assertRaisesRegex(ValidationError, "unknown parent"):
  410. CandidateLedger.model_validate(raw)
  411. ledger_path = self.store._get_candidate_ledger_file("root-a")
  412. ledger_path.write_text("{broken", encoding="utf-8")
  413. with self.assertRaisesRegex(TraceStoreCorruptionError, "candidate ledger"):
  414. await self.store.get_candidate_ledger("root-a")
  415. async def test_cyclic_lineage_is_rejected_before_repository_side_effect(self):
  416. first = await self.service.manage(
  417. "root-a",
  418. operation="create",
  419. content={"text": "A"},
  420. parent_refs=[],
  421. effective_at_sequence=1,
  422. )
  423. raw = await self.store.get_candidate_ledger("root-a")
  424. second = first.model_copy(update={
  425. "candidate_id": "candidate-b",
  426. "parent_refs": (CandidatePointer(
  427. candidate_id=first.candidate_id,
  428. revision=first.revision,
  429. ),),
  430. })
  431. raw["candidates"][0]["parent_refs"] = [{
  432. "candidate_id": second.candidate_id,
  433. "revision": second.revision,
  434. }]
  435. raw["candidates"].append(second.model_dump(mode="json"))
  436. ledger_path = self.store._get_candidate_ledger_file("root-a")
  437. ledger_path.write_text(json.dumps(raw), encoding="utf-8")
  438. put_calls = self.repository.put_calls
  439. with self.assertRaisesRegex(ValidationError, "lineage cycle"):
  440. await self.service.manage(
  441. "root-a",
  442. operation="create",
  443. content={"text": "must not be persisted"},
  444. parent_refs=[],
  445. effective_at_sequence=2,
  446. )
  447. self.assertEqual(put_calls, self.repository.put_calls)
  448. async def test_review_adoption_is_idempotent_and_creates_rewind_barrier(self):
  449. await self._create_child("child-a")
  450. candidate = await self.service.manage(
  451. "child-a",
  452. operation="create",
  453. content={"text": "A"},
  454. parent_refs=[],
  455. effective_at_sequence=4,
  456. )
  457. await self._record_validation("child-a", candidate)
  458. action = CandidateReviewAction(
  459. action="adopt",
  460. candidate_ref=candidate,
  461. reason="selected exact revision",
  462. )
  463. first = await self.service.apply_review_actions(
  464. "root-a",
  465. "child-a",
  466. report_refs=[candidate],
  467. actions=[action],
  468. effective_at_sequence=9,
  469. )
  470. second = await self.service.apply_review_actions(
  471. "root-a",
  472. "child-a",
  473. report_refs=[candidate],
  474. actions=[action],
  475. effective_at_sequence=9,
  476. )
  477. self.assertEqual(first, second)
  478. self.assertEqual(1, self.repository.adoption_calls)
  479. self.assertEqual(1, len(self.repository.adoptions))
  480. with self.assertRaisesRegex(CandidateStateError, "committed"):
  481. await self.service.assert_rewind_allowed("root-a", 8)
  482. await self.service.assert_rewind_allowed("root-a", 9)
  483. with self.assertRaisesRegex(CandidateStateError, "terminal"):
  484. await self.service.apply_review_actions(
  485. "root-a",
  486. "child-a",
  487. report_refs=[candidate],
  488. actions=[CandidateReviewAction(
  489. action="discard",
  490. candidate_ref=candidate,
  491. reason="too late",
  492. )],
  493. effective_at_sequence=10,
  494. )
  495. async def test_adoption_retry_uses_same_command_after_parent_sequence_changes(self):
  496. await self._create_child("child-a")
  497. candidate = await self.service.manage(
  498. "child-a",
  499. operation="create",
  500. content={"text": "publish once"},
  501. parent_refs=[],
  502. effective_at_sequence=4,
  503. )
  504. await self._record_validation("child-a", candidate)
  505. action = CandidateReviewAction(
  506. action="adopt",
  507. candidate_ref=candidate,
  508. reason="stable parent decision",
  509. )
  510. first = await self.service.apply_review_actions(
  511. "root-a",
  512. "child-a",
  513. report_refs=[candidate],
  514. actions=[action],
  515. effective_at_sequence=10,
  516. )
  517. recovered = await self.service.apply_review_actions(
  518. "root-a",
  519. "child-a",
  520. report_refs=[candidate],
  521. actions=[action],
  522. effective_at_sequence=999,
  523. )
  524. self.assertEqual(first, recovered)
  525. self.assertEqual(1, self.repository.adoption_calls)
  526. self.assertEqual(1, len(self.repository.adoptions))
  527. async def test_descendant_adoption_blocks_ancestor_rewind_without_mapping(self):
  528. await self._create_child("child-a")
  529. await self.store.create_trace(Trace(
  530. trace_id="grandchild-a",
  531. mode="agent",
  532. agent_type="writer",
  533. uid="user-1",
  534. parent_trace_id="child-a",
  535. context=self._context(
  536. "root-a",
  537. task_brief=TaskBrief(
  538. objective="write descendant candidate",
  539. reason="the child needs an option",
  540. completion_criteria=["candidate is complete"],
  541. expected_outputs=["one candidate"],
  542. ),
  543. ),
  544. ))
  545. candidate = await self.service.manage(
  546. "grandchild-a",
  547. operation="create",
  548. content={"text": "nested candidate"},
  549. parent_refs=[],
  550. effective_at_sequence=4,
  551. )
  552. await self._record_validation("grandchild-a", candidate)
  553. await self.service.apply_review_actions(
  554. "child-a",
  555. "grandchild-a",
  556. report_refs=[candidate],
  557. actions=[CandidateReviewAction(
  558. action="adopt",
  559. candidate_ref=candidate,
  560. reason="commit descendant output",
  561. )],
  562. effective_at_sequence=9,
  563. )
  564. with self.assertRaisesRegex(CandidateStateError, "committed"):
  565. await self.service.assert_rewind_allowed("root-a", 100)
  566. async def test_failed_candidate_can_only_be_revised_or_discarded(self):
  567. await self._create_child("child-a")
  568. candidate = await self.service.manage(
  569. "child-a",
  570. operation="create",
  571. content={"text": "placeholder"},
  572. parent_refs=[],
  573. effective_at_sequence=4,
  574. )
  575. await self._record_validation("child-a", candidate, outcome="failed")
  576. with self.assertRaisesRegex(CandidateStateError, "passed"):
  577. await self.service.apply_review_actions(
  578. "root-a",
  579. "child-a",
  580. report_refs=[candidate],
  581. actions=[CandidateReviewAction(
  582. action="adopt",
  583. candidate_ref=candidate,
  584. reason="invalid",
  585. )],
  586. effective_at_sequence=9,
  587. )
  588. records = await self.service.apply_review_actions(
  589. "root-a",
  590. "child-a",
  591. report_refs=[candidate],
  592. actions=[CandidateReviewAction(
  593. action="revise",
  594. candidate_ref=candidate,
  595. reason="remove placeholder",
  596. )],
  597. effective_at_sequence=10,
  598. )
  599. self.assertEqual("discarded", records[0].state)
  600. self.assertIn("retry_from=output", records[0].reason)
  601. async def test_adoption_replays_same_operation_after_finalize_crash(self):
  602. await self._create_child("child-a")
  603. candidate = await self.service.manage(
  604. "child-a",
  605. operation="create",
  606. content={"text": "A"},
  607. parent_refs=[],
  608. effective_at_sequence=4,
  609. )
  610. await self._record_validation("child-a", candidate)
  611. action = CandidateReviewAction(
  612. action="adopt",
  613. candidate_ref=candidate,
  614. reason="publish once",
  615. )
  616. original_replace = self.store.replace_candidate_ledger
  617. calls = 0
  618. async def fail_finalize(root_trace_id, ledger):
  619. nonlocal calls
  620. calls += 1
  621. if calls == 2:
  622. raise OSError("crash before framework finalize")
  623. await original_replace(root_trace_id, ledger)
  624. with patch.object(
  625. self.store,
  626. "replace_candidate_ledger",
  627. side_effect=fail_finalize,
  628. ):
  629. with self.assertRaisesRegex(OSError, "finalize"):
  630. await self.service.apply_review_actions(
  631. "root-a",
  632. "child-a",
  633. report_refs=[candidate],
  634. actions=[action],
  635. effective_at_sequence=9,
  636. )
  637. pending = CandidateLedger.model_validate(
  638. await self.store.get_candidate_ledger("root-a")
  639. )
  640. self.assertEqual("adoption_pending", pending.current_state(candidate))
  641. await self.service.apply_review_actions(
  642. "root-a",
  643. "child-a",
  644. report_refs=[candidate],
  645. actions=[action],
  646. effective_at_sequence=9,
  647. )
  648. self.assertEqual(2, self.repository.adoption_calls)
  649. self.assertEqual(1, len(self.repository.adoptions))
  650. completed = CandidateLedger.model_validate(
  651. await self.store.get_candidate_ledger("root-a")
  652. )
  653. self.assertEqual("adopted", completed.current_state(candidate))
  654. if __name__ == "__main__":
  655. unittest.main()