# -*- coding: utf-8 -*- """Unit tests for Msg.append_event – event-stream-to-Msg accumulation. The test drives a single Msg object through a full, realistic streaming sequence and asserts the complete model_dump() after every individual event. Coverage -------- * TextBlock : start / delta (×2) / end * ThinkingBlock : start / delta (×2) / end * DataBlock (base-64) : start / delta (×2) / end * ToolCallBlock streaming : start / delta (×2) / end * RequireUserConfirmEvent → ASKING state * UserConfirmResultEvent (confirmed=True) → ALLOWED state * UserConfirmResultEvent (confirmed=False) → FINISHED state * ToolResultBlock text output : start / text-delta (×2) / end (SUCCESS) * RequireExternalExecutionEvent → SUBMITTED state * ExternalExecutionResultEvent → ToolResultBlock appended directly * ToolResultBlock data output : base-64 delta + URL delta / end (ERROR) * ModelCallEndEvent (×2) → usage initialized then accumulated * ReplyEndEvent → finished_at stamped * Wrong reply_id → event silently skipped * Missing block → warning, no crash """ from typing import Any from unittest.async_case import IsolatedAsyncioTestCase from utils import AnyString from agentscope.event import ( ConfirmResult, DataBlockDeltaEvent, DataBlockEndEvent, DataBlockStartEvent, ExternalExecutionResultEvent, ModelCallEndEvent, ReplyEndEvent, RequireExternalExecutionEvent, RequireUserConfirmEvent, TextBlockDeltaEvent, TextBlockEndEvent, TextBlockStartEvent, ThinkingBlockDeltaEvent, ThinkingBlockEndEvent, ThinkingBlockStartEvent, ToolCallDeltaEvent, ToolCallEndEvent, ToolCallStartEvent, ToolResultDataDeltaEvent, ToolResultEndEvent, ToolResultStartEvent, ToolResultTextDeltaEvent, UserConfirmResultEvent, ) from agentscope.message import ( Msg, ToolCallBlock, ToolResultBlock, ToolResultState, ) # --------------------------------------------------------------------------- # Fixed IDs used throughout – hard-coded so ground-truth dicts are readable. # --------------------------------------------------------------------------- _REPLY_ID = "reply_001" _SESSION_ID = "session_001" _B_TEXT = "b_text_001" # TextBlock id _B_THINK = "b_think_001" # ThinkingBlock id _B_DATA = "b_data_001" # DataBlock id _TC_ALLOW = "tc_allow_001" # tool call that gets confirmed → allowed _TC_DENY = "tc_deny_001" # tool call that gets denied → finished _TC_EXT = "tc_ext_001" # tool call for external execution _TC_IMG = "tc_img_001" # tool call whose result has data blocks _RES_DATA_B = "res_data_001" # DataBlock inside tool-result (base-64) _RES_URL_B = "res_url_001" # DataBlock inside tool-result (URL) _FIXED_END_TS = "2026-01-01T12:00:00" # deterministic finished_at # --------------------------------------------------------------------------- # Block-dict helpers – each call returns a fresh dict to avoid aliasing. # --------------------------------------------------------------------------- def _tb(block_id: str, text: str) -> dict: """Text block dict.""" return {"type": "text", "id": block_id, "text": text} def _thb(block_id: str, thinking: str) -> dict: """Thinking block dict.""" return {"type": "thinking", "id": block_id, "thinking": thinking} def _db_b64(block_id: str, data: str, media_type: str) -> dict: """DataBlock (base-64 source) dict.""" return { "type": "data", "id": block_id, "source": {"type": "base64", "data": data, "media_type": media_type}, "name": None, } def _db_url(block_id: str, url: str, media_type: str) -> dict: """DataBlock (URL source) dict.""" return { "type": "data", "id": block_id, "source": {"type": "url", "url": url, "media_type": media_type}, "name": None, } def _tcb(tc_id: str, name: str, inp: str, state: str) -> dict: """ToolCallBlock dict.""" return { "type": "tool_call", "id": tc_id, "name": name, "input": inp, "state": state, "suggested_rules": [], } def _trb(tc_id: str, name: str, output: Any, state: str) -> dict: """ToolResultBlock dict.""" return { "type": "tool_result", "id": tc_id, "name": name, "output": output, "state": state, "metadata": {}, } class EventToMessageTest(IsolatedAsyncioTestCase): """Test Msg.append_event across a full streaming event sequence.""" # ------------------------------------------------------------------ # asyncSetUp: build self.events and self.ground_truths in lock-step. # ------------------------------------------------------------------ async def asyncSetUp(self) -> None: """Build the Msg, the event list, and the ground-truth list.""" self.msg = Msg( id=_REPLY_ID, name="TestAgent", role="assistant", content=[], ) _created_at = self.msg.created_at def _base( content: list, finished_at: str | None = None, usage: dict | None = None, ) -> dict: """Return the expected model_dump() of self.msg.""" return { "name": "TestAgent", "role": "assistant", "id": _REPLY_ID, "metadata": {}, "created_at": _created_at, "finished_at": finished_at, "content": content, "usage": usage, } # ================================================================ # Stage 1 – Text block streaming # ================================================================ ev_text_start = TextBlockStartEvent( reply_id=_REPLY_ID, block_id=_B_TEXT, ) gt_text_start = _base([_tb(_B_TEXT, "")]) ev_text_delta1 = TextBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_TEXT, delta="Hello", ) gt_text_delta1 = _base([_tb(_B_TEXT, "Hello")]) ev_text_delta2 = TextBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_TEXT, delta=" World", ) gt_text_delta2 = _base([_tb(_B_TEXT, "Hello World")]) ev_text_end = TextBlockEndEvent(reply_id=_REPLY_ID, block_id=_B_TEXT) gt_text_end = _base([_tb(_B_TEXT, "Hello World")]) # unchanged # ================================================================ # Stage 2 – Thinking block streaming # ================================================================ ev_think_start = ThinkingBlockStartEvent( reply_id=_REPLY_ID, block_id=_B_THINK, ) gt_think_start = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, ""), ], ) ev_think_delta1 = ThinkingBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_THINK, delta="Let me", ) gt_think_delta1 = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me"), ], ) ev_think_delta2 = ThinkingBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_THINK, delta=" think", ) gt_think_delta2 = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), ], ) ev_think_end = ThinkingBlockEndEvent( reply_id=_REPLY_ID, block_id=_B_THINK, ) gt_think_end = _base( [ # unchanged _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), ], ) # ================================================================ # Stage 3 – Data block streaming (base-64) # ================================================================ ev_data_start = DataBlockStartEvent( reply_id=_REPLY_ID, block_id=_B_DATA, media_type="image/png", ) gt_data_start = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), _db_b64(_B_DATA, "", "image/png"), ], ) # Each delta carries an independently base64-encoded chunk (with its # own padding); ``append_event`` decodes -> concats bytes -> re-encodes # so the message reflects the concatenated underlying bytes, not a # string-concat of the chunks. Here: b"abc" + b"def" -> "YWJjZGVm". ev_data_delta1 = DataBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_DATA, data="YWJj", # base64(b"abc") media_type="image/png", ) gt_data_delta1 = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), _db_b64(_B_DATA, "YWJj", "image/png"), ], ) ev_data_delta2 = DataBlockDeltaEvent( reply_id=_REPLY_ID, block_id=_B_DATA, data="ZGVm", # base64(b"def") media_type="image/png", ) gt_data_delta2 = _base( [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), _db_b64(_B_DATA, "YWJjZGVm", "image/png"), ], ) ev_data_end = DataBlockEndEvent(reply_id=_REPLY_ID, block_id=_B_DATA) gt_data_end = _base( [ # unchanged _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), _db_b64(_B_DATA, "YWJjZGVm", "image/png"), ], ) # ================================================================ # Stage 4 – ToolCall (TC_ALLOW): stream → confirm → allowed # + text tool-result (SUCCESS) # ================================================================ ev_tc_allow_start = ToolCallStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, tool_call_name="search", ) _s4_prefix = [ _tb(_B_TEXT, "Hello World"), _thb(_B_THINK, "Let me think"), _db_b64(_B_DATA, "YWJjZGVm", "image/png"), ] gt_tc_allow_start = _base( _s4_prefix + [_tcb(_TC_ALLOW, "search", "", "pending")], ) ev_tc_allow_delta1 = ToolCallDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, delta='{"q"', ) gt_tc_allow_delta1 = _base( _s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q"', "pending")], ) ev_tc_allow_delta2 = ToolCallDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, delta=': "hi"}', ) gt_tc_allow_delta2 = _base( _s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "pending")], ) ev_tc_allow_end = ToolCallEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, ) gt_tc_allow_end = _base( # unchanged _s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "pending")], ) # RequireUserConfirmEvent → state: pending → asking _tc_allow_block = ToolCallBlock( id=_TC_ALLOW, name="search", input='{"q": "hi"}', ) ev_require_confirm = RequireUserConfirmEvent( reply_id=_REPLY_ID, tool_calls=[_tc_allow_block], ) gt_require_confirm = _base( _s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "asking")], ) # UserConfirmResultEvent (confirmed=True) → state: asking → allowed ev_user_confirmed = UserConfirmResultEvent( reply_id=_REPLY_ID, confirm_results=[ ConfirmResult(confirmed=True, tool_call=_tc_allow_block), ], ) gt_user_confirmed = _base( _s4_prefix + [_tcb(_TC_ALLOW, "search", '{"q": "hi"}', "allowed")], ) # ToolResult for _TC_ALLOW – text output ev_result_start = ToolResultStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, tool_call_name="search", ) _s4b_prefix = _s4_prefix + [ _tcb(_TC_ALLOW, "search", '{"q": "hi"}', "allowed"), ] gt_result_start = _base( _s4b_prefix + [_trb(_TC_ALLOW, "search", [], "running")], ) # First text-delta creates a new TextBlock with auto-generated ID. ev_result_text1 = ToolResultTextDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, delta="Found:", ) gt_result_text1 = _base( _s4b_prefix + [ _trb( _TC_ALLOW, "search", [{"type": "text", "id": AnyString(), "text": "Found:"}], "running", ), ], ) # Second text-delta appends to the SAME TextBlock. ev_result_text2 = ToolResultTextDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, delta=" 3 items", ) gt_result_text2 = _base( _s4b_prefix + [ _trb( _TC_ALLOW, "search", [ { "type": "text", "id": AnyString(), "text": "Found: 3 items", }, ], "running", ), ], ) ev_result_end_ok = ToolResultEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_ALLOW, state=ToolResultState.SUCCESS, ) # TOOL_RESULT_END flips the paired ToolCallBlock to FINISHED, so the # tool_call state in the prefix changes from "allowed" to "finished" # from this point onward. _s4b_done_prefix = _s4_prefix + [ _tcb(_TC_ALLOW, "search", '{"q": "hi"}', "finished"), ] gt_result_end_ok = _base( _s4b_done_prefix + [ _trb( _TC_ALLOW, "search", [ { "type": "text", "id": AnyString(), "text": "Found: 3 items", }, ], "success", ), ], ) # ================================================================ # Stage 5 – ToolCall (TC_DENY): stream → confirm → denied (finished) # ================================================================ _s5_prefix = _s4b_done_prefix + [ _trb( _TC_ALLOW, "search", [ { "type": "text", "id": AnyString(), "text": "Found: 3 items", }, ], "success", ), ] ev_tc_deny_start = ToolCallStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_DENY, tool_call_name="delete", ) gt_tc_deny_start = _base( _s5_prefix + [_tcb(_TC_DENY, "delete", "", "pending")], ) ev_tc_deny_end = ToolCallEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_DENY, ) gt_tc_deny_end = _base( # unchanged _s5_prefix + [_tcb(_TC_DENY, "delete", "", "pending")], ) _tc_deny_block = ToolCallBlock(id=_TC_DENY, name="delete", input="") ev_require_confirm_deny = RequireUserConfirmEvent( reply_id=_REPLY_ID, tool_calls=[_tc_deny_block], ) gt_require_confirm_deny = _base( _s5_prefix + [_tcb(_TC_DENY, "delete", "", "asking")], ) # UserConfirmResultEvent (confirmed=False) → state: asking → finished ev_user_denied = UserConfirmResultEvent( reply_id=_REPLY_ID, confirm_results=[ ConfirmResult(confirmed=False, tool_call=_tc_deny_block), ], ) gt_user_denied = _base( _s5_prefix + [_tcb(_TC_DENY, "delete", "", "finished")], ) # ================================================================ # Stage 6 – ToolCall (TC_EXT): external execution flow # ================================================================ _s6_prefix = _s5_prefix + [_tcb(_TC_DENY, "delete", "", "finished")] ev_tc_ext_start = ToolCallStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_EXT, tool_call_name="run_code", ) gt_tc_ext_start = _base( _s6_prefix + [_tcb(_TC_EXT, "run_code", "", "pending")], ) ev_tc_ext_end = ToolCallEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_EXT, ) gt_tc_ext_end = _base( # unchanged _s6_prefix + [_tcb(_TC_EXT, "run_code", "", "pending")], ) _tc_ext_block = ToolCallBlock(id=_TC_EXT, name="run_code", input="") ev_require_ext = RequireExternalExecutionEvent( reply_id=_REPLY_ID, tool_calls=[_tc_ext_block], ) gt_require_ext = _base( _s6_prefix + [_tcb(_TC_EXT, "run_code", "", "submitted")], ) # ExternalExecutionResultEvent – appends a ToolResultBlock directly. _ext_result_block = ToolResultBlock( id=_TC_EXT, name="run_code", output="output: hello", state=ToolResultState.SUCCESS, ) ev_ext_result = ExternalExecutionResultEvent( reply_id=_REPLY_ID, execution_results=[_ext_result_block], ) _s6b_prefix = _s6_prefix + [_tcb(_TC_EXT, "run_code", "", "submitted")] gt_ext_result = _base( _s6b_prefix + [_trb(_TC_EXT, "run_code", "output: hello", "success")], ) # ================================================================ # Stage 7 – ToolResult with data output: base-64 + URL blocks # ================================================================ _s7_prefix = _s6b_prefix + [ _trb(_TC_EXT, "run_code", "output: hello", "success"), ] ev_tc_img_start = ToolCallStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, tool_call_name="screenshot", ) gt_tc_img_start = _base( _s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")], ) ev_tc_img_end = ToolCallEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, ) gt_tc_img_end = _base( # unchanged _s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")], ) ev_res_img_start = ToolResultStartEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, tool_call_name="screenshot", ) _s7b_prefix = _s7_prefix + [_tcb(_TC_IMG, "screenshot", "", "pending")] gt_res_img_start = _base( _s7b_prefix + [_trb(_TC_IMG, "screenshot", [], "running")], ) # Base-64 data delta → DataBlock(base64) appended to output ev_res_img_b64 = ToolResultDataDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, block_id=_RES_DATA_B, media_type="image/png", data="iVBOR==", ) gt_res_img_b64 = _base( _s7b_prefix + [ _trb( _TC_IMG, "screenshot", [_db_b64(_RES_DATA_B, "iVBOR==", "image/png")], "running", ), ], ) # URL data delta → DataBlock(url) appended to output ev_res_img_url = ToolResultDataDeltaEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, block_id=_RES_URL_B, media_type="image/jpeg", url="https://example.com/img.jpg", ) gt_res_img_url = _base( _s7b_prefix + [ _trb( _TC_IMG, "screenshot", [ _db_b64(_RES_DATA_B, "iVBOR==", "image/png"), _db_url( _RES_URL_B, "https://example.com/img.jpg", "image/jpeg", ), ], "running", ), ], ) ev_res_img_end = ToolResultEndEvent( reply_id=_REPLY_ID, tool_call_id=_TC_IMG, state=ToolResultState.ERROR, ) # TOOL_RESULT_END flips the paired ToolCallBlock to FINISHED. _s7b_done_prefix = _s7_prefix + [ _tcb(_TC_IMG, "screenshot", "", "finished"), ] gt_res_img_end = _base( _s7b_done_prefix + [ _trb( _TC_IMG, "screenshot", [ _db_b64(_RES_DATA_B, "iVBOR==", "image/png"), _db_url( _RES_URL_B, "https://example.com/img.jpg", "image/jpeg", ), ], "error", ), ], ) # ================================================================ # Stage 8 – ModelCallEndEvent (first call: usage initialized; # second call: usage accumulated) # ================================================================ _final_content = _s7b_done_prefix + [ _trb( _TC_IMG, "screenshot", [ _db_b64(_RES_DATA_B, "iVBOR==", "image/png"), _db_url( _RES_URL_B, "https://example.com/img.jpg", "image/jpeg", ), ], "error", ), ] ev_model_call_end_1 = ModelCallEndEvent( reply_id=_REPLY_ID, input_tokens=10, output_tokens=20, ) gt_model_call_end_1 = _base( _final_content, usage={"input_tokens": 10, "output_tokens": 20}, ) ev_model_call_end_2 = ModelCallEndEvent( reply_id=_REPLY_ID, input_tokens=5, output_tokens=8, ) gt_model_call_end_2 = _base( _final_content, usage={"input_tokens": 15, "output_tokens": 28}, ) # ================================================================ # Stage 9 – ReplyEndEvent # ================================================================ ev_reply_end = ReplyEndEvent( reply_id=_REPLY_ID, session_id=_SESSION_ID, created_at=_FIXED_END_TS, ) gt_reply_end = _base( _final_content, finished_at=_FIXED_END_TS, usage={"input_tokens": 15, "output_tokens": 28}, ) # ================================================================ # Assemble the two parallel lists # ================================================================ self.events = [ # Stage 1: Text ev_text_start, ev_text_delta1, ev_text_delta2, ev_text_end, # Stage 2: Thinking ev_think_start, ev_think_delta1, ev_think_delta2, ev_think_end, # Stage 3: Data (base-64) ev_data_start, ev_data_delta1, ev_data_delta2, ev_data_end, # Stage 4: ToolCall → confirm (allowed) + text result (success) ev_tc_allow_start, ev_tc_allow_delta1, ev_tc_allow_delta2, ev_tc_allow_end, ev_require_confirm, ev_user_confirmed, ev_result_start, ev_result_text1, ev_result_text2, ev_result_end_ok, # Stage 5: ToolCall → confirm (denied) ev_tc_deny_start, ev_tc_deny_end, ev_require_confirm_deny, ev_user_denied, # Stage 6: ToolCall → external execution ev_tc_ext_start, ev_tc_ext_end, ev_require_ext, ev_ext_result, # Stage 7: ToolResult with data output (base-64 + URL) ev_tc_img_start, ev_tc_img_end, ev_res_img_start, ev_res_img_b64, ev_res_img_url, ev_res_img_end, # Stage 8: MODEL_CALL_END (init + accumulate) ev_model_call_end_1, ev_model_call_end_2, # Stage 9: REPLY_END ev_reply_end, ] self.ground_truths = [ # Stage 1 gt_text_start, gt_text_delta1, gt_text_delta2, gt_text_end, # Stage 2 gt_think_start, gt_think_delta1, gt_think_delta2, gt_think_end, # Stage 3 gt_data_start, gt_data_delta1, gt_data_delta2, gt_data_end, # Stage 4 gt_tc_allow_start, gt_tc_allow_delta1, gt_tc_allow_delta2, gt_tc_allow_end, gt_require_confirm, gt_user_confirmed, gt_result_start, gt_result_text1, gt_result_text2, gt_result_end_ok, # Stage 5 gt_tc_deny_start, gt_tc_deny_end, gt_require_confirm_deny, gt_user_denied, # Stage 6 gt_tc_ext_start, gt_tc_ext_end, gt_require_ext, gt_ext_result, # Stage 7 gt_tc_img_start, gt_tc_img_end, gt_res_img_start, gt_res_img_b64, gt_res_img_url, gt_res_img_end, # Stage 8 gt_model_call_end_1, gt_model_call_end_2, # Stage 9 gt_reply_end, ] # ------------------------------------------------------------------ # Tests # ------------------------------------------------------------------ async def test_append_event_stream(self) -> None: """Apply every event in order and assert the full Msg state after each. Uses a zip loop over self.events / self.ground_truths so that the event index is clear from the assertion failure message. """ self.assertEqual( len(self.events), len(self.ground_truths), "events and ground_truths must have equal length", ) for idx, (event, expected) in enumerate( zip(self.events, self.ground_truths), ): self.msg.append_event(event) self.assertDictEqual( self.msg.model_dump(), expected, msg=f"Mismatch after event[{idx}] ({event.type})", ) async def test_wrong_reply_id_is_skipped(self) -> None: """An event whose reply_id does not match msg.id must be ignored.""" original_dump = self.msg.model_dump() wrong_event = TextBlockStartEvent( reply_id="totally_wrong_id", block_id="should_not_appear", ) self.msg.append_event(wrong_event) self.assertDictEqual( self.msg.model_dump(), original_dump, msg="Msg must not change when event.reply_id does not match", ) async def test_missing_block_does_not_crash(self) -> None: """Sending a delta for a non-existent block must log a warning only.""" original_dump = self.msg.model_dump() # Delta events for blocks that were never started ghost_events = [ TextBlockDeltaEvent( reply_id=_REPLY_ID, block_id="ghost_text", delta="x", ), ThinkingBlockDeltaEvent( reply_id=_REPLY_ID, block_id="ghost_think", delta="x", ), DataBlockDeltaEvent( reply_id=_REPLY_ID, block_id="ghost_data", data="x", media_type="image/png", ), ToolCallDeltaEvent( reply_id=_REPLY_ID, tool_call_id="ghost_tc", delta="x", ), ToolResultTextDeltaEvent( reply_id=_REPLY_ID, tool_call_id="ghost_tr", delta="x", ), ToolResultDataDeltaEvent( reply_id=_REPLY_ID, tool_call_id="ghost_tr", media_type="image/png", data="x", ), ToolResultEndEvent( reply_id=_REPLY_ID, tool_call_id="ghost_tr", state=ToolResultState.SUCCESS, ), ] for ev in ghost_events: self.msg.append_event(ev) # must not raise self.assertDictEqual( self.msg.model_dump(), original_dump, msg="Msg must not change when delta targets a missing block", ) async def asyncTearDown(self) -> None: """No teardown needed."""