[Lldb-commits] [lldb] [lldb-dap] Add a new lldb-dap test infrastructure (PR #203978)

Ebuka Ezike via lldb-commits lldb-commits at lists.llvm.org
Fri Jun 26 08:55:59 PDT 2026


https://github.com/da-viper updated https://github.com/llvm/llvm-project/pull/203978

>From fb41361ead7bec4d2794e8380b7c413f461c6f10 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <e_ezike at apple.com>
Date: Mon, 15 Jun 2026 18:55:43 +0100
Subject: [PATCH 1/8] [lldb-dap] Add a new lldb-dap test infrastructure

Introduce a new test infrastructure under lldbsuite/test/tools/lldb_dap
that replaces the dict-based dap_server. Initially the new and legacy version
will live side by side.
Once all tests are ported, the legacy version will be removed.
The new infrastructure has four modules:
- dap_types.py: DAP protocol messages as frozen dataclasses,
    plus serializer/deserializer functions to and from JSON.
- utils.py: Helper classes for launching and managing lldb-dap process and its connection.
- session.py: DAP session layer. Owns the per-session state.
- session_helpers.py: Higher-level helpers for writing tests.

Using dataclasses for protocol types lets us catch unexpected
or missing fields at deserialization time instead of at use-site.

Adds a new environment flag, LLDBDAP_RUN_AS_SERVER, that runs the
test suite against lldb-dap in server mode for test cases with
USE_DEFAULT_DEBUG_ADAPTER=True. In the future this could be extended
to run the entire suite through a single lldb-dap server.

Migrates TestDAP_launch_basic and TestDAP_invalidatedEvent to show the
new style and Add unittests for the new primitives:
DAPConnection, EventHistory, and the DAP type (de)serializer.

Some class and function names may not be ideal. I'm open to suggestions.

More information in [discourse page](https://discourse.llvm.org/t/rfc-making-the-lldb-dap-test-infrastructure-more-reliable/90694)
---
 .../lldbsuite/test/tools/lldb_dap/__init__.py |    5 +
 .../test/tools/lldb_dap/dap_types.py          | 2019 +++++++++++++++++
 .../test/tools/lldb_dap/lldb_dap_testcase.py  |  221 ++
 .../lldbsuite/test/tools/lldb_dap/session.py  |  529 +++++
 .../test/tools/lldb_dap/session_helpers.py    | 1699 ++++++++++++++
 .../lldbsuite/test/tools/lldb_dap/utils.py    |  809 +++++++
 .../TestDAP_invalidatedEvent.py               |   67 +-
 .../lldb-dap/launch/TestDAP_launch_basic.py   |   16 +-
 .../utils/TestDAPUtils_DAPConnection.py       |   82 +
 .../utils/TestDAPUtils_EventHistory.py        |  252 ++
 .../lldb-dap/utils/TestDAPUtils_Types.py      |  452 ++++
 .../tools/lldb-dap/utils/sample_message.json  |   38 +
 12 files changed, 6154 insertions(+), 35 deletions(-)
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
 create mode 100644 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
 create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
 create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
 create mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
 create mode 100644 lldb/test/API/tools/lldb-dap/utils/sample_message.json

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
new file mode 100644
index 0000000000000..5e378bfd8b97f
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/__init__.py
@@ -0,0 +1,5 @@
+from .lldb_dap_testcase import DAPTestCaseBase
+from .session_helpers import DAPTestSession
+from .utils import DebugAdapter, DebugAdapterOptions
+
+__all__ = ["DAPTestCaseBase", "DAPTestSession", "DebugAdapter", "DebugAdapterOptions"]
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
new file mode 100644
index 0000000000000..5a3460f7fa226
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -0,0 +1,2019 @@
+# NOTE: this module must not include `from __future__ import annotations`
+# as the `annotations` import changes some of the types hints to strings.
+# especially when you have a forward declared type reference.
+# see https://peps.python.org/pep-0649/#motivation-for-this-pep
+# https://peps.python.org/pep-0749/#rejected-alternatives
+#
+# This module may not depend on any other module.
+
+from contextlib import suppress
+import copy
+import dataclasses
+import enum
+import json
+import os
+import sys
+import typing
+from dataclasses import asdict, dataclass, field, is_dataclass
+from enum import Enum
+from functools import lru_cache
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    List,
+    Literal,
+    Optional,
+    Protocol,
+    Set,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+    runtime_checkable,
+)
+
+if sys.version_info >= (3, 11):
+    from enum import StrEnum
+else:
+
+    class StrEnum(str, Enum):
+        """Backport of StrEnum for Python < 3.11."""
+
+        def __str__(self) -> str:
+            return self.value
+
+        def __repr__(self) -> str:
+            return self.value
+
+        @staticmethod
+        def _generate_next_value_(name: str, start, count, last_values) -> str:
+            return name.lower()
+
+
+T = TypeVar("T")
+
+
+class DAPError(AssertionError):
+    """The base error for all DAP related errors
+
+    Inherits from assertion error because of unittests treats assertions outside of a test as a failure
+    instead of a test error. see https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp
+    """
+
+    @classmethod
+    def history_closed(cls, reason=None, last_event: Optional["Event"] = None):
+        suffix = f" (Reason: {reason})" if reason else ""
+        return cls(
+            f"EventHistory is closed{suffix}. Last recorded event: {last_event}."
+        )
+
+
+RawMessage = Dict[str, Any]
+"""Representation of a json protocol message """
+
+
+class MessageType(StrEnum):
+    REQUEST = "request"
+    RESPONSE = "response"
+    EVENT = "event"
+
+
+class EventName(StrEnum):
+    BREAKPOINT = "breakpoint"
+    CAPABILITIES = "capabilities"
+    CONTINUED = "continued"
+    EXITED = "exited"
+    INITIALIZED = "initialized"
+    INVALIDATED = "invalidated"
+    MEMORY = "memory"
+    MODULE = "module"
+    OUTPUT = "output"
+    PROCESS = "process"
+    PROGRESS_END = "progressEnd"
+    PROGRESS_START = "progressStart"
+    PROGRESS_UPDATE = "progressUpdate"
+    STOPPED = "stopped"
+    TERMINATED = "terminated"
+    THREAD = "thread"
+
+
+ at dataclass(frozen=True)
+class ProtocolMessage:
+    type: MessageType
+    seq: int
+
+    def to_dict(self):
+        return message_to_dict(self)
+
+    @classmethod
+    def from_json(cls: Type[T], json: RawMessage) -> T:
+        if not dataclasses.is_dataclass(cls):
+            raise ValueError(f"{cls.__name__} must be a dataclass")
+
+        return dict_to_message(cls, json)
+
+
+ at dataclass(frozen=True)
+class Request(ProtocolMessage):
+    command: str
+    arguments: Any
+
+    def __post_init__(self):
+        assert (
+            self.type == MessageType.REQUEST
+        ), f"expected request type to be 'request' got '{self.type}' in : {self}"
+
+
+ at dataclass(frozen=True)
+class Response(ProtocolMessage):
+    command: str
+    request_seq: int
+    success: bool
+
+    def __post_init__(self):
+        assert (
+            self.type == MessageType.RESPONSE
+        ), f"expected '{type(self).__name__}' to be of type response: {self}"
+
+    def __init_subclass__(cls, **kwargs):
+        super().__init_subclass__(**kwargs)
+        if not dataclasses.is_dataclass(cls):
+            raise TypeError(f"{cls.__name__} must be a dataclass")
+
+
+AnyResponse = TypeVar("AnyResponse", bound=Response)
+
+
+class RequestError(DAPError):
+    """Raised if a DAP request fails."""
+
+    def __init__(
+        self,
+        request: Union[Request, dict],
+        response: Optional[Union[Response, dict]] = None,
+    ):
+        super().__init__()
+        self.request = request
+        self.response = response
+
+    def __str__(self) -> str:
+        desc = f"request failed request={self.request!r}"
+        if self.response:
+            desc += f" response={self.response!r}"
+        return desc
+
+
+ at dataclass(frozen=True)
+class Event(ProtocolMessage):
+    event: Union[EventName, str]
+    type: MessageType
+    __registry: ClassVar[Dict[str, Type]] = {}
+
+    def __init_subclass__(cls, *, event: str, **kwargs):
+        super().__init_subclass__(**kwargs)
+
+        assert event is not None
+        # Attach metadata (not a field of an event).
+        cls.__message_type__ = event
+
+        # Prevent duplicate types.
+        existing_event_class = Event.__registry.get(event)
+        if existing_event_class is not None:
+            raise Exception(
+                f"cannot register '{event}' event to class '{cls}' because it is already registered to '{existing_event_class}'"
+            )
+
+        Event.__registry[event] = cls
+
+    def __post_init__(self):
+        if self.type != MessageType.EVENT:
+            raise ValueError(f"event must have type of 'event': {self}")
+
+    @classmethod
+    def from_json(cls: Type[T], json: dict, check_class: bool = True) -> T:
+        event_type = json["event"]
+
+        if event_type not in Event.__registry:
+            raise ValueError(f"event type '{event_type}' is not registered")
+
+        event_class = Event.__registry[event_type]
+
+        if check_class and type(cls) != type(event_class):
+            raise ValueError(
+                f"class: {type(cls)} is not of the expected type {type(event_class)}"
+            )
+
+        if not dataclasses.is_dataclass(cls):
+            raise ValueError(f"{cls.__name__} must be a dataclass")
+
+        return dict_to_message(event_class, json)
+
+
+AnyEvent = TypeVar("AnyEvent", bound=Event)
+
+
+class EmptyBodyResponse(Response):
+    body: None = field(init=False, default=None)
+
+
+def args_protocol(cls):
+    """Decorator to check a class conforms to the ArgsProtocol"""
+
+    required_fields = filter(lambda x: not x.startswith("_"), dir(ArgsProtocol))
+    for r_field in required_fields:
+        if not hasattr(cls, r_field):
+            raise AttributeError(
+                f"{cls.__name__} must define '{r_field}' to implement ArgProtocol"
+            )
+
+    command_name = getattr(cls, "command_")
+    if not issubclass(type(command_name), str):
+        raise TypeError(
+            f"the command_ type '{type(command_name)}' for class '{cls.__name__}' must be string like"
+        )
+
+    return cls
+
+
+def _message_to_dict_impl(obj: typing.Any, skip_none: bool = True) -> typing.Any:
+    if dataclasses.is_dataclass(obj):
+        fields = _get_dataclass_fields(type(obj))
+        visited: Dict[str, Any] = {}
+        for f in fields:
+            dict_name = f.metadata.get("alias", f.name)
+            if dict_name in visited or not hasattr(obj, f.name):
+                continue
+            name_attr = getattr(obj, f.name)
+            if skip_none and name_attr is None:
+                continue
+            visited[dict_name] = _message_to_dict_impl(name_attr, skip_none)
+        return visited
+
+    if isinstance(obj, (list, tuple)):
+        return type(obj)(_message_to_dict_impl(item, skip_none) for item in obj)
+
+    if isinstance(obj, dict):
+        result: Dict[str, Any] = {}
+        for key, value in obj.items():
+            result[str(key)] = _message_to_dict_impl(value, skip_none)
+        return result
+
+    # Test enum first as it can also be a subclass of other primitives.
+    if isinstance(obj, Enum):
+        return obj.value
+
+    if isinstance(obj, (bool, int, float, str, bytes, type, type(None))):
+        return obj
+
+    return copy.deepcopy(obj)
+
+
+def message_to_dict(args: Any) -> RawMessage:
+    """
+    Converts DAP types to dictionaries.
+    We always skip optional types in dataclasses during conversion.
+    """
+    if not is_dataclass(args):
+        raise TypeError(
+            f"expected a dataclass instance, got {type(args).__name__}: {args!r}"
+        )
+    result = _message_to_dict_impl(args)
+    assert isinstance(result, dict)
+    return result
+
+
+ at lru_cache
+def _get_dataclass_fields(cls: Type) -> Tuple[dataclasses.Field, ...]:
+    data_class_hints = typing.get_type_hints(cls)
+    result: List[dataclasses.Field] = []
+    for f in dataclasses.fields(cls):  # noqa
+        # Ignores 'command_' and 'response_class_'.
+        if f.name.endswith("_"):
+            continue
+        f_copy = copy.copy(f)
+        f_copy.type = data_class_hints[f_copy.name]
+        result.append(f_copy)
+    return tuple(result)
+
+
+ at lru_cache
+def _prepare_dataclass_fields(cls: Type) -> Dict[str, dataclasses.Field]:
+    # Excludes init=False fields since they cannot be passed to __init__.
+    return {f.name: f for f in _get_dataclass_fields(cls) if f.init}
+
+
+def _get_compatible_union_types(data: Any, possible_types: List[Type]) -> List[Type]:
+    """Filters a list of candidate types to find those compatible with the given raw data.
+
+    This helps fail early on type mismatches during parsing. For example, it
+    ensures that a raw payload like `[10, 20]` is correctly matched to `List[int]`
+    rather than a primitive `int`.
+    """
+
+    def _is_type_compatible(candidate_type: Type, data: Any) -> bool:
+        """Helper function to check if a single type is compatible with the data."""
+        origin = typing.get_origin(candidate_type)
+
+        if dataclasses.is_dataclass(candidate_type) or origin is dict:
+            return isinstance(data, dict)
+
+        if origin in (list, tuple):
+            return isinstance(data, (list, tuple))
+
+        if candidate_type is bool:
+            return isinstance(data, bool)
+
+        if candidate_type in (int, float):
+            # Prevent implicit bool conversion as bool is a subclass of int.
+            return isinstance(data, (int, float)) and not isinstance(data, bool)
+
+        if candidate_type is str:
+            return isinstance(data, str)
+
+        if isinstance(candidate_type, type) and issubclass(candidate_type, enum.Enum):
+            if issubclass(candidate_type, enum.IntEnum):
+                return isinstance(data, int) and not isinstance(data, bool)
+            return isinstance(data, str)
+
+        # Fallback for unknown or other generic types
+        return True
+
+    result = [a_type for a_type in possible_types if _is_type_compatible(a_type, data)]
+    return result
+
+
+def _generic_to_message(cls: Optional[Type], data: Any, scope: List[str]) -> Any:
+    origin = typing.get_origin(cls)
+    args = typing.get_args(cls)
+    full_path = ".".join(scope)
+
+    if origin is Literal:
+        unique_literals: Set = set()
+
+        def flatten_literal(args: Any):
+            """'Literal' types can be nested e.g.
+            Literal[Literal["book"], Literal["pen"]] is the same as  Literal["book", "pen"]
+            """
+            for val in args:
+                if typing.get_origin(val) is None:
+                    unique_literals.add(val)
+                else:
+                    flatten_literal(typing.get_args(val))
+
+        flatten_literal(args)
+
+        if data not in unique_literals:
+            raise ValueError(
+                f"expected one of {unique_literals!r} at {full_path}, got {data!r}"
+            )
+        return data
+
+    elif origin is Union:
+        none_type = type(None)
+        is_optional = none_type in args
+        candidate_args = [a for a in args if a is not none_type]
+
+        # Handle Optional.
+        # Optional is represented as Union[T, None].
+        if is_optional and len(candidate_args) == 1:
+            return _dict_to_message_impl(candidate_args[0], data, scope)
+
+        matches = _get_compatible_union_types(data, candidate_args)
+        if not matches:
+            raise TypeError(
+                f"no variant of {cls} is compatible with "
+                f"{type(data).__name__} at {full_path}: {data!r}"
+            )
+
+        # Only one match, try conversion.
+        if len(matches) == 1:
+            return _dict_to_message_impl(matches[0], data, scope)
+
+        for arg in matches:
+            with suppress(TypeError, ValueError, AttributeError, AssertionError):
+                return _dict_to_message_impl(arg, data, scope)
+
+        raise TypeError(
+            f"no variant of {cls} matched {type(data).__name__} at {full_path}: {data!r}"
+        )
+
+    elif origin is list:
+        if not isinstance(data, list):
+            raise TypeError(
+                f"expected list at {full_path}, got {type(data).__name__}: {data!r}"
+            )
+        list_type = args[0]
+        return [
+            _dict_to_message_impl(list_type, val, scope + [str(idx)])
+            for idx, val in enumerate(data)
+        ]
+
+    elif origin is tuple:
+        if not isinstance(data, list):
+            raise TypeError(
+                f"expected list for tuple at {full_path}, got {type(data).__name__}: {data!r}"
+            )
+        item_type = args[0]
+        return tuple(
+            _dict_to_message_impl(item_type, val, scope + [str(idx)])
+            for idx, val in enumerate(data)
+        )
+
+    elif origin is dict:
+        if not isinstance(data, dict):
+            raise TypeError(
+                f"expected dict at {full_path}, got {type(data).__name__}: {data!r}"
+            )
+        if len(args) == 0:
+            args = (str, Any)
+        key_type = args[0]
+        value_type = args[1]
+        return {
+            _dict_to_message_impl(key_type, key, scope): _dict_to_message_impl(
+                value_type, value, scope + [str(key)]
+            )
+            for key, value in data.items()
+        }
+
+    raise TypeError(f"unhandled generic type {cls} at {full_path}: {data!r}")
+
+
+def _dict_to_message_impl(
+    cls: Optional[Type], data: typing.Any, scope: List[str]
+) -> typing.Any:
+    """
+    Recursively deserializes a dictionary into a specified Python type or dataclass.
+
+    Args:
+        cls: The target Python type or dataclass to deserialize the data into.
+        data: The raw data (usually from a dictionary or JSON payload) to be converted.
+        scope: A list of keys representing the current depth in the nested data structure.
+               Used to provide error messages when validation fails.
+
+    Returns:
+        The deserialized data cast to the requested type or instantiated dataclass.
+
+    Raises:
+        TypeError: If the data type does not match the expected type and cannot be coerced.
+        ValueError: If a dataclass field has a required value that does not match the data.
+    """
+    if not cls:
+        return data
+
+    full_path = ".".join(scope)
+    if data is None:
+        # Only pass None through if the declared type permits it.
+        if cls is type(None):
+            return None
+        is_union = typing.get_origin(cls) is Union
+        if is_union and type(None) in typing.get_args(cls):  # Is Optional
+            return None
+
+        raise TypeError(f"got None for non-optional type '{cls}' at {full_path}")
+
+    if cls in (bytes, bytearray, Any):
+        return data
+
+    if dataclasses.is_dataclass(cls):
+        if not isinstance(data, dict):
+            raise TypeError(
+                f"expected dict for '{cls.__name__}' at {full_path}, "
+                f"got {type(data).__name__}: {data!r}"
+            )
+        fields = _prepare_dataclass_fields(cls)
+        deserialized = {}
+        for key, f in fields.items():
+            data_key = f.metadata.get("alias", key)
+            if data_key not in data:
+                continue
+
+            required_value = f.metadata.get("required", dataclasses.MISSING)
+            value = _dict_to_message_impl(f.type, data[data_key], scope + [key])
+
+            if required_value is not dataclasses.MISSING and value != required_value:
+                raise ValueError(
+                    f"field '{key}' at {full_path} must be {required_value!r}, "
+                    f"got {value!r}"
+                )
+            deserialized[key] = value
+        try:
+            return cls(**deserialized)
+        except TypeError as err:
+            msg = f"\n\tfailed to initialize '{cls.__name__}' at '{full_path}' "
+            msg += f'\n\twith dict: "{data}"'
+            err.args = (err.args[0] + msg, *err.args[1:])
+            raise
+
+    if cls in (str, int, float):
+        if isinstance(data, cls):
+            return data
+        # Allow int to float coercion but not bool as bool is a subclass of int:
+        if cls is float and isinstance(data, int) and not isinstance(data, bool):
+            return float(data)
+        raise TypeError(
+            f"expected {cls.__name__} at {full_path}, "
+            f"got {type(data).__name__}: {data!r}"
+        )
+
+    if cls is bool:
+        if not isinstance(data, bool):
+            raise TypeError(
+                f"expected bool at {full_path}, " f"got {type(data).__name__}: {data!r}"
+            )
+        return data
+
+    if isinstance(cls, type) and issubclass(cls, enum.Enum):
+        if issubclass(cls, enum.IntEnum):
+            return cls(int(data))
+        return cls(data)
+
+    # Handle generic types i.e List[T], Tuple[T] e.t.c.
+    if typing.get_origin(cls) is not None:
+        return _generic_to_message(cls, data, scope)
+
+    raise TypeError(
+        f"unexpected type {cls} at {full_path}: " f"got {type(data).__name__}: {data!r}"
+    )
+
+
+def dict_to_message(cls: Type[T], raw_dict: RawMessage) -> T:
+    return _dict_to_message_impl(cls, raw_dict, [cls.__name__])
+
+
+ at runtime_checkable
+class ArgsProtocol(Protocol[T]):  # type: ignore[misc]
+    __dataclass_fields__: ClassVar[Dict]
+
+    @property
+    def response_class_(self) -> Type[T]: ...
+
+    @property
+    def command_(self) -> str: ...
+
+
+class Console(StrEnum):
+    INTERNAL = "internalConsole"
+    INTEGRATED_TERMINAL = "integratedTerminal"
+    EXTERNAL_TERMINAL = "externalTerminal"
+
+
+class OutputCategory(StrEnum):
+    CONSOLE = "console"
+    IMPORTANT = "important"
+    STDOUT = "stdout"
+    STDERR = "stderr"
+    TELEMETRY = "telemetry"
+
+
+class StoppedReason(StrEnum):
+    STEP = "step"
+    BREAKPOINT = "breakpoint"
+    EXCEPTION = "exception"
+    PAUSE = "pause"
+    ENTRY = "entry"
+    GOTO = "goto"
+    FUNCTION_BREAKPOINT = "function breakpoint"
+    DATA_BREAKPOINT = "data breakpoint"
+    INSTRUCTION_BREAKPOINT = "instruction breakpoint"
+
+
+class ModuleReason(StrEnum):
+    NEW = "new"
+    CHANGED = "changed"
+    REMOVED = "removed"
+
+
+class BreakpointReason(StrEnum):
+    NEW = "new"
+    CHANGED = "changed"
+    REMOVED = "removed"
+
+
+class InvalidatedAreas(StrEnum):
+    ALL = "all"
+    STACKS = "stacks"
+    THREADS = "threads"
+    VARIABLES = "variables"
+
+
+class StartDebuggingRequestType(StrEnum):
+    LAUNCH = "launch"
+    ATTACH = "attach"
+
+
+ExceptionBreakMode = Literal["never", "always", "unhandled", "userUnhandled"]
+ChecksumAlgorithm = Literal["MD5", "SHA1", "SHA256", "timestamp"]
+
+ScopePresentationHint = Literal["arguments", "locals", "registers"]
+SourcePresentationHint = Literal["normal", "emphasize", "deemphasize"]
+StepInTargetPresentationHint = Literal["normal", "label", "subtle"]
+GotoTargetPresentationHint = StepInTargetPresentationHint
+StackFramePresentationHint = StepInTargetPresentationHint
+
+VariablePresentationHintKind = Literal[
+    "property",
+    "method",
+    "class",
+    "data",
+    "event",
+    "baseClass",
+    "innerClass",
+    "interface",
+    "mostDerivedClass",
+    "virtual",
+    "dataBreakpoint",
+]
+
+VariablePresentationHintVisibility = Literal[
+    "public", "private", "protected", "internal", "final"
+]
+
+
+ at dataclass(frozen=True)
+class Checksum:
+    algorithm: ChecksumAlgorithm
+    checksum: str
+
+
+ at dataclass(frozen=True)
+class Source:
+    name: Optional[str] = None
+    path: Optional[str] = None
+    sourceReference: Optional[int] = None
+    presentationHint: Optional[SourcePresentationHint] = None
+    origin: Optional[str] = None
+    sources: Optional[List["Source"]] = None
+    adapterData: Optional[Any] = None
+    checksums: Optional[List[Checksum]] = None
+
+    def __post_init__(self):
+        if not self.name and not self.path and not self.sourceReference:
+            raise ValueError(
+                f"Source requires either name, path, or source_reference. {self}"
+            )
+
+    @classmethod
+    def create(cls, name: Optional[str] = None, path: Optional[str] = None, **kwargs):
+        if path and not name:
+            name = os.path.basename(path)
+
+        return cls(name=name, path=path, **kwargs)
+
+
+ at dataclass(frozen=True)
+class Breakpoint:
+    verified: bool
+    id: Optional[int] = None
+    message: Optional[str] = None
+    source: Optional[Source] = None
+    line: Optional[int] = None
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+    instructionReference: Optional[str] = None
+    offset: Optional[int] = None
+    reason: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class SourceBreakpoint:
+    line: int
+    column: Optional[int] = None
+    condition: Optional[str] = None
+    hitCondition: Optional[str] = None
+    logMessage: Optional[str] = None
+    mode: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class FunctionBreakpoint:
+    name: str
+    condition: Optional[str] = None
+    hitCondition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class DataBreakpoint:
+    dataId: str
+    accessType: Optional[str] = None
+    condition: Optional[str] = None
+    hitCondition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class InstructionBreakpoint:
+    instructionReference: str
+    offset: Optional[int] = None
+    condition: Optional[str] = None
+    hitCondition: Optional[str] = None
+    mode: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class BreakpointLocation:
+    line: int
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+
+
+ColumnDescriptorType = Literal["string", "number", "boolean", "unixTimestampUTC"]
+
+
+ at dataclass(frozen=True)
+class ColumnDescriptor:
+    attributeName: str
+    label: str
+    format: Optional[str] = None
+    type: Optional[ColumnDescriptorType] = None
+    width: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionFilterOptions:
+    filterId: str
+    condition: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionPathSegment:
+    names: List[str]
+    negate: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionOptions:
+    breakMode: ExceptionBreakMode
+    path: Optional[List[ExceptionPathSegment]] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionDetails:
+    message: Optional[str] = None
+    typeName: Optional[str] = None
+    fullTypeName: Optional[str] = None
+    evaluateName: Optional[str] = None
+    stackTrace: Optional[str] = None
+    innerException: Optional[List["ExceptionDetails"]] = None
+
+
+ at dataclass(frozen=True)
+class ExceptionBreakpointsFilter:
+    filter: str
+    label: str
+    description: Optional[str] = None
+    default: Optional[bool] = None
+    supportsCondition: Optional[bool] = None
+    conditionDescription: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Thread:
+    id: int
+    name: str
+
+
+ at dataclass(frozen=True)
+class StackFrame:
+    id: int
+    name: str
+    line: int
+    column: int
+    source: Optional[Source] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+    canRestart: Optional[bool] = None
+    instructionPointerReference: Optional[str] = None
+    moduleId: Optional[Union[int, str]] = None
+    presentationHint: Optional[StackFramePresentationHint] = None
+
+
+ at dataclass(frozen=True)
+class Scope:
+    name: str
+    variablesReference: int
+    presentationHint: Optional[ScopePresentationHint] = None
+    namedVariables: Optional[int] = None
+    indexedVariables: Optional[int] = None
+    expensive: bool = False
+    source: Optional[Source] = None
+    line: Optional[int] = None
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class VariablePresentationHint:
+    kind: Optional[VariablePresentationHintKind] = None
+    attributes: Optional[List[str]] = None
+    visibility: Optional[VariablePresentationHintVisibility] = None
+    lazy: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class Variable:
+    name: str
+    value: str
+    variablesReference: int
+    type: Optional[str] = None
+    presentationHint: Optional[VariablePresentationHint] = None
+    evaluateName: Optional[str] = None
+    namedVariables: Optional[int] = None
+    indexedVariables: Optional[int] = None
+    memoryReference: Optional[str] = None
+    declarationLocationReference: Optional[int] = None
+    valueLocationReference: Optional[int] = None
+
+    @property
+    def value_as_int(self):
+        value = self.value
+        # 'value' may have the variable value and summary.
+        # Extract the variable value since summary can have nonnumeric characters.
+        value = value.split(" ")[0]
+        if value.startswith("0x"):
+            return int(value, 16)
+        elif value.startswith("0"):
+            return int(value, 8)
+        else:
+            return int(value)
+
+
+ at dataclass(frozen=True)
+class Message:
+    id: int
+    format: str
+    variables: Optional[Dict[str, str]] = None
+    sendTelemetry: Optional[bool] = None
+    showUser: Optional[bool] = None
+    url: Optional[str] = None
+    urlLabel: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Module:
+    id: str
+    name: str
+    path: Optional[str] = None
+    isOptimized: Optional[bool] = None
+    isUserCode: Optional[bool] = None
+    version: Optional[str] = None
+    symbolStatus: Optional[str] = None
+    symbolFilePath: Optional[str] = None
+    dateTimeStamp: Optional[str] = None
+    addressRange: Optional[str] = None
+    # custom
+    debugInfoSize: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class CompletionItem:
+    label: str
+    text: Optional[str] = None
+    detail: Optional[str] = None
+    start: Optional[int] = None
+    length: int = 0
+
+    def __repr__(self):
+        # Use json as it is easier to see the diff on failure.
+        return json.dumps(asdict(self), indent=4)
+
+    def clone(self, **kwargs) -> "CompletionItem":
+        """Creates a copy of this CompletionItem with specified fields modified."""
+        return dataclasses.replace(self, **kwargs)
+
+
+ at dataclass(frozen=True)
+class ValueFormat:
+    hex: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class StackFrameFormat:
+    hex: Optional[bool] = None
+    parameters: Optional[bool] = None
+    parameterTypes: Optional[bool] = None
+    parameterNames: Optional[bool] = None
+    parameterValues: Optional[bool] = None
+    line: Optional[bool] = None
+    module: Optional[bool] = None
+    includeAll: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class GotoTarget:
+    id: int
+    label: str
+    line: int
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+    instructionPointerReference: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class StepInTarget:
+    id: int
+    label: str
+    line: Optional[int] = None
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class DisassembledInstruction:
+    address: str
+    instruction: str
+    instructionBytes: Optional[str] = None
+    symbol: Optional[str] = None
+    location: Optional[Source] = None
+    line: Optional[int] = None
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+    presentationHint: Optional[str] = None
+
+
+ at dataclass(frozen=True)
+class Capabilities:
+    supportsConfigurationDoneRequest: Optional[bool] = None
+    supportsFunctionBreakpoints: Optional[bool] = None
+    supportsConditionalBreakpoints: Optional[bool] = None
+    supportsHitConditionalBreakpoints: Optional[bool] = None
+    supportsEvaluateForHovers: Optional[bool] = None
+    exceptionBreakpointFilters: Optional[List[ExceptionBreakpointsFilter]] = None
+    supportsStepBack: Optional[bool] = None
+    supportsSetVariable: Optional[bool] = None
+    supportsRestartFrame: Optional[bool] = None
+    supportsGotoTargetsRequest: Optional[bool] = None
+    supportsStepInTargetsRequest: Optional[bool] = None
+    supportsCompletionsRequest: Optional[bool] = None
+    completionTriggerCharacters: Optional[List[str]] = None
+    supportsModulesRequest: Optional[bool] = None
+    additionalModuleColumns: Optional[List[ColumnDescriptor]] = None
+    supportedChecksumAlgorithms: Optional[List[ChecksumAlgorithm]] = None
+    supportsRestartRequest: Optional[bool] = None
+    supportsExceptionOptions: Optional[bool] = None
+    supportsValueFormattingOptions: Optional[bool] = None
+    supportsExceptionInfoRequest: Optional[bool] = None
+    supportTerminateDebuggee: Optional[bool] = None
+    supportSuspendDebuggee: Optional[bool] = None
+    supportsDelayedStackTraceLoading: Optional[bool] = None
+    supportsLoadedSourcesRequest: Optional[bool] = None
+    supportsLogPoints: Optional[bool] = None
+    supportsTerminateThreadsRequest: Optional[bool] = None
+    supportsSetExpression: Optional[bool] = None
+    supportsTerminateRequest: Optional[bool] = None
+    supportsDataBreakpoints: Optional[bool] = None
+    supportsReadMemoryRequest: Optional[bool] = None
+    supportsWriteMemoryRequest: Optional[bool] = None
+    supportsDisassembleRequest: Optional[bool] = None
+    supportsCancelRequest: Optional[bool] = None
+    supportsBreakpointLocationsRequest: Optional[bool] = None
+    supportsClipboardContext: Optional[bool] = None
+    supportsSteppingGranularity: Optional[bool] = None
+    supportsInstructionBreakpoints: Optional[bool] = None
+    supportsExceptionFilterOptions: Optional[bool] = None
+    supportsSingleThreadExecutionRequests: Optional[bool] = None
+    supportsDataBreakpointBytes: Optional[bool] = None
+    breakpointModes: Optional[List[Any]] = None
+    lldb_version: Optional[str] = field(
+        metadata={"alias": "$__lldb_version"}, default=None
+    )
+
+    # lldb-dap custom capability.
+    supportsModuleSymbolsRequest: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class ErrorResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        error: Optional[Message] = None
+
+    message: Optional[str] = None
+    body: Optional[Body] = None
+
+    def __post_init__(self):
+        assert not self.success, f"success field must be 'False' {self}"
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CancelArgs:
+    requestId: Optional[int] = None
+    progressId: Optional[str] = None
+
+    command_ = "cancel"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class RunInTerminalResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        processId: Optional[int] = None
+        shellProcessId: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class RunInTerminalArgs:
+    # TODO: Fix this cwd is not optional
+    cwd: Optional[str] = None
+    args: List[str] = field(default_factory=list)
+    kind: Optional[Literal["integrated", "external"]] = None
+    title: Optional[str] = None
+    env: Optional[Dict[str, Union[str, None]]] = None
+    argsCanBeInterpretedByShell: Optional[bool] = None
+
+
+ at dataclass(frozen=True)
+class RunInTerminalRequest(Request):
+    arguments: RunInTerminalArgs
+
+
+ReverseResponse = Union[RunInTerminalResponse, EmptyBodyResponse, ErrorResponse]
+"""Possible Responses from a reverse Request"""
+
+
+ at dataclass(frozen=True)
+class StartDebuggingRequestArgs:
+    configuration: Dict[str, Any] = field(default_factory=dict)
+    request: StartDebuggingRequestType = StartDebuggingRequestType.LAUNCH
+
+    command_ = "startDebugging"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class InitializeResponse(Response):
+    body: Capabilities
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class InitializeArgs:
+    adapterID: str
+    clientID: Optional[str] = None
+    clientName: Optional[str] = None
+    locale: Optional[str] = None
+    linesStartAt1: Optional[bool] = None
+    columnsStartAt1: Optional[bool] = None
+    pathFormat: Optional[Literal["path", "uri"]] = None
+    supportsVariableType: Optional[bool] = None
+    supportsVariablePaging: Optional[bool] = None
+    supportsRunInTerminalRequest: Optional[bool] = True
+    supportsMemoryReferences: Optional[bool] = None
+    supportsProgressReporting: Optional[bool] = None
+    supportsInvalidatedEvent: Optional[bool] = None
+    supportsMemoryEvent: Optional[bool] = None
+    supportsArgsCanBeInterpretedByShell: Optional[bool] = None
+    supportsStartDebuggingRequest: Optional[bool] = None
+    supportsANSIStyling: Optional[bool] = None
+    sourceInitFile: bool = field(
+        metadata={"alias": "$__lldbSourceInitFile"}, default=False
+    )
+
+    command_ = "initialize"
+    response_class_ = InitializeResponse
+
+
+ at dataclass(frozen=True)
+class InitializedEvent(Event, event=EventName.INITIALIZED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.INITIALIZED})
+
+    @dataclass(frozen=True)
+    class Body:
+        lldb_statistics: Dict[str, Any] = field(
+            default_factory=dict, metadata={"alias": "$__lldb_statistics"}
+        )
+
+    body: Optional[Body] = None
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ConfigurationDoneArgs:
+    command_ = "configurationDone"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class LaunchArgs:
+    program: str
+    noDebug: bool = False
+    launchCommands: Optional[List[str]] = None
+    cwd: Optional[str] = None
+    args: Optional[List[str]] = None
+    env: Optional[Union[Dict[str, str], List[str]]] = None
+    detachOnError: Optional[bool] = None
+    disableASLR: bool = False
+    disableSTDIO: bool = False
+    shellExpandArguments: bool = False
+    console: Console = Console.INTERNAL
+    stdio: Optional[List[Optional[str]]] = None
+
+    # Configurations.
+    debuggerRoot: Optional[str] = None
+    enableAutoVariableSummaries: bool = False
+    enableSyntheticChildDebugging: bool = False
+    displayExtendedBacktrace: bool = False
+    stopOnEntry: bool = False
+    timeout: Optional[float] = None
+    commandEscapePrefix: Optional[str] = None
+    customFrameFormat: Optional[str] = None
+    customThreadFormat: Optional[str] = None
+    sourcePath: Optional[str] = None
+    sourceMap: Optional[Union[List[Tuple[str, str]], Dict[str, str]]] = None
+    preInitCommands: Optional[List[str]] = None
+    initCommands: Optional[List[str]] = None
+    preRunCommands: Optional[List[str]] = None
+    postRunCommands: Optional[List[str]] = None
+    stopCommands: Optional[List[str]] = None
+    exitCommands: Optional[List[str]] = None
+    terminateCommands: Optional[List[str]] = None
+
+    command_ = "launch"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class AttachArgs:
+    restart: Optional[Any] = field(metadata={"alias": "__restart"}, default=None)
+
+    attachCommands: Optional[List[str]] = None
+    pid: Optional[int] = None
+    waitFor: Optional[bool] = None
+    gdbRemotePort: Optional[int] = None
+    gdbRemoteHostname: Optional[str] = None
+    coreFile: Optional[str] = None
+    program: Optional[str] = None
+
+    # Configurations.
+    debuggerRoot: Optional[str] = None
+    enableAutoVariableSummaries: Optional[bool] = None
+    enableSyntheticChildDebugging: Optional[bool] = None
+    displayExtendedBacktrace: Optional[bool] = None
+    stopOnEntry: Optional[bool] = None
+    timeout: Optional[float] = None
+    commandEscapePrefix: Optional[str] = None
+    customFrameFormat: Optional[str] = None
+    customThreadFormat: Optional[str] = None
+    sourcePath: Optional[str] = None
+    sourceMap: Optional[Union[List[Tuple[str, str]], Dict[str, str]]] = None
+    preInitCommands: Optional[List[str]] = None
+    initCommands: Optional[List[str]] = None
+    preRunCommands: Optional[List[str]] = None
+    postRunCommands: Optional[List[str]] = None
+    stopCommands: Optional[List[str]] = None
+    exitCommands: Optional[List[str]] = None
+    terminateCommands: Optional[List[str]] = None
+
+    command_ = "attach"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class RestartArgs:
+    arguments: Optional[Union[LaunchArgs, AttachArgs]] = None
+
+    command_ = "restart"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DisconnectArgs:
+    restart: Optional[bool] = None
+    terminateDebuggee: Optional[bool] = None
+    suspendDebuggee: Optional[bool] = None
+
+    command_ = "disconnect"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class TerminateArgs:
+    restart: Optional[bool] = None
+
+    command_ = "terminate"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class BreakpointLocationsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        breakpoints: List[BreakpointLocation]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class BreakpointLocationsArgs:
+    source: Source
+    line: int
+    column: Optional[int] = None
+    endLine: Optional[int] = None
+    endColumn: Optional[int] = None
+
+    command_ = "breakpointLocations"
+    response_class_ = BreakpointLocationsResponse
+
+
+ at dataclass(frozen=True)
+class AnyBreakpointsResponse(Response):
+    """The response for 'setBreakpoints', 'setFunctionBreakpoints', 'setDataBreakpoints'
+    and 'setInstructionBreakpoints'"""
+
+    @dataclass(frozen=True)
+    class Body:
+        breakpoints: List[Breakpoint]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetBreakpointsArgs:
+    source: Source
+    breakpoints: Optional[List[SourceBreakpoint]] = None
+    lines: Optional[List[int]] = None
+    sourceModified: Optional[bool] = None
+
+    command_ = "setBreakpoints"
+    response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetFunctionBreakpointsArgs:
+    breakpoints: List[FunctionBreakpoint]
+
+    command_ = "setFunctionBreakpoints"
+    response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class SetExceptionBreakpointsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        breakpoints: Optional[List[Breakpoint]] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetExceptionBreakpointsArgs:
+    filters: List[str]
+    filterOptions: Optional[List[ExceptionFilterOptions]] = None
+    exceptionOptions: Optional[List[ExceptionOptions]] = None
+
+    command_ = "setExceptionBreakpoints"
+    response_class_ = SetExceptionBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DAPTestGetTargetBreakpointsArgs:
+    command_ = "_testGetTargetBreakpoints"
+    response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class DataBreakpointInfoResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        description: str
+        dataId: Optional[str] = None
+        accessTypes: Optional[List[str]] = None
+        canPersist: Optional[bool] = None
+        canBreakOnBytes: Optional[bool] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DataBreakpointInfoArgs:
+    name: str
+    variablesReference: Optional[int] = None
+    frameId: Optional[int] = None
+    bytes: Optional[int] = None
+    asAddress: Optional[bool] = None
+    mode: Optional[str] = None
+
+    command_ = "dataBreakpointInfo"
+    response_class_ = DataBreakpointInfoResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetDataBreakpointsArgs:
+    breakpoints: List[DataBreakpoint]
+
+    command_ = "setDataBreakpoints"
+    response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetInstructionBreakpointsArgs:
+    breakpoints: List[InstructionBreakpoint]
+
+    command_ = "setInstructionBreakpoints"
+    response_class_ = AnyBreakpointsResponse
+
+
+ at dataclass(frozen=True)
+class CompileUnit:
+    compileUnitPath: str
+
+
+ at dataclass(frozen=True)
+class CompileUnitsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        compileUnits: List[CompileUnit]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CompileUnitsArgs:
+    moduleId: str
+
+    command_ = "compileUnits"
+    response_class_ = CompileUnitsResponse
+
+
+ at dataclass(frozen=True)
+class ContinueResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        allThreadsContinued: Optional[bool] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ContinueArgs:
+    threadId: int = 0
+    singleThread: Optional[bool] = None
+
+    command_ = "continue"
+    response_class_ = ContinueResponse
+
+
+SteppingGranularity = Literal["statement", "line", "instruction"]
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class NextArgs:
+    threadId: int = 0
+    singleThread: Optional[bool] = None
+    granularity: Optional[SteppingGranularity] = None
+
+    command_ = "next"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepInArgs:
+    threadId: int = 0
+    singleThread: Optional[bool] = None
+    targetId: Optional[int] = None
+    granularity: Optional[SteppingGranularity] = None
+
+    command_ = "stepIn"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepOutArgs:
+    threadId: int
+    singleThread: Optional[bool] = None
+    granularity: Optional[SteppingGranularity] = None
+
+    command_ = "stepOut"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class GotoArgs:
+    threadId: int
+    targetId: int
+
+    command_ = "goto"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class PauseArgs:
+    threadId: int
+
+    command_ = "pause"
+    response_class_ = EmptyBodyResponse
+
+
+ at dataclass(frozen=True)
+class StackTraceResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        stackFrames: List[StackFrame]
+        totalFrames: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StackTraceArgs:
+    threadId: int
+    startFrame: Optional[int] = None
+    levels: Optional[int] = None
+    format: Optional[StackFrameFormat] = None
+
+    command_ = "stackTrace"
+    response_class_ = StackTraceResponse
+
+
+ at dataclass(frozen=True)
+class ScopesResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        scopes: List[Scope]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ScopesArgs:
+    frameId: int
+
+    command_ = "scopes"
+    response_class_ = ScopesResponse
+
+
+ at dataclass(frozen=True)
+class VariablesResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        variables: List[Variable]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class VariablesArgs:
+    variablesReference: int
+    filter: Optional[Literal["indexed", "named"]] = None
+    start: Optional[int] = None
+    count: Optional[int] = None
+    format: Optional[ValueFormat] = None
+
+    command_ = "variables"
+    response_class_ = VariablesResponse
+
+
+ at dataclass(frozen=True)
+class SetVariableResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        value: str
+        type: Optional[str] = None
+        variablesReference: Optional[int] = None
+        namedVariables: Optional[int] = None
+        indexedVariables: Optional[int] = None
+        memoryReference: Optional[str] = None
+        valueLocationReference: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SetVariableArgs:
+    variablesReference: int
+    name: str
+    value: str
+    format: Optional[ValueFormat] = None
+
+    command_ = "setVariable"
+    response_class_ = SetVariableResponse
+
+
+ at dataclass(frozen=True)
+class SourceResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        content: str
+        mimeType: Optional[str] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class SourceArgs:
+    sourceReference: int
+    source: Optional[Source] = None
+
+    command_ = "source"
+    response_class_ = SourceResponse
+
+
+ at dataclass(frozen=True)
+class ThreadsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        threads: List[Thread]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ThreadsArgs:
+    command_ = "threads"
+    response_class_ = ThreadsResponse
+
+
+ at dataclass(frozen=True)
+class ModulesResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        modules: List[Module]
+        totalModules: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ModulesArgs:
+    startModule: Optional[int] = None
+    moduleCount: Optional[int] = None
+
+    command_ = "modules"
+    response_class_ = ModulesResponse
+
+
+ at dataclass(frozen=True)
+class ModuleSymbol:
+    """Mirrors the `Symbol` struct produced by lldb-dap's `moduleSymbols`
+    request."""
+
+    id: int
+    isDebug: bool
+    isSynthetic: bool
+    isExternal: bool
+    type: str
+    fileAddress: int
+    size: int
+    name: str
+    loadAddress: Optional[int] = None
+
+
+ at dataclass(frozen=True)
+class ModuleSymbolsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        symbols: List[ModuleSymbol]
+        totalSymbols: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ModuleSymbolsArgs:
+    moduleName: str
+    moduleId: str = ""
+    startIndex: Optional[int] = None
+    count: Optional[int] = None
+
+    command_ = "__lldb_moduleSymbols"
+    response_class_ = ModuleSymbolsResponse
+
+
+ at dataclass(frozen=True)
+class EvaluateResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        result: str
+        variablesReference: int
+        type: Optional[str] = None
+        presentationHint: Optional[VariablePresentationHint] = None
+        namedVariables: Optional[int] = None
+        indexedVariables: Optional[int] = None
+        memoryReference: Optional[str] = None
+        valueLocationReference: Optional[int] = None
+
+    body: Body
+
+
+EvaluateContext = Literal["watch", "repl", "hover", "clipboard", "variables"]
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class EvaluateArgs:
+    expression: str
+    frameId: Optional[int] = None
+    context: Optional[EvaluateContext] = None
+    format: Optional[ValueFormat] = None
+
+    command_ = "evaluate"
+    response_class_ = EvaluateResponse
+
+ at dataclass(frozen=True)
+class StepInTargetsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        targets: List[StepInTarget]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class StepInTargetsArgs:
+    frameId: int
+
+    command_ = "stepInTargets"
+    response_class_ = StepInTargetsResponse
+
+
+ at dataclass(frozen=True)
+class GotoTargetsResponse(Response):
+    class Body:
+        targets: List[GotoTarget]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class GotoTargetsArgs:
+    source: Source
+    line: int
+    column: Optional[int] = None
+
+    command_ = "gotoTargets"
+    response_class_ = GotoTargetsResponse
+
+
+ at dataclass(frozen=True)
+class CompletionsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        targets: List[CompletionItem]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class CompletionsArgs:
+    text: str
+    column: int
+    frameId: Optional[int] = None
+    line: Optional[int] = None
+
+    command_ = "completions"
+    response_class_ = CompletionsResponse
+
+
+ at dataclass(frozen=True)
+class ExceptionInfoResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        exceptionId: str
+        breakMode: ExceptionBreakMode
+        description: Optional[str] = None
+        details: Optional[ExceptionDetails] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ExceptionInfoArgs:
+    threadId: int
+
+    command_ = "exceptionInfo"
+    response_class_ = ExceptionInfoResponse
+
+
+ at dataclass(frozen=True)
+class ReadMemoryResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        address: str
+        unreadableBytes: Optional[int] = None
+        data: Optional[str] = None  # base64-encoded bytes
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class ReadMemoryArgs:
+    memoryReference: str
+    count: int
+    offset: Optional[int] = None
+
+    command_ = "readMemory"
+    response_class_ = ReadMemoryResponse
+
+
+ at dataclass(frozen=True)
+class WriteMemoryResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        offset: Optional[int] = None
+        bytesWritten: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class WriteMemoryArgs:
+    memoryReference: str
+    data: str  # base64-encoded bytes
+    offset: Optional[int] = None
+    allowPartial: Optional[bool] = None
+
+    command_ = "writeMemory"
+    response_class_ = WriteMemoryResponse
+
+
+ at dataclass(frozen=True)
+class DisassembleResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        instructions: List[DisassembledInstruction]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class DisassembleArgs:
+    memoryReference: str
+    instructionCount: int
+    offset: Optional[int] = None
+    instructionOffset: Optional[int] = None
+    resolveSymbols: Optional[bool] = None
+
+    command_ = "disassemble"
+    response_class_ = DisassembleResponse
+
+
+ at dataclass(frozen=True)
+class LocationsResponse(Response):
+    @dataclass(frozen=True)
+    class Body:
+        source: Source
+        line: int
+        column: Optional[int] = None
+        endLine: Optional[int] = None
+        endColumn: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+ at args_protocol
+class LocationsArgs:
+    locationReference: int
+
+    command_ = "locations"
+    response_class_ = LocationsResponse
+
+
+ at dataclass(frozen=True)
+class StoppedEvent(Event, event=EventName.STOPPED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.STOPPED})
+
+    @dataclass(frozen=True)
+    class Body:
+        reason: StoppedReason
+        description: Optional[str] = None
+        threadId: Optional[int] = None
+        preserveFocusHint: Optional[bool] = None
+        text: Optional[str] = None
+        allThreadsStopped: Optional[bool] = None
+        hitBreakpointIds: Optional[List[int]] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ContinuedEvent(Event, event=EventName.CONTINUED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.CONTINUED})
+
+    @dataclass(frozen=True)
+    class Body:
+        threadId: int
+        allThreadsContinued: Optional[bool] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ExitedEvent(Event, event=EventName.EXITED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.EXITED})
+
+    @dataclass(frozen=True)
+    class Body:
+        exitCode: int
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class TerminatedEvent(Event, event=EventName.TERMINATED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.TERMINATED})
+
+    @dataclass(frozen=True)
+    class Body:
+        restart: Optional[Any] = None
+        lldb_statistics: Dict[str, Any] = field(
+            default_factory=dict, metadata={"alias": "$__lldb_statistics"}
+        )
+
+    body: Optional[Body] = None
+
+
+ at dataclass(frozen=True)
+class ThreadEvent(Event, event=EventName.THREAD):
+    event: Union[EventName, str] = field(metadata={"required": EventName.THREAD})
+
+    @dataclass(frozen=True)
+    class Body:
+        threadId: int
+        reason: Literal["started", "exited"]
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class OutputEvent(Event, event=EventName.OUTPUT):
+    event: Union[EventName, str] = field(metadata={"required": EventName.OUTPUT})
+
+    @dataclass(frozen=True)
+    class Body:
+        output: str
+        category: OutputCategory = OutputCategory.CONSOLE  # defaults to console
+        group: Optional[Literal["start", "startCollapsed", "end"]] = None
+        variablesReference: Optional[int] = None
+        source: Optional[Source] = None
+        line: Optional[int] = None
+        column: Optional[int] = None
+        data: Optional[Any] = None
+        locationReference: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class BreakpointEvent(Event, event=EventName.BREAKPOINT):
+    event: Union[EventName, str] = field(metadata={"required": EventName.BREAKPOINT})
+
+    @dataclass(frozen=True)
+    class Body:
+        reason: BreakpointReason
+        breakpoint: Breakpoint
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ModuleEvent(Event, event=EventName.MODULE):
+    event: Union[EventName, str] = field(metadata={"required": EventName.MODULE})
+
+    @dataclass(frozen=True)
+    class Body:
+        reason: ModuleReason
+        module: Module
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ProcessEvent(Event, event=EventName.PROCESS):
+    event: Union[EventName, str] = field(metadata={"required": EventName.PROCESS})
+
+    @dataclass(frozen=True)
+    class Body:
+        name: str
+        systemProcessId: Optional[int] = None
+        isLocalProcess: Optional[bool] = None
+        startMethod: Optional[Literal["launch", "attach"]] = None
+        pointerSize: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class CapabilitiesEvent(Event, event=EventName.CAPABILITIES):
+    event: Union[EventName, str] = field(metadata={"required": EventName.CAPABILITIES})
+
+    @dataclass(frozen=True)
+    class Body:
+        capabilities: Capabilities
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressStartEvent(Event, event=EventName.PROGRESS_START):
+    event: Union[EventName, str] = field(
+        metadata={"required": EventName.PROGRESS_START}
+    )
+
+    @dataclass(frozen=True)
+    class Body:
+        progressId: str
+        title: str
+        requestId: Optional[int] = None
+        cancellable: Optional[bool] = None
+        message: Optional[str] = None
+        percentage: Optional[float] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressUpdateEvent(Event, event=EventName.PROGRESS_UPDATE):
+    event: Union[EventName, str] = field(
+        metadata={"required": EventName.PROGRESS_UPDATE}
+    )
+
+    @dataclass(frozen=True)
+    class Body:
+        progressId: str
+        message: Optional[str] = None
+        percentage: Optional[float] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class ProgressEndEvent(Event, event=EventName.PROGRESS_END):
+    event: Union[EventName, str] = field(metadata={"required": EventName.PROGRESS_END})
+
+    @dataclass(frozen=True)
+    class Body:
+        progressId: str
+        message: Optional[str] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class InvalidatedEvent(Event, event=EventName.INVALIDATED):
+    event: Union[EventName, str] = field(metadata={"required": EventName.INVALIDATED})
+
+    @dataclass(frozen=True)
+    class Body:
+        areas: Optional[List[InvalidatedAreas]] = None
+        threadId: Optional[int] = None
+        stackFrameId: Optional[int] = None
+
+    body: Body
+
+
+ at dataclass(frozen=True)
+class MemoryEvent(Event, event=EventName.MEMORY):
+    event: Union[EventName, str] = field(metadata={"required": EventName.MEMORY})
+
+    @dataclass(frozen=True)
+    class Body:
+        memoryReference: str
+        offset: int
+        count: int
+
+    body: Body
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
new file mode 100644
index 0000000000000..b2f56b81c2cd2
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
@@ -0,0 +1,221 @@
+import gc
+import logging
+import os
+from pathlib import Path
+from typing import Any, Final, Optional, TypeVar, Union, cast
+
+from lldbsuite.test.lldbtest import Base, is_exe
+
+from .dap_types import AnyResponse, ErrorResponse, Response
+from .session_helpers import DAPTestSession
+from .utils import DebugAdapter, DebugAdapterOptions
+
+
+def strtobool(val: str) -> bool:
+    """Convert a string representation of truth to a bool following LLVM's CLI argument parsing."""
+
+    val = val.lower()
+    if val in {"false", "0", "no", "off"}:
+        return False
+    return True
+
+
+T = TypeVar("T")
+
+
+class DAPTestCaseBase(Base):
+    """Base test case for DAP tests"""
+
+    NO_DEBUG_INFO_TESTCASE = True
+    DEFAULT_TIMEOUT: Final[float] = 500.0 if "ASAN_OPTIONS" in os.environ else 50.0
+
+    USE_DEFAULT_DEBUG_ADAPTER: bool = True
+    """Subclasses can set this to true to avoid creating a debug adapter is will not be used."""
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.run_as_server: bool = strtobool(os.getenv("LLDBDAP_RUN_AS_SERVER", "false"))
+
+    def setUp(self):
+        super().setUp()
+        self.setUpBaseLogging()
+
+        self._debug_adapter_count: int = 0
+        if self.USE_DEFAULT_DEBUG_ADAPTER:
+            self.__create_default_debug_adapter()
+
+    def setUpBaseLogging(self):
+        self.logger = logging.getLogger(f"lldb_dap.{__name__}")
+        self.logger.propagate = False
+        self.logger.setLevel(logging.DEBUG)
+
+        log_path = f"{self.getLogBasenameForCurrentTest()}-test_dap.log"
+        handler = logging.FileHandler(log_path, mode="w")
+
+        # The Log name gets quite long and becomes noise. use the last log scope.
+        class _ShortNameFormatter(logging.Formatter):
+            def format(self, record: logging.LogRecord) -> str:
+                record.short_name = record.name.rsplit(".", 1)[-1]
+                return super().format(record)
+
+        handler.setFormatter(
+            _ShortNameFormatter(
+                "%(asctime)s.%(msecs)03d %(levelname)-5s (%(short_name)s) %(message)s",
+                datefmt="%H:%M:%S",
+            )
+        )
+        self.logger.addHandler(handler)
+
+        def close_log():
+            self.logger.removeHandler(handler)
+            handler.close()
+
+        self.addTearDownHook(close_log)
+
+    def tearDown(self):
+        gc.collect()
+        super().tearDown()
+
+    def __create_default_debug_adapter(self):
+        self.assertFalse(hasattr(self, "adapter"), "A default adapter already exists.")
+
+        if self.run_as_server:
+            self.adapter = self.create_server_debug_adapter(
+                DebugAdapterOptions(cwd=self.getBuildDir()),
+                connection="listen://localhost:0",
+                connection_timeout=10,
+            )
+        else:
+            self.adapter = self.create_stdio_debug_adapter(
+                DebugAdapterOptions(cwd=self.getBuildDir())
+            )
+
+    def create_session(
+        self,
+        adapter: Optional[DebugAdapter] = None,
+        disconnect_automatically: Optional[bool] = None,
+    ) -> DAPTestSession:
+        if adapter is None:
+            self.assertIsNotNone(self.adapter, "expected we already have an adapter.")
+            adapter = self.adapter
+        self.assertTrue(adapter.is_alive, "expected adapter process is alive.")
+
+        if adapter.is_server and disconnect_automatically is not None:
+            self.assertFalse(
+                disconnect_automatically,
+                "disconnect_automatically is not supported for lldb-dap running as a server",
+            )
+
+        build_dir = Path(self.getBuildDir())
+        session = DAPTestSession(
+            self,
+            build_dir,
+            adapter,
+            message_timeout=self.DEFAULT_TIMEOUT,
+            process_spawner=self.spawnSubprocess,  # type: ignore
+            logger=self.logger,
+        )
+
+        def cleanup_session():
+            # In server mode the adapter automatically shuts down after the last
+            # client disconnects.
+            if not adapter.is_server and disconnect_automatically is not False:
+                session.disconnect(terminateDebuggee=True)
+            session.stop()
+
+        self.addTearDownHook(cleanup_session)
+        session.start()
+        return session
+
+    def build_and_create_session(
+        self,
+        adapter: Optional[DebugAdapter] = None,
+        disconnect_automatically: Optional[bool] = None,
+    ) -> DAPTestSession:
+        self.build()
+        return self.create_session(adapter, disconnect_automatically)
+
+    def create_debug_adapter(
+        self, adapter_options: DebugAdapterOptions
+    ) -> DebugAdapter:
+        self.assertTrue(
+            is_exe(self.lldbDAPExec),
+            f"lldb-dap must exist and be executable. path: {self.lldbDAPExec}",
+        )
+
+        if adapter_options.log_file:
+            log_file = adapter_options.log_file
+        else:
+            count = self._debug_adapter_count
+            suffix = f"-{count}" if count else ""
+            log_file = f"{self.getLogBasenameForCurrentTest()}-dap{suffix}.log"
+
+        self._debug_adapter_count += 1
+        cwd = adapter_options.cwd or self.getBuildDir()
+        pre_init_commands = self.setUpCommands()
+
+        adapter_options = adapter_options.clone(
+            log_file=log_file, cwd=cwd, pre_init_commands=pre_init_commands
+        )
+        lldb_dap_exec = self.expect_not_none(self.lldbDAPExec)
+        adapter = DebugAdapter(executable=lldb_dap_exec, opts=adapter_options)
+        self.assertTrue(adapter.is_alive, "adapter should be running after creation.")
+
+        def cleanup_adapter():
+            if adapter.is_alive:
+                adapter.kill()
+
+        self.addTearDownHook(cleanup_adapter)
+        return adapter
+
+    def create_stdio_debug_adapter(
+        self, adapter_options: Optional[DebugAdapterOptions] = None
+    ) -> DebugAdapter:
+        """Forces the adapter to stdio mode. the DebugAdapter class handles the validation"""
+        adapter_options = adapter_options or DebugAdapterOptions()
+        self.assertIsNone(
+            adapter_options.connection, "'connection' cannot be used with stdio mode."
+        )
+
+        adapter = self.create_debug_adapter(adapter_options)
+        self.assertFalse(adapter.is_server, "adapter should be using stdio.")
+        return adapter
+
+    def create_server_debug_adapter(
+        self,
+        adapter_options: Optional[DebugAdapterOptions] = None,
+        *,
+        connection: str,
+        connection_timeout: int,
+    ) -> DebugAdapter:
+        """Forces the adapter to server mode. the DebugAdapter class handles the validation."""
+        adapter_options = adapter_options or DebugAdapterOptions()
+        adapter_options = adapter_options.clone(
+            connection=connection,
+            connection_timeout=connection_timeout,
+        )
+        adapter = self.create_debug_adapter(adapter_options)
+        self.assertTrue(adapter.is_server, "adapter should run as a server.")
+        return adapter
+
+    def expect_not_none(self, value: Optional[T], msg: Any = None) -> T:
+        """Convenience function to narrow fields that are optional, as most DAP types are."""
+        self.assertIsNotNone(value, msg=msg)
+        return cast(T, value)
+
+    def expect_error(
+        self, value: Union[Response, ErrorResponse], msg: Any = None
+    ) -> ErrorResponse:
+        """Convenience function for narrowing a response Union to `ErrorResponse`."""
+        self.assertIsInstance(value, ErrorResponse, msg=msg)
+        self.assertFalse(value.success)
+        return cast(ErrorResponse, value)
+
+    def expect_success(
+        self, value: Union[AnyResponse, ErrorResponse], msg: Any = None
+    ) -> AnyResponse:
+        """Convenience function for narrowing a response Union to the success type."""
+        self.assertNotIsInstance(value, ErrorResponse, msg=msg)
+        self.assertTrue(value.success)
+        return cast(AnyResponse, value)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
new file mode 100644
index 0000000000000..22063815b111a
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -0,0 +1,529 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import contextlib
+import dataclasses
+import functools
+import io
+import itertools
+import json
+import logging
+import os
+import subprocess
+import threading
+from concurrent import futures
+from concurrent.futures import Future
+from dataclasses import fields
+from pathlib import Path
+from typing import Any, Callable, Generic, Optional, Type, TypeVar
+
+from .dap_types import (
+    AnyResponse,
+    ArgsProtocol,
+    Capabilities,
+    CapabilitiesEvent,
+    ContinueArgs,
+    DAPError,
+    DisconnectArgs,
+    ErrorResponse,
+    Event,
+    ExitedEvent,
+    GotoArgs,
+    InitializeArgs,
+    InitializedEvent,
+    Message,
+    MessageType,
+    NextArgs,
+    OutputCategory,
+    OutputEvent,
+    RawMessage,
+    Request,
+    Response,
+    RestartArgs,
+    ReverseResponse,
+    RunInTerminalRequest,
+    RunInTerminalResponse,
+    StepInArgs,
+    StepOutArgs,
+    StoppedEvent,
+    TerminateArgs,
+    dict_to_message,
+)
+from .utils import (
+    DebugAdapter,
+    EventHistory,
+    MessageHandler,
+    SubProcessSpawner,
+    redirect_stream,
+)
+
+R = TypeVar("R")
+
+# Any Request that resumes execution (or terminates the session),
+# invalidates any frameId or variablesReference captured during the current stop.
+# Sending one of these Requests advances the session's stop_generation.
+# To prevent using a frameId or variablesReference that is no longer valid once
+# the session continues.
+_RESUMING_COMMANDS = (
+    ContinueArgs,
+    NextArgs,
+    StepInArgs,
+    StepOutArgs,
+    GotoArgs,
+    RestartArgs,
+    TerminateArgs,
+)
+
+
+class PendingResponse(Generic[AnyResponse]):
+    """A Holds the future to the expected request for the sequence id."""
+
+    def __init__(
+        self,
+        seq: int,
+        response_class: Type[AnyResponse],
+        raw_future: Future[RawMessage],
+        timeout: float,
+        command: str,
+        on_resolve: Callable[[Response], None] = lambda _: None,
+    ):
+        assert issubclass(
+            response_class, Response
+        ), f"'{response_class.__name__}' must be a subclass of Response."
+        self.seq = seq
+        self.response_class: Type[AnyResponse] = response_class
+        self._future = raw_future
+        self._timeout = timeout
+        self._command = command
+        self._on_resolve = on_resolve
+
+    def result(self, msg: Optional[str] = None) -> AnyResponse:
+        response = self.result_or_error()
+
+        if isinstance(response, self.response_class):
+            return response
+        detail = f"expected '{self.response_class.__name__}' got {response}."
+        raise DAPError(f"{msg}:\n\t{detail}" if msg else detail)
+
+    def error(self, msg: Optional[str] = None) -> ErrorResponse:
+        response = self.result_or_error()
+
+        if isinstance(response, ErrorResponse):
+            return response
+        detail = f"expected '{self.response_class.__name__}' got {response}."
+        raise DAPError(f"{msg}\n\t{detail}" if msg else detail)
+
+    def result_or_error(self) -> AnyResponse | ErrorResponse:
+        try:
+            raw = self._future.result(timeout=self._timeout)
+        except (TimeoutError, futures.TimeoutError) as e:
+            msg = f"\n\tRequest '{self._command}' (seq={self.seq}) timed out after {self._timeout}s"
+            e.args = (f"{e.args[0]}{msg}", *e.args)
+            raise
+        except ConnectionError as e:
+            raise DAPError(
+                f"Session ended before getting response for "
+                f"'{self._command}' (seq={self.seq})"
+            ) from e
+
+        cls = self.response_class if raw["success"] else ErrorResponse
+        response = cls.from_json(raw)
+
+        self._on_resolve(response)
+        return response
+
+
+def _synchronized(method: Callable[..., R]) -> Callable[..., R]:
+    """Class method decorator to acquire and release the lock automatically."""
+
+    @functools.wraps(method)
+    def wrapper(self, *args: Any, **kwargs: Any) -> R:
+        with self._lock:
+            return method(self, *args, **kwargs)
+
+    return wrapper
+
+
+class _DAPSessionState:
+    def __init__(self):
+        self._lock = threading.RLock()
+        self._initialized = False
+        self._capabilities = Capabilities()
+        self.output_streams = {
+            OutputCategory.STDOUT: io.StringIO(),
+            OutputCategory.STDERR: io.StringIO(),
+            OutputCategory.CONSOLE: io.StringIO(),
+            OutputCategory.IMPORTANT: io.StringIO(),
+            OutputCategory.TELEMETRY: io.StringIO(),
+        }
+        self._stopped_thread_id: Optional[int] = None
+        self._last_response: Optional[Response] = None
+        self._stop_generation: int = 0
+
+    @property
+    @_synchronized
+    def is_initialized(self):
+        return self._initialized
+
+    @_synchronized
+    def set_initialized(self, val: bool):
+        self._initialized = val
+
+    @property
+    @_synchronized
+    def stopped_thread_id(self):
+        return self._stopped_thread_id
+
+    @_synchronized
+    def set_stopped_thread_id(self, id: int):
+        assert isinstance(id, int)
+        self._stopped_thread_id = id
+
+    @property
+    @_synchronized
+    def stop_generation(self) -> int:
+        """The current stop's generation number.
+
+        Monotonic counter identifying the current session stop.
+        Incremented every time we send a request that resumes or terminates execution
+        (see `_RESUMING_COMMANDS`).
+        """
+        return self._stop_generation
+
+    @_synchronized
+    def advance_stop_generation(self) -> int:
+        self._stop_generation += 1
+        return self._stop_generation
+
+    @_synchronized
+    def capabilities(self):
+        return dataclasses.replace(self._capabilities)
+
+    @_synchronized
+    def update_capabilities(self, new_capabilities: Capabilities):
+        kwargs = {
+            field: value
+            for field, value in vars(new_capabilities).items()
+            if value is not None
+        }
+        self._capabilities = dataclasses.replace(self._capabilities, **kwargs)
+
+    @property
+    @_synchronized
+    def last_response(self) -> Optional[Response]:
+        return self._last_response
+
+    @_synchronized
+    def set_last_response(self, response: Response):
+        self._last_response = response
+
+
+class Session:
+    """
+    Protocol-level DAP session managing communication and state with a debug adapter.
+
+    Wraps a `DAPConnection` to handle message routing (requests, responses, and events).
+    It maintains the core session state, including negotiated capabilities.
+
+    It only exists to separate the test helpers from the implementation.
+    see `DAPTestSession`.
+    """
+
+    def __init__(
+        self,
+        test_dir: Path,
+        adapter: DebugAdapter,
+        message_timeout: float,
+        process_spawner: SubProcessSpawner,
+        logger: logging.Logger,
+    ):
+        self._test_dir = test_dir
+
+        self._message_timeout = message_timeout
+        self._process_spawner = process_spawner
+        self._next_sequence = functools.partial(next, itertools.count(start=1))
+        self._state = _DAPSessionState()
+
+        self._event_history = EventHistory(self._message_timeout)
+        self._adapter = adapter
+        self._connection = adapter.create_connection()
+        self._logger = logger.getChild(self._connection.id)
+
+        def on_connection_closed(err: Optional[Exception]):
+            # We want fail early if there is already a request for wait_for_X_event
+            # in the main thread.
+            self._event_history.close(err or Exception("Session Ended."))
+
+        msg_handler = MessageHandler(
+            on_response=self._on_protocol_response,
+            on_event=self._on_protocol_event,
+            on_reverse_request=self._on_protocol_reverse_request,
+            on_close=on_connection_closed,
+        )
+        self._read_thread = threading.Thread(
+            target=self._connection.start, args=[msg_handler], name="Read Thread"
+        )
+
+        # Function Mappings.
+        self.wait_for_earliest_event = self._event_history.wait_for_earliest_event
+        self.wait_for_any_event = self._event_history.wait_for_any_event
+        self.wait_for_event = self._event_history.wait_for_event
+        self.capabilities = self._state.capabilities
+
+        # Reverse Requests.
+        self._reverse_requests: list[Request] = []
+        self._reverse_process: Optional[subprocess.Popen[bytes]] = None
+        # The list of threads that redirects stdio when the debuggee
+        # is created using `RunInTerminal`.
+        self._reverse_process_io_threads: list[threading.Thread] = []
+
+    def last_response(self):
+        """Returns a copy of the response most recently consumed by a test.
+
+        This is the response a test last checked via (PendingResponse) not
+        the last response from the adapter. only useful when a helper function
+        does not expose the response such as `resolve_source_breakpoint`."""
+        response = self._state.last_response
+        assert response is not None, "expected at least previous response."
+        return dataclasses.replace(response)
+
+    @property
+    def stopped_thread_id(self):
+        thread_id = self._state.stopped_thread_id
+        assert thread_id is not None, "stopped thread id is never set."
+        return thread_id
+
+    def _current_stop_generation(self) -> int:
+        return self._state.stop_generation
+
+    def _check_stop_generation(self, ctx_generation: int, context: Any) -> None:
+        """Assert a context generation is still valid for the current stop."""
+        current = self._state.stop_generation
+        if ctx_generation != current:
+            raise AssertionError(
+                f"{type(context).__name__} from stop generation {ctx_generation} used at "
+                f"generation {current}: the session resumed, so "
+                f"this context's frameId/variablesReference is no longer valid."
+            )
+
+    def start(self) -> None:
+        self._read_thread.start()
+        # Synchronize with the connection.
+        self._connection.wait_until_alive(self._message_timeout)
+
+    def _on_protocol_response(self, message: RawMessage) -> None:
+        self._logger.debug("<-- %s", json.dumps(message))
+        command = message.get("command")
+        if command == InitializeArgs.command_:
+            if raw_capabilities := message.get("body"):
+                init_capabilities = dict_to_message(Capabilities, raw_capabilities)
+                self._state.update_capabilities(init_capabilities)
+        if command == DisconnectArgs.command_:
+            self._connection.stop()
+
+    def _on_protocol_event(self, message: RawMessage) -> None:
+        self._logger.debug("<-- %s", json.dumps(message))
+
+        event = Event.from_json(message)
+        event_name = event.event
+        assert event_name is not None
+
+        if isinstance(event, InitializedEvent):
+            self._state.set_initialized(True)
+        elif isinstance(event, CapabilitiesEvent):
+            self._state.update_capabilities(event.body.capabilities)
+        elif isinstance(event, StoppedEvent):
+            if (thread_id := event.body.threadId) and not event.body.preserveFocusHint:
+                self._state.set_stopped_thread_id(thread_id)
+        elif isinstance(event, OutputEvent):
+            category = self._state.output_streams[event.body.category]
+            category.write(event.body.output)
+        elif isinstance(event, ExitedEvent):
+            # If we have a 'runInTerminal' process it must have exited.
+            if self._reverse_process:
+                self.verify_reverse_process_exited()
+
+                # Join the redirect threads here. so any tail bytes are flushed
+                # into the StringIOs before the test reads them.
+                for thread in self._reverse_process_io_threads:
+                    thread.join(self._message_timeout)
+
+        # Store in general event queue.
+        self._event_history.record(event)
+
+    def _on_protocol_reverse_request(self, request: RawMessage):
+        self._logger.debug("<-- %s", json.dumps(request))
+        request_type = request.get("command", "unknown")
+        if request_type == "runInTerminal":
+            terminal_request = dict_to_message(RunInTerminalRequest, request)
+            self._reverse_requests.append(terminal_request)
+            self._handle_run_in_terminal(terminal_request)
+        else:
+            raise NotImplementedError(
+                f"no reverse request handler for '{request_type}'"
+            )
+
+    def _handle_run_in_terminal(self, request: RunInTerminalRequest):
+        request_args = request.arguments
+        [process_exe, *process_args] = request_args.args
+        # Per DAP spec, "env" contains additions/overrides to the
+        # default environment, not a full replacement. Merge with
+        # os.environ so the spawned process inherits PATH etc.
+        env_dict = os.environ.copy()
+        if request_args.env:
+            for key, value in request_args.env.items():
+                env_dict[key] = "" if value is None else value
+
+        process_env = [f"{k}={v}" for k, v in env_dict.items()]
+        self._logger.info("runInTerminal process with args: %s", process_args)
+
+        process = self._process_spawner(
+            process_exe,
+            process_args,
+            process_env,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+        )
+        return_code = process.poll()
+        if return_code is not None:
+            stdout, stderr = process.communicate()
+            response = ErrorResponse(
+                type=MessageType.RESPONSE,
+                seq=self._next_sequence(),
+                command=request.command,
+                request_seq=request.seq,
+                success=False,
+                body=ErrorResponse.Body(
+                    error=Message(
+                        id=3,
+                        format=f"failed to launch process {process_args[0]}, stdout={stdout}"
+                        f"\nstderr={stderr} return code ={return_code}",
+                    )
+                ),
+            )
+        else:
+            self._reverse_process = process
+            response = RunInTerminalResponse(
+                type=MessageType.RESPONSE,
+                seq=self._next_sequence(),
+                command="runInTerminal",
+                request_seq=request.seq,
+                success=True,
+                body=RunInTerminalResponse.Body(processId=process.pid),
+            )
+            if proc_stdout := process.stdout:
+                out_stream = self._state.output_streams[OutputCategory.STDOUT]
+                out_thread = redirect_stream(proc_stdout, out_stream, "stdout")
+                self._reverse_process_io_threads.append(out_thread)
+            if proc_stderr := process.stderr:
+                err_stream = self._state.output_streams[OutputCategory.STDERR]
+                err_thread = redirect_stream(proc_stderr, err_stream, "stderr")
+                self._reverse_process_io_threads.append(err_thread)
+
+        self._send_response(response)
+
+    def ensure_initialized(self):
+        if self._state.is_initialized:
+            return
+        self.wait_for_earliest_event(InitializedEvent)
+        # Sanity check.
+        assert self._state.is_initialized
+
+    def get_stdout(self):
+        return self._state.output_streams[OutputCategory.STDOUT].getvalue()
+
+    def get_console(self) -> str:
+        return self._state.output_streams[OutputCategory.CONSOLE].getvalue()
+
+    def get_stderr(self) -> str:
+        return self._state.output_streams[OutputCategory.STDERR].getvalue()
+
+    def get_important(self) -> str:
+        return self._state.output_streams[OutputCategory.IMPORTANT].getvalue()
+
+    def send_request(
+        self, request_args: ArgsProtocol[AnyResponse]
+    ) -> PendingResponse[AnyResponse]:
+        """Send a request and return a `PendingResponse` to wait on."""
+        assert isinstance(request_args, ArgsProtocol)
+        seq = self._next_sequence()
+
+        # Any frameId or variablesReference during this stop becomes stale once the request is sent.
+        if type(request_args) in _RESUMING_COMMANDS:
+            self._state.advance_stop_generation()
+
+        request = Request(
+            seq=seq,
+            type=MessageType.REQUEST,
+            command=request_args.command_,
+            arguments=request_args if len(fields(request_args)) > 0 else None,
+        )
+
+        self._logger.debug("--> %s", json.dumps(request.to_dict()))
+        raw_future = self._connection.send_request(request)
+        return PendingResponse(
+            seq=seq,
+            response_class=request_args.response_class_,
+            raw_future=raw_future,
+            timeout=self._message_timeout,
+            command=request_args.command_,
+            on_resolve=self._state.set_last_response,
+        )
+
+    def _send_response(self, response: ReverseResponse):
+        assert isinstance(response, Response)
+        response_dict = response.to_dict()
+        self._logger.debug("--> %s", response_dict)
+        self._connection.send_message(response_dict)
+
+    def last_reverse_request(self) -> Request:
+        assert len(self._reverse_requests) > 0, "No Reverse Request made"
+        return self._reverse_requests[-1]
+
+    def is_running(self):
+        return self._connection.is_alive()
+
+    def verify_reverse_process_exited(self, exit_code: Optional[int] = None):
+        if process := self._reverse_process:
+            proc_exit_code = process.poll()
+            if proc_exit_code is None:
+                raise DAPError(
+                    f"process is still running, "
+                    f"for process pid: '{process.pid}', args: {process.args}"
+                )
+
+            if exit_code is not None:
+                assert proc_exit_code == exit_code, (
+                    f"{proc_exit_code=} != expected_exit_code={exit_code} "
+                    f"for process pid: '{process.pid}'"
+                )
+
+    def stop(self) -> None:
+        logger = self._logger
+        self._connection.stop()
+
+        if self._read_thread.is_alive():
+            self._logger.info("Joining the read thread.")
+            self._read_thread.join(self._message_timeout)
+
+        # If the runInTerminal subprocess is still alive the redirect threads are
+        # blocked in read. Kill the process to stop the redirect threads.
+        reverse_process = self._reverse_process
+        if reverse_process and reverse_process.poll() is None:
+            logger.info("Terminating the reverse process: %s.", reverse_process.args)
+            reverse_process.terminate()
+            try:
+                reverse_process.wait(timeout=2.0)
+            except subprocess.TimeoutExpired:
+                logger.info("Force kill the reverse process: %s.", reverse_process.args)
+                reverse_process.kill()
+
+        for thread in self._reverse_process_io_threads:
+            logger.info("Joining the reverse process io thread: %s.", thread.name)
+            thread.join(timeout=self._message_timeout)
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.stop()
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
new file mode 100644
index 0000000000000..5c36d5dbed2aa
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -0,0 +1,1699 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import base64
+import dataclasses
+import logging
+import os
+import unittest
+from dataclasses import dataclass
+from pathlib import Path
+from typing import (
+    Callable,
+    Iterable,
+    Iterator,
+    Literal,
+    Optional,
+    Sequence,
+    TypeVar,
+    cast,
+)
+
+from .dap_types import (
+    AttachArgs,
+    Breakpoint,
+    BreakpointEvent,
+    BreakpointLocationsArgs,
+    CompletionsArgs,
+    ConfigurationDoneArgs,
+    ContinueArgs,
+    DataBreakpoint,
+    DataBreakpointInfoArgs,
+    DisassembleArgs,
+    DisconnectArgs,
+    EmptyBodyResponse,
+    ErrorResponse,
+    EvaluateArgs,
+    EvaluateContext,
+    EvaluateResponse,
+    Event,
+    EventName,
+    ExceptionFilterOptions,
+    ExceptionInfoArgs,
+    ExceptionOptions,
+    ExitedEvent,
+    FunctionBreakpoint,
+    InitializeArgs,
+    InitializedEvent,
+    InstructionBreakpoint,
+    InvalidatedEvent,
+    LaunchArgs,
+    LocationsArgs,
+    MemoryEvent,
+    ModuleEvent,
+    ModuleReason,
+    ModulesArgs,
+    NextArgs,
+    OutputCategory,
+    OutputEvent,
+    ProcessEvent,
+    ReadMemoryArgs,
+    ReadMemoryResponse,
+    Response,
+    RestartArgs,
+    Scope,
+    ScopesArgs,
+    SetBreakpointsArgs,
+    SetDataBreakpointsArgs,
+    SetExceptionBreakpointsArgs,
+    SetFunctionBreakpointsArgs,
+    SetInstructionBreakpointsArgs,
+    SetVariableArgs,
+    SetVariableResponse,
+    Source,
+    SourceBreakpoint,
+    StackFrame,
+    StackFrameFormat,
+    StackTraceArgs,
+    StepInArgs,
+    StepOutArgs,
+    SteppingGranularity,
+    StoppedEvent,
+    StoppedReason,
+    TerminatedEvent,
+    ThreadsArgs,
+    ValueFormat,
+    Variable,
+    VariablePresentationHint,
+    VariablesArgs,
+    WriteMemoryArgs,
+)
+from .session import PendingResponse, Session
+from .utils import DebugAdapter, SubProcessSpawner
+
+T = TypeVar("T")
+
+
+class ThreadContext:
+    """Lazy view of a debug adapter thread.
+
+    Thread ids do not have a limited lifetime, so this context is long-lived.
+    It can be reused after continue and stepXXX requests.
+    """
+
+    def __init__(self, thread_id: int, session: DAPTestSession):
+        self._thread_id: int = thread_id
+        self._session: DAPTestSession = session
+
+    @property
+    def thread_id(self) -> int:
+        return self._thread_id
+
+    def step_in(
+        self,
+        *,
+        targetId: Optional[int] = None,
+        granularity: SteppingGranularity = "statement",
+    ):
+        return self._session.step_in(
+            threadId=self.thread_id, targetId=targetId, granularity=granularity
+        )
+
+    def step_over(self, *, granularity: SteppingGranularity = "statement"):
+        return self._session.step_over(threadId=self.thread_id, granularity=granularity)
+
+    def step_out(self, *, granularity: SteppingGranularity = "statement"):
+        return self._session.step_out(threadId=self.thread_id, granularity=granularity)
+
+    def top_frame(
+        self,
+        *,
+        format: Optional[StackFrameFormat] = None,
+    ) -> FrameContext:
+        return self.frames(levels=1, format=format)[0]
+
+    def frames(
+        self,
+        *,
+        startFrame: Optional[int] = None,
+        levels: Optional[int] = None,
+        format: Optional[StackFrameFormat] = None,
+    ) -> list[FrameContext]:
+        args = StackTraceArgs(
+            self._thread_id, startFrame=startFrame, levels=levels, format=format
+        )
+        response = self._session.send_request(args).result()
+        generation = self._session._current_stop_generation()
+        return [
+            FrameContext(frame, self._session, generation)
+            for frame in response.body.stackFrames
+        ]
+
+
+class FrameContext:
+    """Lazy view of a stack frame. Valid only within its stop generation."""
+
+    def __init__(self, frame: StackFrame, session: DAPTestSession, generation: int):
+        self._frame = frame
+        self._session = session
+        self._generation = generation
+        self._scopes: Optional[list[ScopeContext]] = None
+
+    @property
+    def frame(self) -> StackFrame:
+        self._session._check_stop_generation(self._generation, self)
+        return self._frame
+
+    @property
+    def id(self) -> int:
+        return self.frame.id
+
+    @property
+    def name(self) -> str:
+        return self.frame.name
+
+    def __dir__(self):
+        # Hide the property fields that may call 'ScopesRequest' from the debugger.
+        # The python debugger will hang because it is waiting for a response
+        # when viewing the FrameContext.
+        hidden = {"locals", "globals", "registers", "scopes"}
+        return (attr for attr in super().__dir__() if attr not in hidden)
+
+    def source_and_line(self) -> tuple[str, int]:
+        frame = self.frame
+        assert frame.source is not None
+        assert frame.source.path is not None
+        assert frame.line is not None
+        return frame.source.path, frame.line
+
+    def scopes(self) -> list[ScopeContext]:
+        self._session._check_stop_generation(self._generation, self)
+        if self._scopes is None:
+            scope_args = ScopesArgs(frameId=self._frame.id)
+            response = self._session.send_request(scope_args).result()
+            self._scopes = [
+                ScopeContext(scope, self._session, self._generation)
+                for scope in response.body.scopes
+            ]
+        return self._scopes
+
+    def scope(self, name: str) -> ScopeContext:
+        scopes = self.scopes()
+        for scope in scopes:
+            if scope.scope.name == name:
+                return scope
+        scope_names = [scope.scope.name for scope in scopes]
+        self._session.test_case.fail(
+            f"scope '{name}' not in frame scopes: {scope_names}"
+        )
+
+    @property
+    def locals(self) -> ScopeContext:
+        return self.scope("Locals")
+
+    @property
+    def globals(self) -> ScopeContext:
+        return self.scope("Globals")
+
+    @property
+    def registers(self) -> ScopeContext:
+        return self.scope("Registers")
+
+    def evaluate(
+        self,
+        expression: str,
+        *,
+        context: Optional[EvaluateContext] = None,
+        format: Optional[ValueFormat] = None,
+    ):
+        """Evaluates `expression` in this frame's context."""
+        self._session._check_stop_generation(self._generation, self)
+        return self._session.evaluate(
+            expression, frameId=self._frame.id, context=context, format=format
+        )
+
+    def disassemble(self):
+        self._session._check_stop_generation(self._generation, self)
+
+        mem_ref = self._frame.instructionPointerReference
+        if mem_ref is None:
+            self._session.test_case.fail(
+                f"expects 'instructionPointerReference' for frame {self.frame}"
+            )
+        return self._session.disassemble(
+            mem_ref, instructionOffset=0, instructionCount=100
+        )
+
+
+class _VariableContainer:
+    """Shared dict-like behaviour for contexts that hold a variablesReference.
+
+    The optional `_value_format` is passed into every child-fetching
+    `variables` request in the container.
+    A child `VariableContext` inherits its parent's format,
+    so walking `locals.with_format(hex)["pt"]["x"]` keeps hex formatting all
+    the way down without the caller repeating it at each step.
+    """
+
+    _session: DAPTestSession
+    _generation: int
+    _value_format: Optional[ValueFormat] = None
+
+    def _fetch_variables(
+        self,
+        variables_reference: int,
+        *,
+        filter: Optional[Literal["indexed", "named"]] = None,
+        start: Optional[int] = None,
+        count: Optional[int] = None,
+    ) -> list[VariableContext]:
+        self._session._check_stop_generation(self._generation, self)
+        variables = self._session.get_variables(
+            variables_reference,
+            filter=filter,
+            start=start,
+            count=count,
+            format=self._value_format,
+        )
+        return [
+            VariableContext(var, self._session, self._generation, self._value_format)
+            for var in variables
+        ]
+
+    def page(
+        self,
+        *,
+        filter: Optional[Literal["indexed", "named"]] = None,
+        start: Optional[int] = None,
+        count: Optional[int] = None,
+    ) -> list[VariableContext]:
+        """Fetch a subset of children with paging/filter arguments.
+
+        Inherits the container's value format.
+        """
+        return self._fetch_variables(
+            self._container_reference(),
+            filter=filter,
+            start=start,
+            count=count,
+        )
+
+    def set(
+        self, name: str, value, *, is_hex: bool = False
+    ) -> SetVariableResponse | ErrorResponse:
+        """Sends a `setVariable` request for a named child."""
+        self._session._check_stop_generation(self._generation, self)
+        return self._session.set_variable(
+            name, value, variablesReference=self._container_reference(), is_hex=is_hex
+        )
+
+    def _container_reference(self) -> int:
+        raise NotImplementedError
+
+    def _by_name(self) -> dict[str, VariableContext]:
+        return {child.name: child for child in self._children()}
+
+    def _children(self) -> list[VariableContext]:
+        raise NotImplementedError
+
+    def __getitem__(self, name: str) -> VariableContext:
+        by_name = self._by_name()
+        try:
+            return by_name[name]
+        except KeyError:
+            self._session.test_case.fail(
+                f"'{name}' not found in {self}, has: {list(by_name)}"
+            )
+
+    def __contains__(self, name: object) -> bool:
+        return name in self._by_name()
+
+    def __iter__(self) -> Iterator[VariableContext]:
+        return iter(self._children())
+
+    def __len__(self) -> int:
+        return len(self._children())
+
+    def __str__(self) -> str:
+        return type(self).__name__
+
+
+class ScopeContext(_VariableContainer):
+    """Lazy view of a scope's variables. Valid only within its stop generation."""
+
+    def __init__(
+        self,
+        scope: Scope,
+        session: DAPTestSession,
+        generation: int,
+        value_format: Optional[ValueFormat] = None,
+    ):
+        self._scope = scope
+        self._session = session
+        self._generation = generation
+        self._value_format = value_format
+
+    @property
+    def scope(self) -> Scope:
+        self._session._check_stop_generation(self._generation, self)
+        return self._scope
+
+    @property
+    def name(self) -> str:
+        return self.scope.name
+
+    @property
+    def variablesReference(self) -> int:
+        return self.scope.variablesReference
+
+    def variables(self) -> list[VariableContext]:
+        return self._fetch_variables(self._scope.variablesReference)
+
+    def with_format(self, *, is_hex: bool = False) -> ScopeContext:
+        """Return a new ScopeContext that applies the ValueFormat."""
+        value_format = ValueFormat(hex=True) if is_hex else None
+        return ScopeContext(self._scope, self._session, self._generation, value_format)
+
+    def _container_reference(self) -> int:
+        return self._scope.variablesReference
+
+    def _children(self) -> list[VariableContext]:
+        return self.variables()
+
+    def __str__(self) -> str:
+        return f"scope '{self._scope.name}'"
+
+
+class VariableContext(_VariableContainer):
+    """Lazy view of a variable and (optionally) its children.
+
+    Valid only within its' stop generation.
+    """
+
+    def __init__(
+        self,
+        variable: Variable,
+        session: DAPTestSession,
+        generation: int,
+        value_format: Optional[ValueFormat] = None,
+    ):
+        self._variable = variable
+        self._session = session
+        self._generation = generation
+        self._value_format = value_format
+
+    @property
+    def variable(self) -> Variable:
+        self._session._check_stop_generation(self._generation, self)
+        return self._variable
+
+    @property
+    def name(self) -> str:
+        return self._variable.name
+
+    @property
+    def value(self) -> str:
+        return self._variable.value
+
+    @property
+    def value_as_int(self) -> int:
+        return self._variable.value_as_int
+
+    @property
+    def type(self) -> Optional[str]:
+        return self._variable.type
+
+    @property
+    def variablesReference(self) -> int:
+        return self._variable.variablesReference
+
+    @property
+    def memoryReference(self) -> Optional[str]:
+        return self._variable.memoryReference
+
+    @property
+    def indexedVariables(self) -> Optional[int]:
+        return self._variable.indexedVariables
+
+    @property
+    def namedVariables(self) -> Optional[int]:
+        return self._variable.namedVariables
+
+    @property
+    def has_children(self) -> bool:
+        return self._variable.variablesReference > 0
+
+    def children(self) -> list[VariableContext]:
+        if not self.has_children:
+            self._session.test_case.fail(
+                f"variable '{self._variable.name}' has no children"
+            )
+        return self._fetch_variables(self._variable.variablesReference)
+
+    def with_format(self, *, is_hex: bool = False) -> VariableContext:
+        """Return a new VariableContext that applies the ValueFormat"""
+        value_format = ValueFormat(hex=True) if is_hex else None
+        return VariableContext(
+            self._variable, self._session, self._generation, value_format
+        )
+
+    def _container_reference(self) -> int:
+        return self._variable.variablesReference
+
+    def _children(self) -> list[VariableContext]:
+        return self.children()
+
+    def __str__(self) -> str:
+        return f"variable '{self._variable.name}'"
+
+
+ at dataclass(frozen=True)
+class CapturedOutput:
+    seen_texts: str
+    """The accumulated text until the terminator (included if it was an OutputEvent)."""
+    event: Event
+    """The event that terminated the collection"""
+
+
+class _ConfigureContext:
+    """Handles the initial launch sequence handshake.
+
+    Orchestrates the full DAP initialization sequence:
+    On enter:
+        - Request and respond to the `Initialize` command.
+        - Send launch/attach request.
+        - Wait for InitializedEvent.
+
+    In between:
+       The test can set breakpoints or perform any check it needs do.
+
+    On exit:
+        4. Set and verify the pending source and function breakpoints.
+        5. Request and response to configurationDone.
+        6. Wait for ProcessEvent and launch/attach response.
+
+    Example:
+
+      >>> session.configure(LaunchArgs(program="a.out")) as ctx:
+      ...     session.resolve_function_breakpoints(["do_foo"])
+      >>> session.wait_for_breakpoint(after=ctx.process_event)
+    """
+
+    def __init__(
+        self,
+        session: "DAPTestSession",
+        config: LaunchArgs | AttachArgs,
+    ):
+        self._session = session
+        self._config = config
+        self._pending_request: Optional[PendingResponse[EmptyBodyResponse]] = None
+
+    def __enter__(self) -> "_ConfigureContext":
+        session = self._session
+        session.test_case.assertFalse(
+            session._state.is_initialized, "session already started."
+        )
+        self.init_response = session.initialize_sequence(session.initialize_args)
+        self._pending_request = session.send_request(self._config)
+        session.wait_for_event(InitializedEvent, after=self.init_response)
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        if exc_type is not None:
+            return False
+
+        session = self._session
+        assert self.init_response is not None
+        assert self._pending_request is not None
+
+        session.verify_configuration_done()
+        self.process_event = session.wait_for_event(
+            ProcessEvent, after=self.init_response
+        )
+
+        self.launch_or_attach_response = self._pending_request.result()
+        return False
+
+
+ at dataclass
+class _ExpectCommon:
+    """Shared fields used by both `ExpectVar` and `ExpectEval`.
+    Any attribute set to `None` will be skipped when checking.
+    """
+
+    type: Optional[str] = None
+    variables_reference: Optional[int] = None
+    named_variables: Optional[int] = None
+    indexed_variables: Optional[int] = None
+    read_only: bool = False
+
+    has_var_ref: Optional[bool] = None
+    has_mem_ref: Optional[bool] = None
+    has_loc_ref: Optional[bool] = None
+    has_indexed_variables: Optional[bool] = None
+
+    # Checks on the Expression's result or Variable's value.
+    startswith: Optional[str] = None
+    matches: Optional[str] = None  # regex applied to .value/.result
+    # When set, fetch children via `variablesReference` and verify recursively.
+    children: Optional[dict[str, "ExpectVar"]] = None
+
+
+ at dataclass
+class ExpectVar(_ExpectCommon):
+    """Typed expectation for a `Variable`.
+    Any attribute set to `None` will be skipped when checking.
+    """
+
+    value: Optional[str] = None
+    evaluate_name: Optional[str] = None
+    has_evaluate_name: Optional[bool] = None
+
+
+ at dataclass
+class ExpectEval(_ExpectCommon):
+    """Typed expectation for an `EvaluateResponse.body`
+    Any attribute set to `None` will be skipped when checking.
+    """
+
+    result: Optional[str] = None
+
+
+class DAPTestSession(Session):
+    """A `Session` bound to a `unittest.TestCase`.
+
+    Adds repeating patterns for sending, receiving and verifying protocol messages.
+    such as breakpoints, threads and evaluate.
+    """
+
+    def __init__(
+        self,
+        test_case: unittest.TestCase,
+        test_dir: Path,
+        adapter: DebugAdapter,
+        message_timeout: float,
+        process_spawner: SubProcessSpawner,
+        logger: logging.Logger,
+    ):
+        super().__init__(test_dir, adapter, message_timeout, process_spawner, logger)
+        self.test_case = test_case
+
+        # The default features that lldb supports.
+        # When a test does not explicitly set initialize args this is used.
+        self._init_args = InitializeArgs(
+            adapterID="lldb-native",
+            clientID="vscode",
+            columnsStartAt1=True,
+            linesStartAt1=True,
+            locale="en-us",
+            pathFormat="path",
+            supportsRunInTerminalRequest=True,
+            supportsVariablePaging=True,
+            supportsVariableType=True,
+            supportsStartDebuggingRequest=True,
+            supportsProgressReporting=True,
+            supportsInvalidatedEvent=True,
+            supportsMemoryEvent=True,
+        )
+
+    def update_initialize_args(self, **kwargs):
+        self.test_case.assertFalse(
+            self._state.is_initialized,
+            "session already initialized cannot update initialize args.",
+        )
+
+        self._init_args = dataclasses.replace(self._init_args, **kwargs)
+
+    @property
+    def initialize_args(self):
+        return dataclasses.replace(self._init_args)
+
+    def launch(self, config: LaunchArgs) -> ProcessEvent:
+        """Drives the full launch handshake
+
+        (initialize -> launch -> configurationDone -> ProcessEvent).
+        """
+        with self.configure(config) as ctx:
+            pass
+        return ctx.process_event
+
+    def attach(self, config: AttachArgs) -> ProcessEvent:
+        """Drives the full attach handshake
+
+        (initialize -> attach -> configurationDone -> ProcessEvent).
+        """
+        with self.configure(config) as ctx:
+            pass
+        return ctx.process_event
+
+    def configure(self, config: LaunchArgs | AttachArgs) -> _ConfigureContext:
+        """Return a context that scopes the launch sequence.
+
+        process_event and launch_or_attach are only a valid after
+        leaving the context block.
+
+        Example:
+            >>> with session.configure(LaunchArgs(program)) as ctx:
+            ...     session.set_source_breakpoints("main.cpp", [10, 25])
+            >>> process_event = ctx.process_event
+            >>> response = ctx.launch_or_attach_response
+        """
+        return _ConfigureContext(self, config)
+
+    def initialize_sequence(self, initialize_args: InitializeArgs):
+        init_response = self.send_request(initialize_args).result()
+        return init_response
+
+    def initialize_and_launch(self, args: LaunchArgs | AttachArgs):
+        self.initialize_sequence(self.initialize_args)
+        return self.send_request(args)
+
+    def configuration_done(self) -> PendingResponse[EmptyBodyResponse]:
+        # Wait for initialized event.
+        self.ensure_initialized()
+        # And then send configuration done.
+        return self.send_request(ConfigurationDoneArgs())
+
+    def verify_configuration_done(self, expected_success: bool = True):
+        response = self.configuration_done().result_or_error()
+        if expected_success:
+            self.test_case.assertEqual(
+                response.success, True, f"got error response: {response}."
+            )
+            self.test_case.assertIsInstance(response, EmptyBodyResponse)
+
+            # In VSCode, immediately following 'configurationDone', a
+            # 'threads' request is made to get the initial set of threads,
+            # specifically the main threads id and name.
+            # We issue the threads request to mimic this pattern and prevent
+            # tests that use threads to have the wrong result.
+            self.send_request(ThreadsArgs()).result()
+        else:
+            self.test_case.assertEqual(response.success, False)
+            self.test_case.assertIsInstance(response, ErrorResponse)
+        return response
+
+    def set_source_breakpoints(
+        self, source_path: str, breakpoints: list[int] | list[SourceBreakpoint]
+    ):
+        self.ensure_initialized()
+        # Convert the deprecated lines field to SourceBreakpoints.
+        s_breakpoints: list[SourceBreakpoint] = []
+        for bp in breakpoints:
+            if isinstance(bp, int):
+                s_breakpoints.append(SourceBreakpoint(bp))
+            elif isinstance(bp, SourceBreakpoint):
+                s_breakpoints.append(bp)
+            else:
+                self.test_case.fail(
+                    "breakpoints must only contain ints or SourceBreakpoints."
+                    f" got '{bp}' of type '{type(bp)}'."
+                )
+
+        source = Source.create(path=source_path)
+        bp_args = SetBreakpointsArgs(source, breakpoints=s_breakpoints)
+        return self.send_request(bp_args).result()
+
+    def set_assembly_breakpoints(
+        self,
+        source: Source | int,
+        breakpoints: list[int] | list[SourceBreakpoint],
+    ):
+        """Set breakpoints in an assembly source.
+
+        `source` can be either a `sourceReference` int (for a source produced
+        in the current session) or a full `Source` object (for replaying a
+        persisted assembly source across sessions).
+        """
+        self.ensure_initialized()
+        if isinstance(source, int):
+            source = Source(sourceReference=source)
+
+        s_breakpoints: list[SourceBreakpoint] = []
+        for bp in breakpoints:
+            if isinstance(bp, int):
+                s_breakpoints.append(SourceBreakpoint(bp))
+            elif isinstance(bp, SourceBreakpoint):
+                s_breakpoints.append(bp)
+            else:
+                self.test_case.fail(
+                    "breakpoints must only contain ints or SourceBreakpoints."
+                    f" got '{bp}' of type '{type(bp)}'."
+                )
+
+        bp_args = SetBreakpointsArgs(source=source, breakpoints=s_breakpoints)
+        return self.send_request(bp_args).result()
+
+    def set_function_breakpoints(
+        self,
+        function_names: list[str],
+        condition: Optional[str] = None,
+        hitCondition: Optional[str] = None,
+    ):
+        f_breakpoints = [
+            FunctionBreakpoint(name, condition=condition, hitCondition=hitCondition)
+            for name in function_names
+        ]
+        response = self.send_request(SetFunctionBreakpointsArgs(f_breakpoints)).result()
+        return response
+
+    def set_exception_breakpoints(
+        self,
+        *,
+        filters: list[str],
+        filterOptions: Optional[list[ExceptionFilterOptions]] = None,
+        exceptionOptions: Optional[list[ExceptionOptions]] = None,
+    ):
+        args = SetExceptionBreakpointsArgs(
+            filters=filters,
+            filterOptions=filterOptions,
+            exceptionOptions=exceptionOptions,
+        )
+        response = self.send_request(args).result()
+        return response
+
+    def set_variable(
+        self, name: str, value, *, variablesReference: int, is_hex: bool = False
+    ) -> SetVariableResponse | ErrorResponse:
+        last_response = self.last_response()
+        handle = self.send_request(
+            SetVariableArgs(
+                variablesReference=variablesReference,
+                name=name,
+                value=str(value),
+                format=ValueFormat(hex=True) if is_hex else None,
+            )
+        )
+        response = handle.result_or_error()
+        if isinstance(response, SetVariableResponse):
+            invalidated_event = self.wait_for_invalidated(after=last_response)
+            invalidated_areas = invalidated_event.body.areas
+            self.test_case.assertEqual(["variables"], invalidated_areas)
+
+            memory_event = self.wait_for_memory(after=last_response)
+            self.test_case.assertEqual(
+                memory_event.body.memoryReference, response.body.memoryReference
+            )
+        return response
+
+    @staticmethod
+    def breakpoints_to_ids(breakpoints: list[Breakpoint]):
+        ids: list[int] = []
+        for bp in breakpoints:
+            assert bp.id is not None, f"id is None for breakpoint: {bp}"
+            ids.append(bp.id)
+        return ids
+
+    def resolve_source_breakpoints(
+        self, source_path: str, breakpoints: list[int] | list[SourceBreakpoint]
+    ) -> list[int]:
+        last_response = self.last_response()
+        bp_response = self.set_source_breakpoints(source_path, breakpoints)
+        r_breakpoints = bp_response.body.breakpoints
+
+        all_verified = all(bp.verified for bp in r_breakpoints)
+        if not all_verified:
+            self.wait_until_all_breakpoints_verified(r_breakpoints, after=last_response)
+
+        self.test_case.assertEqual(
+            len(breakpoints),
+            len(r_breakpoints),
+            "expect correct number of breakpoints.",
+        )
+        return self.breakpoints_to_ids(r_breakpoints)
+
+    def resolve_function_breakpoints(
+        self,
+        function_names: list[str],
+        condition: Optional[str] = None,
+        hitCondition: Optional[str] = None,
+    ) -> list[int]:
+        """Sets breakpoints by function name given an array of function names
+        and returns an array of strings containing the breakpoint IDs
+        ("1", "2") for each breakpoint that was set.
+        """
+        last_response = self.last_response()
+        response = self.set_function_breakpoints(
+            function_names, condition, hitCondition
+        )
+        breakpoints = response.body.breakpoints
+
+        all_verified = all(bp.verified for bp in breakpoints)
+        if not all_verified:
+            self.wait_until_all_breakpoints_verified(breakpoints, after=last_response)
+
+        return self.breakpoints_to_ids(breakpoints)
+
+    def set_data_breakpoints(self, breakpoints: list[DataBreakpoint]):
+        args = SetDataBreakpointsArgs(breakpoints=breakpoints)
+        return self.send_request(args).result()
+
+    def set_instruction_breakpoints(self, memory_references: list[str]):
+        breakpoints = [InstructionBreakpoint(ref) for ref in memory_references]
+        return self.send_request(SetInstructionBreakpointsArgs(breakpoints)).result()
+
+    def set_breakpoint_locations(
+        self,
+        file_path: str,
+        line: int,
+        column: Optional[int] = None,
+        endLine: Optional[int] = None,
+        endColumn: Optional[int] = None,
+    ):
+        _, name = os.path.split(file_path)
+        bp_loc_args = BreakpointLocationsArgs(
+            Source.create(name=name, path=file_path),
+            line=line,
+            column=column,
+            endLine=endLine,
+            endColumn=endColumn,
+        )
+        return self.send_request(bp_loc_args).result()
+
+    def data_breakpoint_info(self, name: str, variablesReference: int, frameId: int):
+        info_args = DataBreakpointInfoArgs(
+            name=name, variablesReference=variablesReference, frameId=frameId
+        )
+        return self.send_request(info_args).result()
+
+    def data_breakpoint_info_as_address(self, address: str, size: int):
+        info_args = DataBreakpointInfoArgs(name=address, bytes=size, asAddress=True)
+        return self.send_request(info_args).result()
+
+    def step_in(
+        self,
+        threadId: Optional[int] = None,
+        *,
+        targetId: Optional[int] = None,
+        granularity: SteppingGranularity = "statement",
+    ):
+        thread_id = threadId or self.stopped_thread_id
+        self.test_case.assertIsNotNone(thread_id)
+        stepin_args = StepInArgs(
+            threadId=thread_id, targetId=targetId, granularity=granularity
+        )
+        response = self.send_request(stepin_args).result()
+        stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+        return stop_event
+
+    def step_over(
+        self,
+        threadId: Optional[int] = None,
+        *,
+        granularity: SteppingGranularity = "statement",
+    ):
+        thread_id = threadId or self.stopped_thread_id
+        self.test_case.assertIsNotNone(thread_id)
+        next_args = NextArgs(threadId=thread_id, granularity=granularity)
+        response = self.send_request(next_args).result()
+        stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+        return stop_event
+
+    def step_out(
+        self,
+        threadId: Optional[int] = None,
+        *,
+        granularity: SteppingGranularity = "statement",
+    ):
+        thread_id = threadId or self.stopped_thread_id
+        self.test_case.assertIsNotNone(thread_id)
+        step_out_args = StepOutArgs(threadId=thread_id, granularity=granularity)
+        response = self.send_request(step_out_args).result()
+
+        stop_event = self.verify_stopped(StoppedReason.STEP, after=response)
+        return stop_event
+
+    def wait_until_any_breakpoint_hit(
+        self, breakpoint_ids: list[int], *, after: Event | Response
+    ) -> StoppedEvent:
+        """Wait for the process to send `StoppedEvents` and verify we stopped for
+        any breakpoint in breakpoint_ids the event or response.
+        """
+
+        self.test_case.assertGreater(len(breakpoint_ids), 0, "empty breakpoint ids.")
+        is_ids_int = all(isinstance(id, int) for id in breakpoint_ids)
+        self.test_case.assertTrue(is_ids_int, "all breakpoint_ids must be integers.")
+
+        breakpoint_stop_reasons = [
+            StoppedReason.BREAKPOINT,
+            StoppedReason.INSTRUCTION_BREAKPOINT,
+            StoppedReason.FUNCTION_BREAKPOINT,
+            StoppedReason.DATA_BREAKPOINT,
+        ]
+
+        def event_hit_id_in_breakpoint_ids(event: StoppedEvent):
+            hit_ids = event.body.hitBreakpointIds or []
+            for hit_id in hit_ids:
+                if hit_id in breakpoint_ids:
+                    return True
+
+            return False
+
+        event = self.wait_for_stopped(
+            breakpoint_stop_reasons,
+            after=after,
+            until=event_hit_id_in_breakpoint_ids,
+            timeout_msg=f"waiting for any breakpoint id in {breakpoint_ids} after seq {after.seq}.",
+        )
+
+        return event
+
+    def wait_until_all_breakpoints_verified(
+        self, breakpoints: list[int] | list[Breakpoint], *, after: Event | Response
+    ):
+        """Wait for the process to send breakpoint events and verify we hit
+        all 'ids' in 'breakpoints' after the event or response.
+        """
+        self.test_case.assertTrue(len(breakpoints) > 0, "empty list of breakpoints.")
+
+        id_to_verify: dict[int, bool] = {}
+        for bp in breakpoints:
+            if isinstance(bp, int):
+                id_to_verify[bp] = False
+            elif isinstance(bp, Breakpoint):
+                assert bp.id is not None
+                id_to_verify[bp.id] = bp.verified
+            else:
+                self.test_case.fail(
+                    f"expected list of type 'Breakpoint' or 'int' got '{breakpoints}'"
+                )
+
+        bp_ids = list(id_to_verify.keys())
+
+        def all_breakpoints_verified(evt: BreakpointEvent):
+            event_bp = evt.body.breakpoint
+            if event_bp.id is None:
+                return False
+
+            if event_bp.id not in bp_ids:
+                return False
+
+            id_to_verify[event_bp.id] = event_bp.verified
+            all_verified = all(verified for verified in id_to_verify.values())
+            return all_verified
+
+        timeout_msg = f"waiting for all breakpoint ids {bp_ids} to be verified"
+        last_breakpoint_event = self.wait_for_event(
+            BreakpointEvent,
+            after=after,
+            until=all_breakpoints_verified,
+            timeout_msg=timeout_msg,
+        )
+        return last_breakpoint_event
+
+    def wait_for_stopped_or_exited(
+        self,
+        *,
+        after: Event | Response,
+        until: Optional[Callable[[StoppedEvent | ExitedEvent], bool]] = None,
+        timeout_msg: Optional[str] = None,
+    ) -> StoppedEvent | ExitedEvent:
+        event = self.wait_for_any_event(
+            (StoppedEvent, ExitedEvent),
+            after=after,
+            until=until,
+            timeout_msg=timeout_msg,
+        )
+        return event
+
+    def wait_for_stopped(
+        self,
+        matching_any: Optional[Sequence[StoppedReason]] = None,
+        *,
+        after: Event | Response,
+        until: Optional[Callable[[StoppedEvent], bool]] = None,
+        timeout_msg: Optional[str] = None,
+    ) -> StoppedEvent:
+        """
+        Wait for a process to stop, optionally filtered by stop reason and custom condition.
+
+        Blocks until a StoppedEvent is received after the specified event. If matching_any
+        is provided, only stops with those reasons are accepted. The until callback allows
+        additional condition checking. If an ExitedEvent is encountered, wait_for terminates.
+
+        Args:
+            matching_any: Filter by specific stop reasons.
+            after: Event or Response to start waiting after.
+            until: Optional callback for additional filtering.
+            timeout_msg: Custom timeout error message.
+        """
+        if matching_any:
+            self.test_case.assertGreater(
+                len(matching_any), 0, "expected at least one stop reason."
+            )
+
+        def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
+            # Break early for exited event.
+            # We cannot hit a stopped event after the process exited.
+            if isinstance(event, ExitedEvent):
+                return True
+
+            # Match any of the stopped reasons.
+            if matching_any and event.body.reason not in matching_any:
+                return False
+
+            if until:
+                return until(event)
+
+            return True
+
+        event = self.wait_for_stopped_or_exited(
+            after=after, until=matches_any_reason_until, timeout_msg=timeout_msg
+        )
+
+        self.test_case.assertIsInstance(event, StoppedEvent, f"after seq: {after.seq}")
+        self.test_case.assertEqual(event.event, EventName.STOPPED)
+        return cast(StoppedEvent, event)
+
+    def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
+        """
+        Wait for a process to exit.
+
+        Blocks until an ExitedEvent is received following the given event or response.
+        Raises an error if a StoppedEvent is encountered, as a stopped process
+        cannot subsequently exit.
+        """
+        event = self.wait_for_stopped_or_exited(after=after)
+        self.test_case.assertIsInstance(event, ExitedEvent)
+        self.test_case.assertEqual(event.event, "exited", "expected ExitedEvent'")
+        return cast(ExitedEvent, event)
+
+    def verify_next_module_event(
+        self,
+        reason: Optional[ModuleReason] = None,
+        *,
+        after: Event | Response,
+    ):
+        event = self.wait_for_module_event(after=after)
+        event_body = event.body
+        if reason is not None:
+            msg = f"module event reason does not match, got {event_body}."
+            self.test_case.assertEqual(event_body.reason, reason, msg)
+        return event
+
+    def wait_for_module_event(
+        self,
+        *,
+        after: Event | Response,
+        until: Optional[Callable[[ModuleEvent], bool]] = None,
+    ):
+        return self.wait_for_event(ModuleEvent, after=after, until=until)
+
+    def wait_for_terminated(self, *, after: Event | Response):
+        return self.wait_for_event(TerminatedEvent, after=after)
+
+    def wait_for_invalidated(self, *, after: Event | Response):
+        return self.wait_for_event(InvalidatedEvent, after=after)
+
+    def wait_for_memory(self, *, after: Event | Response):
+        return self.wait_for_event(MemoryEvent, after=after)
+
+    def do_continue(self):
+        self.ensure_initialized()
+        return self.send_request(ContinueArgs()).result()
+
+    def continue_to_exit(self, exitCode: int = 0) -> ExitedEvent:
+        continue_response = self.do_continue()
+        return self.verify_process_exited(after=continue_response, exitCode=exitCode)
+
+    def continue_to_breakpoint(self, breakpoint_id: int):
+        return self.continue_to_any_breakpoint([breakpoint_id])
+
+    def continue_to_any_breakpoint(self, breakpoint_ids: list[int]):
+        response = self.do_continue()
+        event = self.wait_until_any_breakpoint_hit(breakpoint_ids, after=response)
+        return event
+
+    def continue_to_exception_breakpoint(
+        self, *, expected_description: str, expected_text: Optional[str] = None
+    ):
+        continue_response = self.do_continue()
+        return self.verify_stopped_on_exception(
+            expected_description=expected_description,
+            expected_text=expected_text,
+            after=continue_response,
+        )
+
+    def continue_to_next_stop(self, *, exp_reason: Optional[StoppedReason] = None):
+        """Continue execution and wait for stopped event"""
+        response = self.do_continue()
+        if exp_reason is None:
+            return self.wait_for_stopped(after=response)
+
+        return self.verify_stopped(exp_reason, after=response)
+
+    def evaluate(
+        self,
+        expression: str,
+        *,
+        frameId: Optional[int] = None,
+        context: Optional[EvaluateContext] = None,
+        format: Optional[ValueFormat] = None,
+    ):
+        """Send an `evaluate` request and expects a successful response and result."""
+        pending = self.do_evaluate(
+            expression, frameId=frameId, context=context, format=format
+        )
+        response = pending.result(
+            f"failed to evaluate `{expression}` with {context=}, {frameId=}."
+        )
+
+        result = response.body.result
+        self.test_case.assertFalse(result.startswith("error:"), f'"{result}"')
+        return response.body
+
+    def do_evaluate(
+        self,
+        expression: str,
+        *,
+        frameId: Optional[int] = None,
+        context: Optional[EvaluateContext] = None,
+        format: Optional[ValueFormat] = None,
+    ) -> PendingResponse[EvaluateResponse]:
+        """Send an `evaluate` request without failing on error."""
+        eval_args = EvaluateArgs(
+            expression=expression, frameId=frameId, context=context, format=format
+        )
+        return self.send_request(eval_args)
+
+    def collect_output(
+        self,
+        category: OutputCategory,
+        *,
+        until: str | Event,
+        after: Event | Response,
+        timeout_msg: Optional[str] = None,
+    ) -> CapturedOutput:
+        """Collect OutputEvents in `category` until a terminator is reached.
+
+        Args:
+            until:
+                If a `str`, return once this substring is seen in an
+                OutputEvent's text in `category`.
+                If an `Event` instance, return when that exact event (matched by seq) is reached —
+                useful after a prior `wait_for_event` call gave you the
+                synchronization point. The terminator's own text is not
+                accumulated.
+            category: The output category to collect.
+        Returns:
+            CapturedOutput. `event` is the terminator — the matching
+            OutputEvent (string form) or the supplied event (instance form).
+        """
+        if not isinstance(until, (str, Event)):
+            self.test_case.fail(f"expected str or Event for until got {type(until)}")
+
+        seen_outputs: list[str] = []
+
+        if isinstance(until, str):
+            self.test_case.assertTrue(until, "expected a non-empty pattern")
+            pattern = until
+
+            def matches_pattern(event: OutputEvent):
+                if event.body.category != category:
+                    return False
+                seen_outputs.append(event.body.output)
+                return pattern in event.body.output
+
+            timeout_msg = f"{timeout_msg}\n\t" if timeout_msg else ""
+            timeout_msg += f"collecting output category '{category}' until found pattern: '{pattern}'."
+
+            event = self.wait_for_event(
+                OutputEvent,
+                after=after,
+                until=matches_pattern,
+                timeout_msg=timeout_msg,
+            )
+            # Sanity check.
+            self.test_case.assertIsInstance(event, OutputEvent)
+            return CapturedOutput(seen_texts="".join(seen_outputs), event=event)
+
+        until_event = until
+        self.test_case.assertGreater(
+            until_event.seq,
+            after.seq,
+            f"'{until_event}' event must be later than '{after}'.",
+        )
+
+        def matches_until_event(event: Event):
+            if isinstance(event, OutputEvent) and event.body.category == category:
+                seen_outputs.append(event.body.output)
+            return event.seq >= until_event.seq
+
+        timeout_msg = f"{timeout_msg}\n\t" if timeout_msg else ""
+        timeout_msg += f"collecting output category '{category}' up to event seq "
+        timeout_msg += f"{until_event.seq} ({type(until_event).__name__})."
+
+        # The until_event is already in history, so this never blocks beyond
+        # reading the events that are already recorded.
+        self.wait_for_event(
+            Event,
+            after=after,
+            until=matches_until_event,
+            timeout_msg=timeout_msg,
+        )
+        return CapturedOutput(seen_texts="".join(seen_outputs), event=until_event)
+
+    def collect_console(self, *, until: str | Event, after: Event | Response):
+        return self.collect_output(OutputCategory.CONSOLE, until=until, after=after)
+
+    def collect_stdout(self, *, until: str | Event, after: Event | Response):
+        return self.collect_output(OutputCategory.STDOUT, until=until, after=after)
+
+    def collect_important(self, *, until: str | Event, after: Event | Response):
+        return self.collect_output(OutputCategory.IMPORTANT, until=until, after=after)
+
+    def verify_stopped(
+        self,
+        reasons: StoppedReason | Iterable[StoppedReason],
+        *,
+        after: Event | Response,
+        expected_ids: Iterable[int] | int | None = None,
+        expected_description: Optional[str] = None,
+        expected_text: Optional[str] = None,
+    ) -> StoppedEvent:
+        """Wait for a `StoppedEvent` and assert the body matches every supplied
+        expectation."""
+        if isinstance(reasons, StoppedReason):
+            reasons = [reasons]
+
+        timeout_msg = f"waiting for 'StoppedEvent' matching reasons: {reasons}"
+        stopped_event = self.wait_for_stopped(after=after, timeout_msg=timeout_msg)
+
+        body = stopped_event.body
+        test_case = self.test_case
+        msg = f"for stopped event {body}."
+        test_case.assertIn(body.reason, reasons, msg)
+
+        if expected_ids is not None:
+            if isinstance(expected_ids, int):
+                expected_ids = [expected_ids]
+
+            hit_bp_ids = body.hitBreakpointIds or []
+            for expected_id in expected_ids:
+                msg = f"expected breakpoint_id '{expected_id}' not in {hit_bp_ids=}."
+                test_case.assertIn(expected_id, hit_bp_ids, msg)
+
+        if expected_description is not None:
+            test_case.assertIsNotNone(
+                body.description, f"stopped event missing description {body}."
+            )
+            description = cast(str, body.description)
+            test_case.assertRegex(description, expected_description, msg)
+
+        if expected_text is not None:
+            test_case.assertIsNotNone(body.text, f"stopped event missing text {body}.")
+            text = cast(str, body.text)
+            test_case.assertRegex(text, expected_text, msg)
+
+        return stopped_event
+
+    def verify_stopped_on_breakpoint(
+        self,
+        expected_ids: list[int] | int | None = None,
+        *,
+        after: Event | Response,
+    ) -> StoppedEvent:
+        reasons = [
+            StoppedReason.BREAKPOINT,
+            StoppedReason.DATA_BREAKPOINT,
+            StoppedReason.FUNCTION_BREAKPOINT,
+            StoppedReason.INSTRUCTION_BREAKPOINT,
+        ]
+        return self.verify_stopped(reasons, after=after, expected_ids=expected_ids)
+
+    def verify_stopped_on_entry(self, *, after: Event | Response) -> StoppedEvent:
+        return self.verify_stopped(StoppedReason.ENTRY, after=after)
+
+    def verify_stopped_on_exception(
+        self,
+        *,
+        expected_description: Optional[str] = None,
+        expected_text: Optional[str] = None,
+        after: Event | Response,
+    ) -> StoppedEvent:
+        """Wait for the debuggee to stop with reason `exception` and verify
+        the description matches `expected_description` (regex) and, if given,
+        the text matches `expected_text` (regex).
+        """
+        return self.verify_stopped(
+            StoppedReason.EXCEPTION,
+            after=after,
+            expected_description=expected_description,
+            expected_text=expected_text,
+        )
+
+    def verify_multiple_breakpoints_hit(
+        self, breakpoint_ids: list[int], *, after: Event | Response
+    ) -> StoppedEvent:
+        """Wait for the session receive a 'StoppedEvent' and verify we stopped for
+        any breakpoint in breakpoint_ids the event or response.
+        """
+
+        self.test_case.assertGreater(len(breakpoint_ids), 0, "empty breakpoint ids.")
+        is_ids_int = all(isinstance(id, int) for id in breakpoint_ids)
+        self.test_case.assertTrue(is_ids_int, "all breakpoint_ids must be integers.")
+
+        event = self.verify_stopped_on_breakpoint(after=after)
+        hit_ids = event.body.hitBreakpointIds or []
+        if set(breakpoint_ids).issubset(hit_ids):
+            return event
+
+        self.test_case.fail(f"{breakpoint_ids=} missed in {event=} {after=}.")
+
+    def verify_process_exited(
+        self, *, after: Event | Response | None = None, exitCode: int = 0
+    ):
+        if after:
+            event = self.wait_for_exited(after=after)
+        else:
+            event = self.wait_for_earliest_event(ExitedEvent)
+
+        fail_msg = f"expect '{exitCode=}' for '{event.body}'"
+        self.test_case.assertEqual(event.body.exitCode, exitCode, fail_msg)
+
+        self.verify_reverse_process_exited(exitCode)
+        return event
+
+    def verify_commands(self, flavor: str, output: str, commands: list[str]):
+        self.test_case.assertTrue(output and len(output) > 0, "expect console output")
+        lines = output.splitlines()
+        prefix = "(lldb) "
+
+        for cmd in commands:
+            cmd_stripped = cmd.lstrip("!?")
+            for line in lines:
+                if line.startswith(prefix) and cmd_stripped in line:
+                    break
+            else:
+                self.test_case.fail(
+                    f"Command '{flavor}' - '{cmd}' not found in output: {output}."
+                )
+
+    def verify_location(self, locationReference: int, filename: str, line: int):
+        response = self.send_request(LocationsArgs(locationReference)).result()
+        path = response.body.source.path
+        self.test_case.assertIsNotNone(response.body.source.path)
+
+        msg = f"expect path '{path}' to end with '{filename}'."
+        self.test_case.assertTrue(str(path).endswith(filename), msg)
+        self.test_case.assertEqual(response.body.line, line)
+
+    def __verify_common(
+        self, actual: Variable | EvaluateResponse.Body, expected: _ExpectCommon
+    ):
+        """Verify the shared fields of `Variable` and `EvaluateResponse.Body`."""
+        if expected.type is not None:
+            msg = f"type mismatch for {actual}: {actual.type!r} != {expected.type!r}."
+            self.test_case.assertEqual(actual.type, expected.type, msg)
+
+        if expected.variables_reference is not None:
+            self.test_case.assertEqual(
+                actual.variablesReference,
+                expected.variables_reference,
+                f"variablesReference mismatch for {actual}.",
+            )
+
+        if expected.named_variables is not None:
+            self.test_case.assertEqual(
+                actual.namedVariables,
+                expected.named_variables,
+                f"namedVariables mismatch for {actual}.",
+            )
+
+        if expected.indexed_variables is not None:
+            self.test_case.assertEqual(
+                actual.indexedVariables,
+                expected.indexed_variables,
+                f"indexedVariables mismatch for {actual}.",
+            )
+
+        if expected.has_var_ref is not None:
+            has_var_ref = bool(actual.variablesReference)
+            fail_msg = f"has_var_ref mismatch for {actual=}, {expected=}."
+            self.test_case.assertEqual(has_var_ref, expected.has_var_ref, fail_msg)
+
+        if expected.has_mem_ref is not None:
+            has_mem_ref = actual.memoryReference is not None
+            fail_msg = f"has_mem_ref mismatch for {actual=}, {expected=}."
+            self.test_case.assertEqual(has_mem_ref, expected.has_mem_ref, fail_msg)
+
+        if expected.has_loc_ref is not None:
+            has_loc_ref = actual.valueLocationReference is not None
+            fail_msg = f"has_loc_ref mismatch for {actual=}, {expected=}."
+            self.test_case.assertEqual(has_loc_ref, expected.has_loc_ref, fail_msg)
+
+        if expected.has_indexed_variables is not None:
+            has_idx_vars = actual.indexedVariables is not None
+            fail_msg = f"has_index_variables mismatch for {actual=}, {expected=}."
+            self.test_case.assertEqual(
+                has_idx_vars, expected.has_indexed_variables, fail_msg
+            )
+
+        hint = actual.presentationHint or VariablePresentationHint()
+        attributes = hint.attributes or []
+        fail_msg = f"readOnly attribute mismatch for {actual=}."
+        if expected.read_only:
+            self.test_case.assertIn("readOnly", attributes, fail_msg)
+        else:
+            self.test_case.assertNotIn("readOnly", attributes, fail_msg)
+
+        if expected.children is not None:
+            var_ref = actual.variablesReference
+            self.test_case.assertTrue(
+                var_ref, f"children expected but no variablesReference for {actual=}."
+            )
+            children = self.get_variables(var_ref)
+            self.verify_variables(children, expected.children)
+
+    def verify_evaluate(
+        self,
+        eval_body: EvaluateResponse.Body,
+        expected: Optional[ExpectEval] = None,
+        /,
+        **expected_kwargs,
+    ):
+        """Verify an `EvaluateResponse.body`."""
+        if expected is not None and expected_kwargs:
+            self.test_case.fail("pass an ExpectEval OR its keyword fields, not both.")
+        if expected is None and not expected_kwargs:
+            self.test_case.fail("pass an ExpectEval or at least one keyword field.")
+
+        expected = expected or ExpectEval(**expected_kwargs)
+        eval_result = eval_body.result
+        if expected.result is not None:
+            self.test_case.assertEqual(
+                eval_result, expected.result, f"result mismatch for {eval_body}."
+            )
+
+        if expected.matches is not None:
+            self.test_case.assertRegex(eval_result, expected.matches)
+
+        if expected_prefix := expected.startswith:
+            self.test_case.assertTrue(
+                eval_result.startswith(expected_prefix),
+                f"'{eval_result!r}' does not start with '{expected_prefix!r}'.",
+            )
+
+        self.__verify_common(eval_body, expected)
+
+    def verify_variable(
+        self,
+        variable: Variable,
+        expected: Optional[ExpectVar] = None,
+        /,
+        **expected_kwargs,
+    ):
+        """Verify a Variable matches the expected `ExpectVar` ."""
+        if expected is not None and expected_kwargs:
+            self.test_case.fail("pass an ExpectVar or its keyword fields, not both.")
+        if expected is None and not expected_kwargs:
+            self.test_case.fail("pass an ExpectVar or at least one keyword field.")
+
+        expected = expected or ExpectVar(**expected_kwargs)
+        value = variable.value
+
+        if expected.value is not None:
+            fail_msg = f"value mismatch for {variable=}."
+            self.test_case.assertEqual(value, expected.value, fail_msg)
+
+        if expected.matches is not None:
+            fail_msg = f"value doesn't match pattern for '{variable}'."
+            self.test_case.assertRegex(variable.value, expected.matches, fail_msg)
+
+        if expected_prefix := expected.startswith:
+            fail_msg = f"{value!r} does not start with {expected_prefix!r}."
+            self.test_case.assertTrue(value.startswith(expected_prefix), fail_msg)
+
+        if evaluate_name := expected.evaluate_name:
+            fail_msg = f"evaluateName mismatch for {variable}."
+            self.test_case.assertEqual(variable.evaluateName, evaluate_name, fail_msg)
+
+        if expected.has_evaluate_name is not None:
+            has_evaluate_name = variable.evaluateName is not None
+            fail_msg = f"has_evaluate_name mismatch for {variable=}, {expected=}."
+            self.test_case.assertEqual(
+                has_evaluate_name, expected.has_evaluate_name, fail_msg
+            )
+
+        self.__verify_common(variable, expected)
+
+    def verify_variables(
+        self, variables: list[Variable], expected: dict[str, ExpectVar]
+    ):
+        """Verify each `Variable` in `variables` against its entry in `expected`."""
+        self.test_case.assertTrue(len(variables) >= 1, f"no variables to verify.")
+
+        for variable in variables:
+            if variable.name.startswith("std::"):
+                continue
+            self.test_case.assertIn(variable.name, expected)
+            self.verify_variable(variable, expected[variable.name])
+
+    def get_modules(
+        self, startModule: Optional[int] = None, moduleCount: Optional[int] = None
+    ):
+        args = ModulesArgs(startModule=startModule, moduleCount=moduleCount)
+        response = self.send_request(args).result()
+        modules_dict = {module.name: module for module in response.body.modules}
+        return modules_dict
+
+    def get_threads(self) -> list[ThreadContext]:
+        response = self.send_request(ThreadsArgs()).result()
+        threads = response.body.threads
+        t_threads = [ThreadContext(thread.id, self) for thread in threads]
+        return t_threads
+
+    def get_variables(
+        self,
+        variablesReference: int,
+        *,
+        filter: Optional[Literal["indexed", "named"]] = None,
+        start: Optional[int] = None,
+        count: Optional[int] = None,
+        format: Optional[ValueFormat] = None,
+    ) -> list[Variable]:
+        args = VariablesArgs(
+            variablesReference=variablesReference,
+            filter=filter,
+            start=start,
+            count=count,
+            format=format,
+        )
+        response = self.send_request(args).result()
+        return response.body.variables
+
+    def thread_context_from(self, thread_ref: int | StoppedEvent) -> ThreadContext:
+        if isinstance(thread_ref, StoppedEvent):
+            self.test_case.assertIsNotNone(thread_ref.body.threadId)
+            thread_id = cast(int, thread_ref.body.threadId)
+        elif isinstance(thread_ref, int):
+            thread_id = thread_ref
+        else:
+            self.test_case.fail(f"cannot get thread context from '{type(thread_ref)}'.")
+        return ThreadContext(thread_id, self)
+
+    def top_frame_from(self, thread_ref: int | StoppedEvent) -> FrameContext:
+        """Top FrameContext of the currently stopped thread."""
+        return self.thread_context_from(thread_ref).top_frame()
+
+    def get_completions(self, text: str, frameId: Optional[int]):
+        def code_units(input: str) -> int:
+            utf16_bytes = input.encode("utf-16-le")
+            # one UTF16 codeunit = 2 bytes.
+            return len(utf16_bytes) // 2
+
+        com_args = CompletionsArgs(
+            text=text, column=code_units(text) + 1, frameId=frameId
+        )
+        response = self.send_request(com_args).result()
+        return response.body.targets
+
+    def get_exception_info(self, threadId: int):
+        info_args = ExceptionInfoArgs(threadId=threadId)
+        response = self.send_request(info_args).result()
+        return response.body
+
+    def do_restart(self, arguments: LaunchArgs | AttachArgs | None = None):
+        restart_args = RestartArgs(arguments)
+        return self.send_request(restart_args).result()
+
+    def disassemble(
+        self,
+        memoryReference: str,
+        instructionOffset: int = -50,
+        instructionCount: int = 200,
+        resolveSymbols: bool = True,
+    ):
+        dis_args = DisassembleArgs(
+            memoryReference=memoryReference,
+            instructionOffset=instructionOffset,
+            instructionCount=instructionCount,
+            resolveSymbols=resolveSymbols,
+        )
+        return self.send_request(dis_args).result().body.instructions
+
+    def stack_trace(
+        self,
+        threadId: int,
+        *,
+        startFrame: Optional[int] = None,
+        levels: Optional[int] = None,
+        format: Optional[StackFrameFormat] = None,
+    ):
+        """Send a `stackTrace` request and wait for a response"""
+        args = StackTraceArgs(
+            threadId=threadId, startFrame=startFrame, levels=levels, format=format
+        )
+        return self.send_request(args).result()
+
+    def read_memory(
+        self, memoryReference: str, count: int, offset: Optional[int] = None
+    ) -> PendingResponse[ReadMemoryResponse]:
+        args = ReadMemoryArgs(
+            memoryReference=memoryReference, offset=offset, count=count
+        )
+        return self.send_request(args)
+
+    def write_memory(
+        self,
+        memoryReference: str,
+        value: int | str | bytes,
+        *,
+        offset: Optional[int] = None,
+        allowPartial: bool = False,
+    ):
+        """Send a `writeMemory` request.
+
+        Integer value is serialized as little-endian bytes,
+        `value` is Base64-encoded because the DAP protocol requires it.
+        """
+        if isinstance(value, int):
+            # (bit_length + 7 (rounding up to nearest byte)) // 8 = converts to bytes.
+            byte_length = (value.bit_length() + 7) // 8
+            val_bytes = value.to_bytes(byte_length, "little")
+            data = base64.b64encode(val_bytes).decode()
+        else:
+            data = base64.b64encode(str(value).encode()).decode()
+
+        before_request = self.last_response()
+        write_args = WriteMemoryArgs(
+            memoryReference=memoryReference,
+            data=data,
+            offset=offset,
+            allowPartial=allowPartial,
+        )
+        handle = self.send_request(write_args)
+        response = handle.result_or_error()
+
+        # Check we sent invalidated event.
+        if response.success and self.initialize_args.supportsInvalidatedEvent:
+            invalidated = self.wait_for_invalidated(after=before_request)
+            self.test_case.assertEqual(invalidated.body.areas, ["all"])
+        return response
+
+    def disconnect(
+        self, restart: Optional[bool] = None, terminateDebuggee: Optional[bool] = None
+    ):
+        args = DisconnectArgs(restart=restart, terminateDebuggee=terminateDebuggee)
+        response = self.send_request(args).result()
+        return response
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
new file mode 100644
index 0000000000000..9ef04b0c5ca90
--- /dev/null
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -0,0 +1,809 @@
+# FIXME: remove when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+from __future__ import annotations
+
+import bisect
+import contextlib
+import itertools
+import json
+import os
+import socket
+import subprocess
+import threading
+import time
+from concurrent.futures import Future
+from dataclasses import asdict, dataclass, field, replace
+from pprint import pformat
+from typing import IO, Callable, Optional, Protocol, Tuple, Type, runtime_checkable
+
+from .dap_types import (
+    AnyEvent,
+    DAPError,
+    Event,
+    MessageType,
+    RawMessage,
+    Request,
+    Response,
+)
+
+
+# See lldbtest.Base.spawnSubprocess, which should help ensure any processes
+# created by the DAP client are terminated correctly when the test ends.
+class SubProcessSpawner(Protocol):
+    def __call__(
+        self,
+        executable: str,
+        args: list[str] | None = None,
+        extra_env: list[str] | None = None,
+        install_remote: bool = True,
+        **kwargs,
+    ) -> subprocess.Popen[bytes]: ...
+
+
+ at dataclass(frozen=True)
+class DebugAdapterOptions:
+    """The options passed when spawning the debug adapter."""
+
+    args: list[str] = field(default_factory=list)
+    env: dict[str, str] = field(default_factory=dict)
+    cwd: Optional[str] = None
+    pre_init_commands: Optional[list[str]] = None
+    log_file: Optional[str] = None
+    # sever_mode related options.
+    connection: Optional[str] = None
+    connection_timeout: Optional[int] = None
+
+    @property
+    def run_as_server(self):
+        return self.connection is not None
+
+    def clone(self, **kwargs) -> DebugAdapterOptions:
+        """Returns a copy with the given fields overridden."""
+        return replace(self, **kwargs)
+
+    def __repr__(self):
+        return f"{type(self).__name__}: {pformat(asdict(self), indent=2, compact=True)}"
+
+    def __post_init__(self):
+        # Check connection options is not in args.
+        if "--connection" in self.args or "--connection-timeout" in self.args:
+            raise DAPError(
+                f"--connection in adapter options, use the connection field instead {self}"
+            )
+
+        if not self.run_as_server and self.connection_timeout is not None:
+            raise DAPError(
+                f"'--connection-timeout' option can only be used when a connection is specified: {self}"
+            )
+
+
+class DebugAdapter:
+    """Spawns and owns the lifetime of lldb-dap binary"""
+
+    _listening_uri: Optional[str]
+
+    def __init__(self, executable: str, opts: DebugAdapterOptions):
+        self.executable = executable
+        self._connection_count = 0
+        self._is_server = opts.run_as_server
+
+        # Setup the process args.
+        process_args = [self.executable]
+        process_args.extend(opts.args)
+
+        if pre_init_commands := opts.pre_init_commands:
+            for command in pre_init_commands:
+                process_args.extend(["--pre-init-command", command])
+
+        # Verify we are using the correct args in stdio or server mode.
+        if opts.run_as_server:
+            process_args.extend(["--connection", opts.connection])  # type: ignore
+            if opts.connection_timeout:
+                connection_timeout = str(opts.connection_timeout)
+                process_args.extend(["--connection-timeout", connection_timeout])
+
+        # Setup process environment.
+        process_env = os.environ.copy()
+        process_env.update(opts.env)
+        if log_file := opts.log_file:
+            process_env["LLDBDAP_LOG"] = log_file
+
+        self._process = subprocess.Popen(
+            process_args,
+            stdin=subprocess.PIPE,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            env=process_env,
+            cwd=opts.cwd,
+        )
+        assert self.is_alive, "expected running process"
+
+        if self.is_server:
+            self._listening_uri = self._read_listening_uri()
+        else:
+            self._listening_uri = None
+
+    def create_connection(self) -> DAPConnection:
+        if self.is_server:
+            assert self._listening_uri is not None
+            transport = _SocketTransport(uri=self._listening_uri)
+        else:
+            if self._connection_count > 0:
+                raise DAPError("Cannot create multiple connections in stdio mode")
+            transport = _StdioTransport(self._process)
+
+        count = self._connection_count
+        connection_id = f"conn{count}" if self.is_server else "stdio"
+        self._connection_count += 1
+        return DAPConnection(connection_id, transport)
+
+    @property
+    def is_server(self):
+        return self._is_server
+
+    @property
+    def is_alive(self):
+        return self._process.poll() is None
+
+    @property
+    def process(self):
+        return self._process
+
+    def kill(self):
+        self._process.terminate()
+        try:
+            self._process.wait(timeout=2.0)
+        except subprocess.TimeoutExpired:
+            self._process.kill()
+
+    def _read_listening_uri(self) -> str:
+        # lldb-dap will print the listening address once the listener is
+        # made to stdout. The listener is formatted like
+        # `connection://host:port` or `unix-connection:///path`.
+        expected_prefix = "Listening for: "
+        process_stdout = self._process.stdout
+        if process_stdout is None:
+            raise AttributeError("expected the process stdout to be a PIPE")
+
+        out = process_stdout.readline().decode()
+        if not out:
+            # Check if there is a message in stderr.
+            err = ""
+            with contextlib.suppress(Exception):
+                if process_stderr := self.process.stderr:
+                    err = process_stderr.read().decode()
+            raise EOFError(
+                f"Unexpected End of file for process {self.process.args},\n"
+                f"process stderr: {err}"
+            )
+
+        if not out.startswith(expected_prefix):
+            raise ValueError(
+                "lldb-dap failed to print listening address, "
+                f"expected '{expected_prefix}', got '{out}'"
+            )
+
+        # FIXME: use `str.removeprefix` when LLDB_MINIMUM_PYTHON_VERSION > 3.8
+        out = out[len(expected_prefix) :]
+
+        # If the listener expanded into multiple addresses, use the first.
+        uri = out.rstrip("\r\n").split(",", 1)[0]
+        return uri
+
+
+class EventHistory:
+    """Thread-safe event log that tests block against to observe the adapter.
+
+    Every event the debug adapter sends is recorded here by the read
+    thread, in the order it arrived. Tests don't read the log directly,
+    they call one of the `wait_for_*` methods, which block until a matching
+    event has been recorded.
+
+    Args:
+        timeout: Default timeout in seconds for `wait_for_*` functions.
+
+    Example:
+    Wait for a stop after stepping, without racing the adapter.
+
+    >>> step_resp = session.step_in(thread_id=1)
+    >>> # History will only check for events after the step_response sequence.
+    >>> stopped = history.wait_for_event(StoppedEvent, after=step_resp)
+
+    Wait for any of several events (either is an acceptable outcome).
+    >>> end = history.wait_for_any_event((StoppedEvent, TerminatedEvent), after=continue_resp)
+
+    Narrow with a predicate.
+
+    >>> hit = history.wait_for_event(
+    ...    StoppedEvent,
+    ...    after=launch_resp,
+    ...    until=lambda e: e.body.reason == "breakpoint",
+    ... )
+
+    Find the first Initialized event from the start of the history.
+    >>> init_event = history.wait_for_earliest_event(InitializedEvent)
+    """
+
+    def __init__(self, timeout: float):
+        self._sequences: list[int] = []
+        self._events: list[Event] = []
+        self._new_event_condition = threading.Condition()
+        self._timeout: float = timeout
+
+        self._is_closed: bool = False
+        self._closed_reason: Optional[Exception] = None
+
+    @property
+    def is_closed(self):
+        with self._new_event_condition:
+            return self._is_closed
+
+    def close(self, reason: Optional[Exception] = None):
+        """Close the history and wake all pending waiters.
+
+        After closing, `record` raises `DAPError` and any in-flight
+        `wait_for_*` call raises `DAPError` instead of timing out. This
+        is called when the adapter disconnects or the session ends so
+        tests do not block for the full default timeout.
+
+        Args:
+            reason: Optional exception describing why the history was
+                closed. When set, it is included in the error raised by
+                waiters so they can see the underlying cause.
+        """
+        with self._new_event_condition:
+            if self._is_closed:
+                raise DAPError(
+                    f"history already closed with exception {self._closed_reason}"
+                    f"trying to close again with {reason}."
+                )
+            self._is_closed = True
+            self._closed_reason = reason
+            self._new_event_condition.notify_all()
+
+    def record(self, new_event: Event):
+        """Record an event in the history.
+
+        Enforces recording event in sequential order.
+        Raises:
+            DAPError: If the history has been closed or If the new event
+                seq is not greater than the last recorded event's `seq`.
+        """
+        new_seq: int = new_event.seq
+        with self._new_event_condition:
+            if self._is_closed:
+                raise DAPError(
+                    "Cannot record in EventHistory: session is closed."
+                ) from self._closed_reason
+
+            if len(self._sequences) > 0:
+                # History must be sequential.
+                last_seen_seq = self._sequences[-1]
+                if new_seq <= last_seen_seq:
+                    raise DAPError(
+                        f"event: '{new_event.event}' seq '{new_seq}' is older than last event: "
+                        f"'{self._events[-1].event}' seq: '{last_seen_seq}'"
+                    )
+
+            self._sequences.append(new_seq)
+            self._events.append(new_event)
+
+            # Sanity check.
+            assert len(self._sequences) == len(self._events)
+            self._new_event_condition.notify_all()
+
+    def wait_for_earliest_event(
+        self,
+        event_type: Type[AnyEvent],
+        *,
+        until: Optional[Callable[[AnyEvent], bool]] = None,
+        timeout: Optional[float] = None,
+        timeout_msg: Optional[str] = None,
+    ) -> AnyEvent:
+        """Wait for the earliest event of `event_type` in the history.
+
+        Searches from the beginning of the log (`seq` 0), so already-received
+        events count. Use this when a test wants the first event of a given
+        kind regardless of when it arrived.
+
+        Raises the same exceptions as `wait_for_event`.
+        """
+        assert issubclass(event_type, Event)
+
+        event_types = tuple((event_type,))
+        return self.__wait_for_any_event(
+            event_types,
+            after_seq=0,
+            until=until,
+            timeout=timeout,
+            timeout_msg=timeout_msg,
+        )
+
+    def wait_for_event(
+        self,
+        event_type: Type[AnyEvent],
+        *,
+        until: Optional[Callable[[AnyEvent], bool]] = None,
+        after: Event | Response,
+        timeout: Optional[float] = None,
+        timeout_msg: Optional[str] = None,
+    ) -> AnyEvent:
+        """Wait for the next event of `event_type` after a given message.
+
+        Search from "after some prior message" avoids races where
+        the event has already been observed: a test can capture a response
+        or event, run some action, and then wait for the *next* event of a
+        given kind without matching against anything already in the log.
+
+        Args:
+            event_type: Event subclass to match.
+            until: Optional predicate applied to each candidate. Only
+                events for which `until(event)` is true are accepted.
+            after: The prior event or response. Only events whose `seq`
+                is strictly greater are considered.
+            timeout: Override the history's default timeout, in seconds.
+            timeout_msg: Extra context appended to the `TimeoutError`
+                message if the wait times out.
+
+        Returns:
+            The first matching event after `after`.
+
+        Raises:
+            TimeoutError: If no matching event arrives within `timeout`.
+            DAPError: If the history is closed before a match is found.
+        """
+        assert issubclass(event_type, Event)
+
+        event_types = tuple((event_type,))
+        return self.wait_for_any_event(
+            event_types,
+            after=after,
+            until=until,
+            timeout=timeout,
+            timeout_msg=timeout_msg,
+        )
+
+    def wait_for_any_event(
+        self,
+        event_types: Tuple[Type[AnyEvent], ...],
+        *,
+        until: Optional[Callable[[AnyEvent], bool]] = None,
+        after: Event | Response,
+        timeout: Optional[float] = None,
+        timeout_msg: Optional[str] = None,
+    ):
+        """Wait for the next event matching any of several types.
+
+        Same semantics as `wait_for_event`, but the returned event may be
+        an instance of any of the given `event_types`. Useful when a test
+        is expecting the first of two different events.
+        """
+        assert after.type in (
+            MessageType.EVENT,
+            MessageType.RESPONSE,
+        ), f"expects instance of 'Event' or 'Response' got {after}."
+        return self.__wait_for_any_event(
+            event_types,
+            after_seq=after.seq,
+            until=until,
+            timeout=timeout,
+            timeout_msg=timeout_msg,
+        )
+
+    def __wait_for_any_event(
+        self,
+        event_types: Tuple[Type[AnyEvent], ...],
+        *,
+        after_seq: int,
+        until: Optional[Callable[[AnyEvent], bool]] = None,
+        timeout: Optional[float] = None,
+        timeout_msg: Optional[str] = None,
+    ):
+        assert after_seq >= 0, "response or event sequence must be greater than 0."
+        assert isinstance(event_types, tuple), "expected a tuple of events."
+        assert len(event_types) > 0, "expected at least one event to wait for."
+
+        def make_error_msg(is_timeout: bool = True):
+            event_names = [x.__name__ for x in event_types]
+            prefix = f"Timed out after {timeout}s" if is_timeout else "Error while"
+            err_msg = f"{prefix} waiting for any event that matches: {event_names}"
+            err_msg += f" after sequence: {after_seq}."
+
+            if timeout_msg:
+                err_msg += f"\n\t{timeout_msg}."
+
+            with self._new_event_condition:
+                last_event = self._events[-1] if self._events else None
+            err_msg += f"\n\tlast seen event: {last_event}."
+            return err_msg
+
+        def is_event_and_matches_condition(evt: Event):
+            if not isinstance(evt, event_types):
+                return False
+
+            if until is None:
+                return True
+
+            matches = until(evt)
+            return matches
+
+        timeout = timeout or self._timeout
+        try:
+            event = self.__wait_until(
+                is_event_and_matches_condition, after_seq=after_seq, timeout=timeout
+            )
+        except DAPError as err:
+            # Add extra context to the error.
+            err.args = (f"{err.args[0]}\n\t{make_error_msg(False)}", *err.args[1:])
+            raise
+
+        if event is None:
+            raise TimeoutError(make_error_msg())
+
+        # Sanity check.
+        assert isinstance(event, event_types)
+        return event
+
+    def __wait_until(
+        self,
+        matches_condition: Callable[[Event], bool],
+        *,
+        after_seq: int,
+        timeout: float,
+    ):
+        """Waits until the `matches_condition` returns true for an exiting
+        event or an incoming event. If the history is closed during the wait,
+        raise a DAPError."""
+
+        end_time = time.monotonic() + timeout
+        start_idx = 0
+
+        with self._new_event_condition:
+            while True:
+                seq_len = len(self._sequences)
+                idx = bisect.bisect_right(self._sequences, after_seq, lo=start_idx)
+
+                # Scan forward until we find a matching type.
+                for event in itertools.islice(self._events, idx, seq_len):
+                    if matches_condition(event):
+                        return event
+                start_idx = seq_len
+
+                if self._is_closed:  # Can no longer receive new messages.
+                    reason = self._closed_reason
+                    last_evt = self._events[-1] if self._events else None
+                    raise DAPError.history_closed(reason, last_evt) from reason
+
+                remaining_time = end_time - time.monotonic()
+                if remaining_time <= 0:
+                    return None
+                self._new_event_condition.wait(remaining_time)
+
+
+def redirect_stream(
+    in_stream: IO[bytes], out_stream: IO[str], thread_name: str
+) -> threading.Thread:
+    """
+    Creates a new thread that redirects stream from `in_stream` to
+    `out_stream`. We use this for the 'runInTerminal' process to send stdio
+    to the session's output.
+
+    Returns a thread that redirects the stream.
+    """
+
+    def read_loop(in_stream: IO[bytes], out_stream: IO[str]):
+        with contextlib.suppress(OSError, ValueError):  # Nothing to report.
+            while True:
+                chunk = in_stream.read(4096)
+                if not chunk:
+                    break
+
+                out_stream.write(chunk.decode(errors="replace"))
+                out_stream.flush()
+
+    thread_name = f"redirect_{thread_name}"
+    redirect_thread = threading.Thread(
+        target=read_loop,
+        name=thread_name,
+        args=[in_stream, out_stream],
+        daemon=True,
+    )
+    redirect_thread.start()
+
+    return redirect_thread
+
+
+ at runtime_checkable
+class Transport(Protocol):
+    """Interface representing a bidirectional transport.
+
+    Implementations:
+        `_StdioTransport`: speaks to the adapter using a subprocess's stdin/stdout.
+            Used when the adapter is spawned as a child process.
+        `_SocketTransport`: speaks to the adapter using socket. Used when the
+            adapter is already running and exposes connection URI.
+    """
+
+    def write(self, data: bytes): ...
+
+    def read(self, n: int) -> bytes: ...
+
+    def readline(self) -> bytes: ...
+
+    def close(self):
+        """Close the transport.
+
+        Buffered data will be flushed and transport closed.
+        """
+        ...
+
+    @property
+    def is_alive(self) -> bool:
+        """Whether send or receive bytes through the transport."""
+        ...
+
+
+ at dataclass(frozen=True)
+class MessageHandler:
+    on_response: Callable[[RawMessage], None]
+    on_event: Callable[[RawMessage], None]
+    on_reverse_request: Callable[[RawMessage], None]
+    on_close: Optional[Callable[[Optional[Exception]], None]] = lambda _: None
+
+
+class DAPConnection:
+    """Manages the connection between a debug session and a debug adapter.
+
+    Encodes and decodes messages using the DAP protocol, mapping them to
+    dictionaries representing DAP types. Handles bidirectional communication
+    between the session and the adapter, including error handling for
+    failures from the debug adapter.
+    """
+
+    def __init__(self, connection_id: str, transport: Transport):
+        assert isinstance(transport, Transport)
+        self.id: str = connection_id
+        self._transport = transport
+
+        # A request that's been sent and is awaiting its response.
+        self._in_flight_requests: dict[int, tuple[RawMessage, Future[RawMessage]]] = {}
+        self._in_flight_lock = threading.Lock()
+        self._received_messages: list[RawMessage] = []
+
+        # Event to sync when the Connection start listening for messages.
+        self._is_ready = threading.Event()
+        self._is_ready.clear()
+
+    def start(self, handler: MessageHandler):
+        self._is_ready.set()
+        self._read_loop(handler)
+
+    def stop(self):
+        if self._transport.is_alive:
+            self._transport.close()
+
+    @staticmethod
+    def encode_message(message: dict):
+        content = json.dumps(message, separators=(",", ":"))
+        header = f"Content-Length: {len(content)}\r\n\r\n"
+        data = f"{header}{content}".encode("utf-8")
+        return data
+
+    def send_request(self, request: Request) -> Future[RawMessage]:
+        seq = request.seq
+        response_future: Future[RawMessage] = Future()
+        request_dict = request.to_dict()
+        with self._in_flight_lock:
+            self._in_flight_requests[seq] = (request_dict, response_future)
+        self.send_message(request_dict)
+        return response_future
+
+    def send_message(self, message: dict):
+        assert self.is_alive(), f"'{type(self).__name__}' is not running"
+        data = DAPConnection.encode_message(message)
+        self._transport.write(data)
+
+    def is_alive(self):
+        return self._transport.is_alive
+
+    def wait_until_alive(self, timeout: float):
+        return self._is_ready.wait(timeout)
+
+    def _read_loop(self, handler: MessageHandler):
+        error = None
+        try:
+            while self.is_alive():
+                message = self._read_message()
+                if not message:
+                    break
+
+                self._received_messages.append(message)
+                self._on_message(message, handler)
+
+        except Exception as e:
+            error = e
+        finally:
+            # Reject any unresolved requests so the test thread don't wait the
+            # full timeout when the adapter exits or gets killed.
+            with self._in_flight_lock:
+                pending_futures = [f for _, f in self._in_flight_requests.values()]
+
+            resp_error = error or DAPError("DAP connection closed before response.")
+            for future in pending_futures:
+                if not future.done():
+                    future.set_exception(resp_error)
+
+            with contextlib.suppress(Exception):
+                self.stop()
+                if on_close := handler.on_close:
+                    on_close(error)
+
+    def _on_message(self, message: RawMessage, handler: MessageHandler):
+        msg_type = message.get("type")
+        if msg_type == "response":
+            request_seq = message["request_seq"]
+            with self._in_flight_lock:
+                in_flight = self._in_flight_requests.pop(request_seq, None)
+            if in_flight is not None:
+                request, response_future = in_flight
+                if request["command"] == message["command"]:
+                    response_future.set_result(message)
+                else:
+                    response_future.set_exception(
+                        ValueError(
+                            f"command mismatch in response"
+                            f"{request['command']} != {message['command']}"
+                        )
+                    )
+            handler.on_response(message)
+
+        elif msg_type == "event":
+            handler.on_event(message)
+
+        elif msg_type == "request":
+            handler.on_reverse_request(message)
+
+        else:
+            raise DAPError(f"Unknown message type: {msg_type}")
+
+    def _read_message(self):
+        HEADER_TERMINATOR = b"\r\n\r\n"
+        CONTENT_LEN_PREFIX = b"Content-Length: "
+        buffer = bytearray()
+
+        while True:
+            chunk = self._transport.readline()
+            if not chunk:
+                if buffer:
+                    raise EOFError(f"unexpected EOF when parsing header: {buffer}")
+                else:
+                    return None
+            buffer += chunk
+
+            header_end = buffer.find(HEADER_TERMINATOR)
+            if header_end == -1:
+                continue
+            header = buffer[:header_end]
+
+            # Look for the Content-Length header.
+            content_length = 0
+            for line in header.split(b"\r\n"):
+                if line.startswith(CONTENT_LEN_PREFIX):
+                    content_length = int(line[len(CONTENT_LEN_PREFIX) :])
+                    break
+            else:
+                raise DAPError(f"Invalid header: {header}")
+
+            # Parse Content-Part.
+            message_start = header_end + len(HEADER_TERMINATOR)
+            buffer = buffer[message_start:]
+            while len(buffer) < content_length:
+                chunk = self._transport.read(content_length - len(buffer))
+                if not chunk:
+                    raise EOFError(f"unexpected EOF when parsing message: {buffer}")
+                buffer += chunk
+
+            message = json.loads(buffer.decode("utf-8"))
+            return message
+
+
+class _StdioTransport:
+    def __init__(self, process: subprocess.Popen[bytes]):
+        self._process = process
+
+        stdin = self._process.stdin
+        stdout = self._process.stdout
+        assert stdin is not None
+        assert stdout is not None
+        self._stdin = stdin
+        self._stdout = stdout
+
+        self._is_closed = False
+        assert self.is_alive
+
+    def write(self, data: bytes):
+        self._stdin.write(data)
+        self._stdin.flush()
+
+    def read(self, n: int) -> bytes:
+        return self._stdout.read(n)
+
+    def readline(self):
+        return self._stdout.readline()
+
+    def close(self):
+        if self._is_closed:
+            return
+        self._is_closed = True
+
+        # Close stdin only. In Python3.8, closing stdout from main thread while the
+        # reader thread is inside BufferedReader.read() will crash the interpreter.
+        # The stdout cleanup happens via DebugAdapter.kill() when we kill the process
+        with contextlib.suppress(OSError, ValueError):
+            self._stdin.flush()
+            self._stdin.close()
+
+    @property
+    def is_alive(self):
+        if self._is_closed:
+            return False
+
+        # On Unix-like systems, attaching a debugger via ptrace temporarily reparents
+        # the target process. This breaks Python's internal waitpid() tracking, causing
+        # Popen.poll() to falsely return 0. Python assumes the missing child process
+        # has already terminated cleanly, which leads to inaccurate status checks.
+        return not self._stdout.closed and not self._stdin.closed
+
+
+class _SocketTransport:
+    def __init__(self, uri: str):
+        self.uri = uri
+        scheme, address = self.uri.split("://")
+        if scheme == "unix-connect":  # unix-connect:///path
+            self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+            self._socket.connect(address)
+        elif scheme == "connection":  # connection://[host]:port
+            host, port = address.rsplit(":", 1)
+            # create_connection with try both ipv4 and ipv6.
+            self._socket = socket.create_connection((host.strip("[]"), int(port)))
+        else:
+            raise ValueError(f"invalid URI '{self.uri}' for socket")
+
+        self._reader = self._socket.makefile("rb", buffering=-1)
+        self._writer = self._socket.makefile("wb", buffering=-1)
+
+        self._is_closed = False
+        assert self.is_alive
+
+    def write(self, data: bytes):
+        self._writer.write(data)
+        self._writer.flush()
+
+    def read(self, n: int) -> bytes:
+        return self._reader.read(n)
+
+    def readline(self):
+        return self._reader.readline()
+
+    def close(self):
+        if self._is_closed:
+            return
+        self._is_closed = True
+
+        with contextlib.suppress(OSError, ValueError):
+            self._writer.flush()
+
+        if self._socket.fileno() != -1:
+            self._socket.shutdown(socket.SHUT_RDWR)
+
+        self._writer.close()
+        self._socket.close()
+
+    @property
+    def is_alive(self):
+        if self._is_closed:
+            return False
+        try:
+            _ = self._socket.getpeername()
+        except socket.error:
+            return False
+        return True
diff --git a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
index 8ba56b0bb27ca..b13b1d44b6d43 100644
--- a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
+++ b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
@@ -4,17 +4,23 @@
 know about it.
 """
 
-import lldbdap_testcase
 from lldbsuite.test.lldbtest import line_number
-from dap_server import Event
+from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs, StackTraceArgs
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.session_helpers import DAPTestSession
 
 
-class TestDAP_invalidatedEvent(lldbdap_testcase.DAPTestCaseBase):
-    def verify_top_frame_name(self, frame_name: str):
-        all_frames = self.get_stackFrames()
+class TestDAP_invalidatedEvent(DAPTestCaseBase):
+    def verify_top_frame_name(
+        self, session: DAPTestSession, frame_name: str, thread_id: int
+    ):
+        response = session.stack_trace(thread_id)
+        all_frames = response.body.stackFrames
+
         self.assertGreaterEqual(len(all_frames), 1, "Expected at least one frame.")
-        top_frame_name = all_frames[0]["name"]
+        top_frame_name = all_frames[0].name
         self.assertRegex(top_frame_name, f"{frame_name}.*")
+        return response
 
     def test_invalidated_stack_area_event(self):
         """
@@ -22,34 +28,39 @@ def test_invalidated_stack_area_event(self):
         The event is sent when the command `thread return <expr>` is sent by the user.
         """
         other_source = "other.h"
-        return_bp_line = line_number(other_source, "// thread return breakpoint")
-
         program = self.getBuildArtifact("a.out")
-        self.build_and_launch(program)
-        self.set_source_breakpoints(other_source, [return_bp_line])
-        self.continue_to_next_stop()
+        session = self.build_and_create_session()
+        with session.configure(LaunchArgs(program)) as ctx:
+            return_bp_line = line_number(other_source, "// thread return breakpoint")
+            session.resolve_source_breakpoints(other_source, [return_bp_line])
+
+        stopped_event = session.verify_stopped_on_breakpoint(after=ctx.process_event)
 
-        self.verify_top_frame_name("add")
-        thread_id = self.dap_server.get_thread_id()
-        self.assertIsNotNone(thread_id, "Exepected a thread id.")
+        thread_id = self.expect_not_none(
+            stopped_event.body.threadId, "expected a thread id."
+        )
+        stack_response = self.verify_top_frame_name(session, "add", thread_id)
 
-        # run thread return
+        # Run thread return.
         thread_command = "thread return 20"
-        eval_resp = self.dap_server.request_evaluate(thread_command, context="repl")
-        self.assertTrue(eval_resp["success"], f"Failed to evaluate `{thread_command}`.")
-
-        # wait for the invalidated stack event.
-        stack_event = self.dap_server.wait_for_event(["invalidated"])
-        self.assertIsNotNone(stack_event, "Expected an invalidated event.")
-        event_body: Event = stack_event["body"]
-        self.assertIn("stacks", event_body["areas"])
-        self.assertIn("threadId", event_body.keys())
+        session.evaluate(thread_command, context="repl")
+
+        # Wait for the invalidated stack event.
+        invalid_event = session.wait_for_invalidated(after=stack_response)
+        self.assertIsNotNone(invalid_event, "Expected an invalidated event.")
+        event_body = invalid_event.body
+        self.assertIsNotNone(event_body.areas)
+        self.assertIn("stacks", event_body.areas or [])
+        self.assertIsNotNone(event_body.threadId)
         self.assertEqual(
             thread_id,
-            event_body["threadId"],
+            event_body.threadId,
             f"Expected the event from thread {thread_id}.",
         )
 
-        # confirm we are back at the main frame.
-        self.verify_top_frame_name("main")
-        self.continue_to_exit()
+        # Confirm we are back at the main frame.
+        thread_id = self.expect_not_none(
+            invalid_event.body.threadId, "expected a thread id."
+        )
+        self.verify_top_frame_name(session, "main", thread_id)
+        session.continue_to_exit()
diff --git a/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py b/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
index 93ae5d05e9d6c..8cbad660651b6 100644
--- a/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
+++ b/lldb/test/API/tools/lldb-dap/launch/TestDAP_launch_basic.py
@@ -2,11 +2,11 @@
 Test lldb-dap launch request.
 """
 
-from lldbsuite.test.decorators import expectedFailureWindows
-import lldbdap_testcase
+from lldbsuite.test.tools.lldb_dap import lldb_dap_testcase
+from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs
 
 
-class TestDAP_launch_basic(lldbdap_testcase.DAPTestCaseBase):
+class TestDAP_launch_basic(lldb_dap_testcase.DAPTestCaseBase):
     """
     Tests the default launch of a simple program. No arguments,
     environment, or anything else is specified.
@@ -14,10 +14,12 @@ class TestDAP_launch_basic(lldbdap_testcase.DAPTestCaseBase):
 
     def test(self):
         program = self.getBuildArtifact("a.out")
-        self.build_and_launch(program)
-        self.continue_to_exit()
-        # Now get the STDOUT and verify our program argument is correct
-        output = self.get_stdout()
+        session = self.build_and_create_session()
+        session.launch(LaunchArgs(program=program))
+        session.verify_process_exited()
+
+        # Now get the STDOUT and verify our program argument is correct.
+        output = session.get_stdout()
         self.assertTrue(output and len(output) > 0, "expect program output")
         lines = output.splitlines()
         self.assertIn(program, lines[0], "make sure program path is in first argument")
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
new file mode 100644
index 0000000000000..9af9e553c0446
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
@@ -0,0 +1,82 @@
+import io
+import json
+from typing import List
+
+from lldbsuite.test.tools.lldb_dap.dap_types import RawMessage
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.utils import DAPConnection, MessageHandler, Transport
+
+
+class EchoClient:
+    def __init__(self):
+        self.seen_messages: List[RawMessage] = []
+
+    def on_message(self, msg: RawMessage):
+        self.seen_messages.append(msg)
+
+
+class TestDAPUtils_DAPConnection(DAPTestCaseBase):
+    def test_round_trip(self):
+        received_messages = self.get_sample_message()
+        transport = self.create_transport(received_messages)
+        connection = DAPConnection("conn0", transport)
+
+        client = EchoClient()
+        handler = MessageHandler(
+            on_event=client.on_message,
+            on_response=client.on_message,
+            on_reverse_request=client.on_message,
+        )
+        connection.start(handler)
+        expected_messages = client.seen_messages
+        self.assertEqual(len(received_messages), len(expected_messages))
+        for actual, expected in zip(received_messages, expected_messages):
+            self.assertEqual(actual, expected)
+
+    def test_encode_message_framing(self):
+        payload = {"type": "request", "seq": 1, "command": "initialize"}
+        data = DAPConnection.encode_message(payload)
+        header, _, body = data.partition(b"\r\n\r\n")
+        self.assertTrue(header.startswith(b"Content-Length:"))
+        content_length = int(header.split(b":")[1].strip())
+        self.assertEqual(content_length, len(body))
+        self.assertEqual(json.loads(body), payload)
+
+    def test_encode_message_round_trips(self):
+        payload = {"nested": {"a": 1, "b": [1, 2, 3]}}
+        _, _, body = DAPConnection.encode_message(payload).partition(b"\r\n\r\n")
+        self.assertEqual(json.loads(body), payload)
+
+    @staticmethod
+    def create_transport(data: List[RawMessage]) -> Transport:
+        class BinaryTransport:
+            def __init__(self, data: List[RawMessage]):
+                encoded_data = [
+                    DAPConnection.encode_message(message) for message in data
+                ]
+                self._in = io.BytesIO(b"".join(encoded_data))
+                self._out: List[str] = []
+
+            def write(self, data: bytes):
+                self._out.append(data.decode("utf-8"))
+
+            def readline(self):
+                return self._in.readline()
+
+            def read(self, n: int) -> bytes:
+                return self._in.read(n)
+
+            def close(self):
+                self._in.close()
+
+            @property
+            def is_alive(self) -> bool:
+                return not self._in.closed
+
+        return BinaryTransport(data)
+
+    def get_sample_message(self) -> List[dict]:
+        message_log = self.getSourcePath("sample_message.json")
+        with open(message_log, "r") as file:
+            raw_events = json.loads(file.read())
+        return raw_events
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
new file mode 100644
index 0000000000000..a5e69cf15dc83
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
@@ -0,0 +1,252 @@
+import json
+import threading
+from typing import List, Union
+
+from lldbsuite.test.tools.lldb_dap.dap_types import (
+    CapabilitiesEvent,
+    DAPError,
+    Event,
+    ExitedEvent,
+    InitializedEvent,
+    ModuleEvent,
+    OutputEvent,
+    ProcessEvent,
+    ProgressEndEvent,
+    TerminatedEvent,
+)
+from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
+from lldbsuite.test.tools.lldb_dap.utils import EventHistory
+
+
+class TestDAPUtils_EventHistory(DAPTestCaseBase):
+    """
+    This test suite verifies the behavior of EventHistory, including event ordering validation,
+    querying specific events, handling closed histories, applying custom filtering conditions,
+    and managing timeouts during asynchronous event additions.
+    """
+
+    def test_history_order(self):
+        """Test that events added to EventHistory are in sequential order"""
+
+        history = EventHistory(timeout=10)
+        events = [
+            {
+                "body": {"category": "console", "output": "Running preInitCommands:\n"},
+                "event": "output",
+                "seq": 2,
+                "type": "event",
+            },
+            {
+                "body": {
+                    "category": "console",
+                    "output": "(lldb) log enable lldb process",
+                },
+                "event": "output",
+                "seq": 1,
+                "type": "event",
+            },
+        ]
+        events = [Event.from_json(event) for event in events]
+
+        with self.assertRaises(AssertionError):
+            for event in events:
+                history.record(event)
+
+    def test_wait_for_X_event_on_history_closed(self):
+        """Tests `wait_for_first_event`, `wait_for_event` and `wait_for_any_event` when the history is closed.
+
+        It should return a DAPError (HistoryClosed) instead of waiting for new events if the
+        history is closed and the requested event does not already exist in the history.
+        """
+        history = EventHistory(timeout=10)
+        self.populate_history(history)
+
+        # We only intend on working on existing events.
+        # so we don't get a Timeout error.
+        history.close()
+
+        init_event = history.wait_for_earliest_event(InitializedEvent)
+        self.assertEqual(init_event.seq, 9)
+        self.assertIsInstance(init_event, InitializedEvent)
+        self.assertEqual(init_event.type, "event")
+
+        cap_event = history.wait_for_event(CapabilitiesEvent, after=init_event)
+        self.assertGreater(cap_event.seq, init_event.seq)
+        self.assertIsInstance(cap_event, CapabilitiesEvent)
+
+        # There is only one capabilitiesEvent in the history.
+        with self.assertRaises(DAPError):
+            history.wait_for_event(CapabilitiesEvent, after=cap_event)
+
+        # There is no progressEndEvent in history.
+        with self.assertRaises(DAPError):
+            history.wait_for_earliest_event(ProgressEndEvent)
+
+        # Must wait for at least one event.
+        with self.assertRaises(AssertionError):
+            history.wait_for_any_event((), after=cap_event)
+
+        # Test wait_for_any_event.
+        # Wait for_any_event should return the first seen event that in in the tuple
+        any_event = history.wait_for_any_event(
+            (ProcessEvent, OutputEvent, ModuleEvent, TerminatedEvent), after=cap_event
+        )
+        # We should see the output event first.
+        self.assertIsInstance(any_event, OutputEvent)
+
+        any_event = history.wait_for_any_event(
+            (ModuleEvent, TerminatedEvent), after=cap_event
+        )
+
+        # We should see the ModuleEvent event first.
+        self.assertIsInstance(any_event, ModuleEvent)
+
+    def test_wait_for_X_event_until_Y_condition(self):
+        """
+        Tests the `until` parameter in wait_for_XXXX methods.
+
+        Verifies that `wait_for_event` and `wait_for_any_event` continue scanning
+        through matching event types until the provided condition function returns True.
+        or throws an error.
+        """
+        history = EventHistory(timeout=10)
+        self.populate_history(history)
+        history.close()
+
+        start_event = history.wait_for_earliest_event(OutputEvent)
+        self.assertEqual(start_event.seq, 1)
+        self.assertIsInstance(start_event, OutputEvent)
+        self.assertEqual(start_event.body.output, "Running preInitCommands:\n")
+
+        # Test wait_for_event_until success.
+        def has_got_token(event: OutputEvent):
+            return "GOT TOKEN" in event.body.output
+
+        output_event = history.wait_for_event(
+            OutputEvent, after=start_event, until=has_got_token
+        )
+        self.assertEqual(output_event.seq, 44)
+        self.assertIsInstance(output_event, OutputEvent)
+        self.assertIn("GOT TOKEN", output_event.body.output)
+
+        def sdl3_module_loaded(event: ModuleEvent):
+            module_is_sdl = event.body.module.name == "sdl3"
+            is_new_module = event.body.reason == "new"
+
+            return module_is_sdl and is_new_module
+
+        # Test wait_for_event_until failure.
+        # There is no sdl3 module in the event history.
+        with self.assertRaises(DAPError):
+            history.wait_for_event(
+                ModuleEvent, after=start_event, until=sdl3_module_loaded
+            )
+
+        # Test wait_for_any_event.
+        def sdl3_module_loaded_or_process_exited(
+            event: Union[ModuleEvent, ExitedEvent],
+        ):
+            if isinstance(event, ModuleEvent):
+                return sdl3_module_loaded(event)
+
+            return True
+
+        seen_event = history.wait_for_any_event(
+            (ModuleEvent, ExitedEvent),
+            after=start_event,
+            until=sdl3_module_loaded_or_process_exited,
+        )
+
+        self.assertEqual(seen_event.seq, 47)
+        self.assertIsInstance(seen_event, ExitedEvent)
+        self.assertEqual(seen_event.body.exitCode, 0)  # type: ignore
+
+    def test_wait_for_X_event_with_timeout(self):
+        """Tests the timeout functionality when waiting for events.
+
+        Verifies that a TimeoutError is raised if the expected event does not
+        arrive within the specified timeout. Also ensures that the wait methods
+        successfully block and retrieve events added asynchronously by another thread
+        before the timeout expires.
+        """
+        history = EventHistory(timeout=10)
+        self.populate_history(history)
+
+        delayed_append = threading.Event()
+        delayed_append.clear()
+
+        new_cap_events = [
+            Event.from_json(
+                {
+                    "body": {"capabilities": {"supportsModulesRequest": True}},
+                    "event": "capabilities",
+                    "seq": 50,
+                    "type": "event",
+                }
+            ),
+            Event.from_json(
+                {
+                    "body": {"capabilities": {"supportsRestartRequest": True}},
+                    "event": "capabilities",
+                    "seq": 52,
+                    "type": "event",
+                }
+            ),
+        ]
+
+        def add_delayed_events(events: List[Event]):
+            delayed_append.wait()
+            for event in events:
+                history.record(event)
+
+        event_thread = threading.Thread(
+            target=add_delayed_events, args=[new_cap_events]
+        )
+        event_thread.start()
+
+        try:
+            exited_event = history.wait_for_earliest_event(ExitedEvent)
+            self.assertEqual(exited_event.seq, 47)
+            self.assertEqual(exited_event.body.exitCode, 0)
+
+            def supports_restart_request(event: CapabilitiesEvent):
+                return event.body.capabilities.supportsRestartRequest == True
+
+            # No new CapabilitiesEvents have been added after exited event, so this should time out.
+            with self.assertRaises(TimeoutError):
+                history.wait_for_event(
+                    CapabilitiesEvent,
+                    after=exited_event,
+                    until=supports_restart_request,
+                    timeout=0.05,
+                )
+
+            # Unblock and Sync with the event thread.
+            delayed_append.set()
+            seen_event = history.wait_for_event(
+                CapabilitiesEvent,
+                after=exited_event,
+                until=supports_restart_request,
+                timeout=10,
+            )
+
+            # The supportsModuleRequest CapabilitiesEvent should be ignored.
+            self.assertEqual(seen_event.seq, 52)
+            self.assertIsInstance(seen_event, CapabilitiesEvent)
+            self.assertTrue(seen_event.body.capabilities.supportsRestartRequest)
+        finally:
+            # Clean up
+            delayed_append.set()
+            event_thread.join()
+
+    def generate_events(self) -> List[Event]:
+        """Generates the default events that is used in tests."""
+        message_log = self.getSourcePath("sample_message.json")
+        with open(message_log, "r") as file:
+            raw_events = json.loads(file.read())
+        events = [Event.from_json(raw_event) for raw_event in raw_events]
+        return events
+
+    def populate_history(self, history: EventHistory):
+        for event in self.generate_events():
+            history.record(event)
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
new file mode 100644
index 0000000000000..2205e17d41c40
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_Types.py
@@ -0,0 +1,452 @@
+import copy
+import unittest
+from dataclasses import dataclass, field
+from typing import Dict, List, Literal, Optional, Tuple, Type, TypeVar, Union, cast
+
+from lldbsuite.test.tools.lldb_dap.dap_types import (
+    EmptyBodyResponse,
+    Event,
+    EventName,
+    OutputEvent,
+    Request,
+    SetBreakpointsArgs,
+    Source,
+    StackTraceResponse,
+    StoppedEvent,
+    StoppedReason,
+    dict_to_message,
+    message_to_dict,
+)
+
+T = TypeVar("T")
+
+_Color = Literal["RED", "BLUE", "YELLOW"]
+_Number = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+_ColorOrNumber = Literal[_Color, _Number]
+_ColorNumberOrString = Literal[Literal["ONE", "TWO", Literal["THREE"]], _ColorOrNumber]
+
+
+class TestDAPUtils_Types(unittest.TestCase):
+    """Test serialization and deserialization of different dap types."""
+
+    def verify_round_trip(self, value_type: Type, value: dict):
+        message = dict_to_message(value_type, value)
+        gotten_value = message_to_dict(message)
+        self.assertDictEqual(value, gotten_value)
+
+        # Check the dict values get reconverted to the appropriate types.
+        for expected_key, expected_value in gotten_value.items():
+            self.assertEqual(type(expected_key), str, "expects type of key to be str.")
+            original_value = value[expected_key]
+            self.assertEqual(type(expected_value), type(original_value))
+
+    def test_encode_and_decode(self):
+        # Self referencing and optional type.
+        source_dict = {
+            "name": "main.cpp",
+            "path": "/some/random/path/to/a/file",
+            "sources": [{"name": "main.c", "path": "/another/random/path/to/file"}],
+        }
+        self.verify_round_trip(Source, source_dict)
+
+        # Argument and nested type.
+        source_breakpoint_dict = {
+            "source": {
+                "name": "CreateASTUnitFromArgs.cpp",
+                "path": "/llvm-project/clang/lib/Driver/CreateASTUnitFromArgs.cpp",
+            },
+            "lines": [86],
+            "breakpoints": [{"line": 86}, {"line": 30}, {"line": 100}],
+            "sourceModified": False,
+        }
+        self.verify_round_trip(SetBreakpointsArgs, source_breakpoint_dict)
+
+        # Response type.
+        configuration_done_response_dict = {
+            "command": "configurationDone",
+            "request_seq": 16,
+            "seq": 28,
+            "success": True,
+            "type": "response",
+        }
+        self.verify_round_trip(EmptyBodyResponse, configuration_done_response_dict)
+
+        stack_trace_dict = {
+            "body": {
+                "stackFrames": [
+                    {
+                        "column": 11,
+                        "id": 524288,
+                        "instructionPointerReference": "0x555555555266",
+                        "line": 23,
+                        "moduleId": "2833EAD0-0FDC-66C8-88B1-8C6E1D82736C-AE103AE6",
+                        "name": "main",
+                        "source": {
+                            "name": "convert.cpp",
+                            "path": "/path/to/where/convert.cpp",
+                        },
+                    }
+                ],
+                "totalFrames": 21,
+            },
+            "command": "stackTrace",
+            "request_seq": 21,
+            "seq": 40,
+            "success": True,
+            "type": "response",
+        }
+        self.verify_round_trip(StackTraceResponse, stack_trace_dict)
+
+        # Event type.
+        output_event_dict = {
+            "body": {
+                "category": "stdout",
+                "output": "print malformed utf8 𐌶𐌰L𐌾𐍈 C𐍈𐌼𐌴𐍃 lone trailing �� bytes\r\n",
+            },
+            "event": "output",
+            "seq": 37,
+            "type": "event",
+        }
+        self.verify_round_trip(OutputEvent, output_event_dict)
+
+        stopped_event_dict = {
+            "body": {
+                "allThreadsStopped": True,
+                "description": "breakpoint 3.1",
+                "hitBreakpointIds": [3],
+                "reason": "breakpoint",
+                "text": "breakpoint 3.1",
+                "threadId": 4039875,
+            },
+            "event": "stopped",
+            "seq": 38,
+            "type": "event",
+        }
+        self.verify_round_trip(StoppedEvent, stopped_event_dict)
+
+    def test_failing_encode_and_decode(self):
+        """Test encoding will fail if the type or value is wrong."""
+        # Verify missing field type.
+        with self.assertRaises(TypeError):
+            configuration_request_dict = {
+                "command": "configurationDone",
+                "seq": 16,
+            }
+            self.verify_round_trip(Request, configuration_request_dict)
+
+        # Event with the wrong value for the type key.
+        with self.assertRaises(ValueError, msg="expects events with the correct type"):
+            process_event_dict = {
+                "body": {
+                    "isLocalProcess": True,
+                    "name": "/path/to/test/build/test",
+                    "pointerSize": 64,
+                    "startMethod": "launch",
+                    "systemProcessId": 4039875,
+                },
+                "event": "process",
+                "seq": 27,
+                "type": "response",  # wrong type
+            }
+            Event.from_json(process_event_dict)
+
+    def test_primitives(self):
+        """Test encoding and decoding of python primitives."""
+
+        @dataclass
+        class Primitives:
+            pbool: bool
+            pint: int
+            pfloat: float
+            pstr: str
+            pbytes: bytes
+
+        prim_dict = {
+            "pbool": True,
+            "pint": 10,
+            "pfloat": 3.14,
+            "pstr": "HeLL0",
+            "pbytes": b"HeLL0 Bytes",
+        }
+        prim_obj = dict_to_message(Primitives, prim_dict)
+        self.assertEqual(prim_obj.pbool, True)
+        self.assertEqual(type(prim_obj.pbool), bool)
+
+        self.assertEqual(prim_obj.pint, 10)
+        self.assertEqual(type(prim_obj.pint), int)
+
+        self.assertEqual(prim_obj.pfloat, 3.14)
+        self.assertEqual(type(prim_obj.pfloat), float)
+
+        self.assertEqual(prim_obj.pstr, "HeLL0")
+        self.assertEqual(type(prim_obj.pstr), str)
+
+        self.assertEqual(prim_obj.pbytes, b"HeLL0 Bytes")
+        self.assertEqual(type(prim_obj.pbytes), bytes)
+
+        self.verify_round_trip(Primitives, prim_dict)
+
+    def test_literals(self):
+        """Test that encoding or decoding dataclass with any form of literal
+        is typechecked correctly.
+        """
+
+        @dataclass
+        class WithLiteral:
+            color: _Color
+            id: Optional[_Number] = None
+            any: Optional[_ColorOrNumber] = None
+            color_opt: Optional[_Color] = None
+            reason: Optional[StoppedReason] = None
+
+        @dataclass
+        class NestedLiterals:
+            value: _ColorNumberOrString
+
+        # Literals
+        color_dict = {"color": "RED", "id": 2, "color_opt": "BLUE"}
+        self.verify_round_trip(WithLiteral, color_dict)
+
+        color_dict_no_opt = {"color": "RED", "id": 1}
+        self.verify_round_trip(WithLiteral, color_dict_no_opt)
+
+        literal_dict = {"color": "BLUE", "id": 0, "any": "YELLOW", "reason": "goto"}
+        self.verify_round_trip(WithLiteral, literal_dict)
+        message = dict_to_message(WithLiteral, literal_dict)
+        self.assertEqual(message.color, literal_dict["color"])
+        self.assertEqual(message.id, literal_dict["id"])
+        self.assertIsNotNone(message.id)
+        self.assertEqual(message.any, literal_dict["any"])
+        self.assertIsNone(message.color_opt)
+        self.assertEqual(message.reason, literal_dict["reason"])
+
+        with self.assertRaises(ValueError):
+            # '1' is string instead of int
+            mixed_lit_dict = {"color": "BLUE", "id": 7, "any": "1"}
+            self.verify_round_trip(WithLiteral, mixed_lit_dict)
+
+        lit_dicts = [
+            {"value": 1},
+            {"value": "ONE"},
+            {"value": "BLUE"},
+            {"value": "THREE"},
+        ]
+        for a_dict in lit_dicts:
+            self.verify_round_trip(NestedLiterals, a_dict)
+
+        with self.assertRaises(ValueError):
+            a_dict = {"value": 100}  # Not part of the specified literals.
+            self.verify_round_trip(NestedLiterals, a_dict)
+
+    def test_unions(self):
+        """Test that Union types encodes and decodes correctly"""
+
+        @dataclass
+        class WithPrimitiveUnion:
+            module_id: Union[int, str]
+
+        obj = dict_to_message(WithPrimitiveUnion, {"module_id": 42})
+        self.assertIsInstance(obj.module_id, int)
+        self.assertEqual(obj.module_id, 42)
+
+        obj = dict_to_message(WithPrimitiveUnion, {"module_id": "abc-def"})
+        self.assertIsInstance(obj.module_id, str)
+        self.assertEqual(obj.module_id, "abc-def")
+
+        # Round-trip preserves the correct type.
+        self.verify_round_trip(WithPrimitiveUnion, {"module_id": 7})
+        self.verify_round_trip(WithPrimitiveUnion, {"module_id": "x"})
+
+        # Incompatible data raises immediately with a clear error.
+        with self.assertRaises(TypeError) as ctx:
+            dict_to_message(WithPrimitiveUnion, {"module_id": [1, 2, 3]})
+        self.assertIn("is compatible with", str(ctx.exception))
+
+        # Union of list and dict.
+        @dataclass
+        class WithListOrDict:
+            source_map: Union[List[Tuple[str, str]], Dict[str, str]]
+
+        obj_list = dict_to_message(
+            WithListOrDict, {"source_map": [["a", "b"], ["c", "d"]]}
+        )
+        self.assertIsInstance(obj_list.source_map, list)
+        self.assertEqual(
+            obj_list.source_map, [("a", "b"), ("c", "d")], "expect list of tuples"
+        )
+
+        obj_dict = dict_to_message(WithListOrDict, {"source_map": {"a": "b", "c": "d"}})
+        self.assertIsInstance(obj_dict.source_map, dict)
+        self.assertEqual(obj_dict.source_map, {"a": "b", "c": "d"})
+
+        # Union of two dataclasses.
+        @dataclass
+        class TypeA:
+            x: int
+
+        @dataclass
+        class TypeB:
+            y: str
+
+        @dataclass
+        class WithDataclassUnion:
+            value: Union[TypeA, TypeB]
+
+        obj_a = dict_to_message(WithDataclassUnion, {"value": {"x": 1}})
+        self.assertIsInstance(obj_a.value, TypeA)
+        self.assertEqual(obj_a.value.x, 1)  # type: ignore
+
+        obj_b = dict_to_message(WithDataclassUnion, {"value": {"y": "hello"}})
+        self.assertIsInstance(obj_b.value, TypeB)
+        self.assertEqual(obj_b.value.y, "hello")  # type: ignore
+
+        # When only one Union type matches, the error should contain that type.
+        # In this case only 'RequiresX' will match 999.
+        @dataclass
+        class RequiresX:
+            x: int
+
+        @dataclass
+        class WithSingleCompatible:
+            value: Union[List[int], str, RequiresX]
+
+        with self.assertRaises(TypeError) as ctx:
+            dict_to_message(WithSingleCompatible, {"value": {"z": 999}})
+        self.assertIn("RequiresX", str(ctx.exception))
+
+        # Nested Optional value.
+        @dataclass
+        class WithOptionalUnion:
+            value: Optional[Union[int, str]] = None
+
+        obj_none = dict_to_message(WithOptionalUnion, {})
+        self.assertIsNone(obj_none.value)
+
+        obj_none = dict_to_message(WithOptionalUnion, {"value": None})
+        self.assertIsNone(obj_none.value)
+
+        obj_int = dict_to_message(WithOptionalUnion, {"value": 5})
+        self.assertEqual(obj_int.value, 5)
+
+        obj_str = dict_to_message(WithOptionalUnion, {"value": "hi"})
+        self.assertEqual(obj_str.value, "hi")
+
+        with self.assertRaises(TypeError):
+            # bool not accepted as int, even though it is a base class.
+            dict_to_message(WithOptionalUnion, {"value": True})
+
+    def test_field_alias(self):
+        """Test that renaming in a DAP field serializes and deserializes correctly"""
+
+        @dataclass
+        class AClass:
+            version: str = field(metadata={"alias": "$__lldb_version"})
+            color: Optional[_Color] = field(metadata={"alias": "colour"})
+
+        a_dict = {"$__lldb_version": "20.20.20", "colour": "RED"}
+        dict_obj = dict_to_message(AClass, a_dict)
+        self.assertEqual(dict_obj.version, a_dict["$__lldb_version"])
+        self.assertEqual(dict_obj.color, a_dict["colour"])
+
+        self.assertEqual(a_dict, message_to_dict(dict_obj))
+
+    def test_required_field(self):
+        """Test that fields with required metadata enforce their value on decode."""
+
+        @dataclass
+        class WithRequired:
+            color: str = field(metadata={"required": "RED"})
+            name: str = ""
+
+        # Test present and correct value.
+        obj = dict_to_message(WithRequired, {"color": "RED", "name": "test"})
+        self.assertEqual(obj.color, "RED")
+
+        # Test missing key.
+        with self.assertRaises(TypeError):
+            dict_to_message(WithRequired, {"name": "test"})
+
+        # Test wrong value raises ValueError.
+        with self.assertRaises(ValueError):
+            dict_to_message(WithRequired, {"color": "BLUE", "name": "test"})
+
+        result = message_to_dict(obj)
+        self.assertEqual(result["color"], "RED")
+
+        # Test non-string required values.
+        @dataclass
+        class WithRequiredInt:
+            version: int = field(metadata={"required": 2})
+
+        obj_int = dict_to_message(WithRequiredInt, {"version": 2})
+        self.assertEqual(obj_int.version, 2)
+
+        with self.assertRaises(TypeError):
+            dict_to_message(WithRequiredInt, {})
+
+        with self.assertRaises(ValueError):
+            dict_to_message(WithRequiredInt, {"version": 3})
+
+        # Test 'required' and 'alias' metadata can be combined.
+        @dataclass
+        class WithRequiredAlias:
+            kind: str = field(metadata={"required": "PURPLE", "alias": "type"})
+
+        obj_alias = dict_to_message(WithRequiredAlias, {"type": "PURPLE"})
+        self.assertEqual(obj_alias.kind, "PURPLE")
+
+        with self.assertRaises(ValueError):
+            dict_to_message(WithRequiredAlias, {"type": "response"})
+
+    def test_event_field_constant(self):
+        """Test that event subclasses always have the correct event name set."""
+        stopped_dict = {
+            "body": {
+                "reason": "breakpoint",
+                "threadId": 1,
+                "allThreadsStopped": True,
+            },
+            "event": "stopped",
+            "seq": 10,
+            "type": "event",
+        }
+        event = dict_to_message(StoppedEvent, stopped_dict)
+        self.assertEqual(event.event, EventName.STOPPED)
+
+        # Serialization round-trips back to the correct event name.
+        result = message_to_dict(event)
+        self.assertEqual(result["event"], "stopped")
+
+        # Missing event key.
+        with self.assertRaises(TypeError):
+            without_event = {k: v for k, v in stopped_dict.items() if k != "event"}
+            dict_to_message(StoppedEvent, without_event)
+
+        # Test wrong event type.
+        with self.assertRaises(ValueError):
+            dict_to_message(StoppedEvent, dict(stopped_dict, event="output"))
+
+    def test_dictionary(self):
+        """Tests DAP types with dictionary decodes and encodes correctly"""
+
+        @dataclass
+        class MessageDict:
+            data: Dict[str, Union[str, None]]
+            data_opt: Optional[Dict[str, Optional[str]]] = None
+
+        a_dict = {
+            "data": {"FOO": None, "NO_COLOR": "TRUE", "OTHER": ""},
+            "data_opt": None,
+        }
+        dict_obj = dict_to_message(MessageDict, a_dict)
+        self.assertIsNotNone(dict_obj.data)
+        dict_obj_data = cast(dict, dict_obj.data)
+        self.assertEqual(dict_obj_data["FOO"], a_dict["data"]["FOO"])
+        self.assertEqual(dict_obj_data["NO_COLOR"], a_dict["data"]["NO_COLOR"])
+        self.assertEqual(dict_obj_data["OTHER"], a_dict["data"]["OTHER"])
+        self.assertEqual(dict_obj.data_opt, a_dict["data_opt"])
+
+        # We skip fields that is None. when converting dataclass to dict.
+        without_data_opt = copy.deepcopy(a_dict)
+        without_data_opt.pop("data_opt")
+        self.assertEqual(without_data_opt, message_to_dict(dict_obj))
diff --git a/lldb/test/API/tools/lldb-dap/utils/sample_message.json b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
new file mode 100644
index 0000000000000..2c2a5e3eed534
--- /dev/null
+++ b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
@@ -0,0 +1,38 @@
+[
+    {"body": {"category": "console", "output": "Running preInitCommands:\n"}, "event": "output", "seq": 1, "type": "event"},
+    {"body": {"category": "console", "output": "(lldb) log enable lldb event -T -f dap_event.log\n"}, "event": "output", "seq": 3, "type": "event"},
+    {"body": {"category": "console", "output": "To get started with the debug console try \"<variable>\", \"<lldb-cmd>\" or \"help [<lldb-cmd>]\"\r\n"}, "event": "output", "seq": 4, "type": "event"},
+    {"body": {"category": "console", "output": "For more information visit https://lldb.llvm.org/use/lldbdap.html#debug-console.\r\n"}, "event": "output", "seq": 5, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4e6e000", "debugInfoSize": "563.1KB", "id": "A55BBBD8-5660-8820-2DBB-60DA08A95C6B-7697E8EE", "name": "ld-linux-x86-64.so.2", "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", "symbolFilePath": "/usr/lib/debug/.build-id/a5/5bbbd8566088202dbb60da08a95c6b7697e8ee.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 6, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4e6c000", "id": "E500AEFD-F104-2A72-8C7D-8390BFD2A311-02F8A183", "name": "[vdso]", "path": "[vdso]", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 7, "type": "event"},
+    {"body": {"module": {"addressRange": "0x5fd98276c000", "debugInfoSize": "1.1KB", "id": "2C18BEA2-9D64-2123-5FBA-0344D06183D9-E7DCA074", "name": "a.out", "path": "/home/buildbot/workspace/a.out", "symbolFilePath": "/home/buildbot/workspace/a.out", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 8, "type": "event"},
+    {"body": {}, "event": "initialized", "seq": 9, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 11, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 12, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 13, "type": "event"},
+    {"body": {"capabilities": {"supportsModuleSymbolsRequest": false, "supportsRestartRequest": false, "supportsStepInTargetsRequest": true}}, "event": "capabilities", "seq": 14, "type": "event"},
+    {"body": {"category": "console", "output": "Executable binary set to '/home/buildbot/workspace/a.out' (x86_64-unknown-linux-gnu).\r\n"}, "event": "output", "seq": 15, "type": "event"},
+    {"body": {"category": "console", "output": "Attached to process 2961472.\r\n"}, "event": "output", "seq": 16, "type": "event"},
+    {"body": {"isLocalProcess": true, "name": "/home/buildbot/workspace/a.out", "pointerSize": 64, "startMethod": "launch", "systemProcessId": 2961472}, "event": "process", "seq": 17, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4d3e000", "debugInfoSize": "936.7KB", "id": "0558EEC1-E2C0-3F68-8CF3-83E73579DEF7-863A61D0", "name": "libm.so.6", "path": "/lib/x86_64-linux-gnu/libm.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/05/58eec1e2c03f688cf383e73579def7863a61d0.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 21, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4600000", "debugInfoSize": "3.6MB", "id": "A5B27DB7-EF30-36C1-DACF-2E4DDC2E0527-67129439", "name": "libc.so.6", "path": "/lib/x86_64-linux-gnu/libc.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/a5/b27db7ef3036c1dacf2e4ddc2e052767129439.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 22, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4a00000", "id": "280F658F-43AA-E7A7-F92B-BA6EC4C5D96B-C2B53A7A", "name": "libstdc++.so.6", "path": "/lib/x86_64-linux-gnu/libstdc++.so.6", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 23, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4d11000", "id": "183187C1-CD58-CFF0-9C2A-25539EEB6AE6-14C67F19", "name": "libgcc_s.so.1", "path": "/lib/x86_64-linux-gnu/libgcc_s.so.1", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 24, "type": "event"},
+    {"body": {"allThreadsStopped": true, "description": "breakpoint 1.1", "hitBreakpointIds": [1], "reason": "breakpoint", "text": "breakpoint 1.1", "threadId": 2961472}, "event": "stopped", "seq": 25, "type": "event"},
+    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 27, "type": "event"},
+    {"body": {"category": "stdout", "output": "before dlopen\r\n"}, "event": "output", "seq": 28, "type": "event"},
+    {"body": {"module": {"addressRange": "0x7e4ec4e5f000", "debugInfoSize": "782B", "id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": "libother.so", "path": "/home/buildbot/workspace/libother.so", "symbolFilePath": "/home/buildbot/workspace/libother.so", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 29, "type": "event"},
+    {"body": {"allThreadsStopped": true, "description": "breakpoint 2.1", "hitBreakpointIds": [2], "reason": "breakpoint", "text": "breakpoint 2.1", "threadId": 2961472}, "event": "stopped", "seq": 30, "type": "event"},
+    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 32, "type": "event"},
+    {"body": {"category": "stdout", "output": "before dlclose\r\n"}, "event": "output", "seq": 33, "type": "event"},
+    {"body": {"module": {"id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": ""}, "reason": "removed"}, "event": "module", "seq": 34, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 35, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 36, "type": "event"},
+    {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 37, "type": "event"},
+    {"body": {"allThreadsStopped": true, "description": "breakpoint 3.1", "hitBreakpointIds": [3], "reason": "breakpoint", "text": "breakpoint 3.1", "threadId": 2961472}, "event": "stopped", "seq": 41, "type": "event"},
+    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 43, "type": "event"},
+    {"body": {"category": "stdout", "output": "GOT TOKEN 3200"}, "event": "output", "seq": 44, "type": "event"},
+    {"body": {"category": "console", "output": "Process 2961472 exited with status = 0 (0x00000000) \n"}, "event": "output", "seq": 45, "type": "event"},
+    {"body": {}, "event": "terminated", "seq": 46, "type": "event"},
+    {"body": {"exitCode": 0}, "event": "exited", "seq": 47, "type": "event"}
+]
\ No newline at end of file

>From 41e76232efe8e8253db0553bbe46c459514bb5a0 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Mon, 15 Jun 2026 20:42:56 +0100
Subject: [PATCH 2/8] Add format changes

---
 .../lldbsuite/test/tools/lldb_dap/dap_types.py       |  7 +++++--
 .../Python/lldbsuite/test/tools/lldb_dap/utils.py    | 12 ++++++++----
 2 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index 5a3460f7fa226..a1c0da50c25fd 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -546,10 +546,12 @@ class ArgsProtocol(Protocol[T]):  # type: ignore[misc]
     __dataclass_fields__: ClassVar[Dict]
 
     @property
-    def response_class_(self) -> Type[T]: ...
+    def response_class_(self) -> Type[T]:
+        ...
 
     @property
-    def command_(self) -> str: ...
+    def command_(self) -> str:
+        ...
 
 
 class Console(StrEnum):
@@ -1646,6 +1648,7 @@ class EvaluateArgs:
     command_ = "evaluate"
     response_class_ = EvaluateResponse
 
+
 @dataclass(frozen=True)
 class StepInTargetsResponse(Response):
     @dataclass(frozen=True)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index 9ef04b0c5ca90..ca0cc8e13f441 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -36,7 +36,8 @@ def __call__(
         extra_env: list[str] | None = None,
         install_remote: bool = True,
         **kwargs,
-    ) -> subprocess.Popen[bytes]: ...
+    ) -> subprocess.Popen[bytes]:
+        ...
 
 
 @dataclass(frozen=True)
@@ -523,11 +524,14 @@ class Transport(Protocol):
             adapter is already running and exposes connection URI.
     """
 
-    def write(self, data: bytes): ...
+    def write(self, data: bytes):
+        ...
 
-    def read(self, n: int) -> bytes: ...
+    def read(self, n: int) -> bytes:
+        ...
 
-    def readline(self) -> bytes: ...
+    def readline(self) -> bytes:
+        ...
 
     def close(self):
         """Close the transport.

>From e2dd75b077a913133d22ba166a1af92436f32b70 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Tue, 16 Jun 2026 13:46:24 +0100
Subject: [PATCH 3/8] Leave only the launch and DAP_types test.

---
 .../TestDAP_invalidatedEvent.py               |  67 ++---
 .../utils/TestDAPUtils_DAPConnection.py       |  82 ------
 .../utils/TestDAPUtils_EventHistory.py        | 252 ------------------
 .../tools/lldb-dap/utils/sample_message.json  |  38 ---
 4 files changed, 28 insertions(+), 411 deletions(-)
 delete mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
 delete mode 100644 lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
 delete mode 100644 lldb/test/API/tools/lldb-dap/utils/sample_message.json

diff --git a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
index b13b1d44b6d43..8ba56b0bb27ca 100644
--- a/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
+++ b/lldb/test/API/tools/lldb-dap/invalidated-event/TestDAP_invalidatedEvent.py
@@ -4,23 +4,17 @@
 know about it.
 """
 
+import lldbdap_testcase
 from lldbsuite.test.lldbtest import line_number
-from lldbsuite.test.tools.lldb_dap.dap_types import LaunchArgs, StackTraceArgs
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.session_helpers import DAPTestSession
+from dap_server import Event
 
 
-class TestDAP_invalidatedEvent(DAPTestCaseBase):
-    def verify_top_frame_name(
-        self, session: DAPTestSession, frame_name: str, thread_id: int
-    ):
-        response = session.stack_trace(thread_id)
-        all_frames = response.body.stackFrames
-
+class TestDAP_invalidatedEvent(lldbdap_testcase.DAPTestCaseBase):
+    def verify_top_frame_name(self, frame_name: str):
+        all_frames = self.get_stackFrames()
         self.assertGreaterEqual(len(all_frames), 1, "Expected at least one frame.")
-        top_frame_name = all_frames[0].name
+        top_frame_name = all_frames[0]["name"]
         self.assertRegex(top_frame_name, f"{frame_name}.*")
-        return response
 
     def test_invalidated_stack_area_event(self):
         """
@@ -28,39 +22,34 @@ def test_invalidated_stack_area_event(self):
         The event is sent when the command `thread return <expr>` is sent by the user.
         """
         other_source = "other.h"
-        program = self.getBuildArtifact("a.out")
-        session = self.build_and_create_session()
-        with session.configure(LaunchArgs(program)) as ctx:
-            return_bp_line = line_number(other_source, "// thread return breakpoint")
-            session.resolve_source_breakpoints(other_source, [return_bp_line])
+        return_bp_line = line_number(other_source, "// thread return breakpoint")
 
-        stopped_event = session.verify_stopped_on_breakpoint(after=ctx.process_event)
+        program = self.getBuildArtifact("a.out")
+        self.build_and_launch(program)
+        self.set_source_breakpoints(other_source, [return_bp_line])
+        self.continue_to_next_stop()
 
-        thread_id = self.expect_not_none(
-            stopped_event.body.threadId, "expected a thread id."
-        )
-        stack_response = self.verify_top_frame_name(session, "add", thread_id)
+        self.verify_top_frame_name("add")
+        thread_id = self.dap_server.get_thread_id()
+        self.assertIsNotNone(thread_id, "Exepected a thread id.")
 
-        # Run thread return.
+        # run thread return
         thread_command = "thread return 20"
-        session.evaluate(thread_command, context="repl")
-
-        # Wait for the invalidated stack event.
-        invalid_event = session.wait_for_invalidated(after=stack_response)
-        self.assertIsNotNone(invalid_event, "Expected an invalidated event.")
-        event_body = invalid_event.body
-        self.assertIsNotNone(event_body.areas)
-        self.assertIn("stacks", event_body.areas or [])
-        self.assertIsNotNone(event_body.threadId)
+        eval_resp = self.dap_server.request_evaluate(thread_command, context="repl")
+        self.assertTrue(eval_resp["success"], f"Failed to evaluate `{thread_command}`.")
+
+        # wait for the invalidated stack event.
+        stack_event = self.dap_server.wait_for_event(["invalidated"])
+        self.assertIsNotNone(stack_event, "Expected an invalidated event.")
+        event_body: Event = stack_event["body"]
+        self.assertIn("stacks", event_body["areas"])
+        self.assertIn("threadId", event_body.keys())
         self.assertEqual(
             thread_id,
-            event_body.threadId,
+            event_body["threadId"],
             f"Expected the event from thread {thread_id}.",
         )
 
-        # Confirm we are back at the main frame.
-        thread_id = self.expect_not_none(
-            invalid_event.body.threadId, "expected a thread id."
-        )
-        self.verify_top_frame_name(session, "main", thread_id)
-        session.continue_to_exit()
+        # confirm we are back at the main frame.
+        self.verify_top_frame_name("main")
+        self.continue_to_exit()
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
deleted file mode 100644
index 9af9e553c0446..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_DAPConnection.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import io
-import json
-from typing import List
-
-from lldbsuite.test.tools.lldb_dap.dap_types import RawMessage
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.utils import DAPConnection, MessageHandler, Transport
-
-
-class EchoClient:
-    def __init__(self):
-        self.seen_messages: List[RawMessage] = []
-
-    def on_message(self, msg: RawMessage):
-        self.seen_messages.append(msg)
-
-
-class TestDAPUtils_DAPConnection(DAPTestCaseBase):
-    def test_round_trip(self):
-        received_messages = self.get_sample_message()
-        transport = self.create_transport(received_messages)
-        connection = DAPConnection("conn0", transport)
-
-        client = EchoClient()
-        handler = MessageHandler(
-            on_event=client.on_message,
-            on_response=client.on_message,
-            on_reverse_request=client.on_message,
-        )
-        connection.start(handler)
-        expected_messages = client.seen_messages
-        self.assertEqual(len(received_messages), len(expected_messages))
-        for actual, expected in zip(received_messages, expected_messages):
-            self.assertEqual(actual, expected)
-
-    def test_encode_message_framing(self):
-        payload = {"type": "request", "seq": 1, "command": "initialize"}
-        data = DAPConnection.encode_message(payload)
-        header, _, body = data.partition(b"\r\n\r\n")
-        self.assertTrue(header.startswith(b"Content-Length:"))
-        content_length = int(header.split(b":")[1].strip())
-        self.assertEqual(content_length, len(body))
-        self.assertEqual(json.loads(body), payload)
-
-    def test_encode_message_round_trips(self):
-        payload = {"nested": {"a": 1, "b": [1, 2, 3]}}
-        _, _, body = DAPConnection.encode_message(payload).partition(b"\r\n\r\n")
-        self.assertEqual(json.loads(body), payload)
-
-    @staticmethod
-    def create_transport(data: List[RawMessage]) -> Transport:
-        class BinaryTransport:
-            def __init__(self, data: List[RawMessage]):
-                encoded_data = [
-                    DAPConnection.encode_message(message) for message in data
-                ]
-                self._in = io.BytesIO(b"".join(encoded_data))
-                self._out: List[str] = []
-
-            def write(self, data: bytes):
-                self._out.append(data.decode("utf-8"))
-
-            def readline(self):
-                return self._in.readline()
-
-            def read(self, n: int) -> bytes:
-                return self._in.read(n)
-
-            def close(self):
-                self._in.close()
-
-            @property
-            def is_alive(self) -> bool:
-                return not self._in.closed
-
-        return BinaryTransport(data)
-
-    def get_sample_message(self) -> List[dict]:
-        message_log = self.getSourcePath("sample_message.json")
-        with open(message_log, "r") as file:
-            raw_events = json.loads(file.read())
-        return raw_events
diff --git a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py b/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
deleted file mode 100644
index a5e69cf15dc83..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/TestDAPUtils_EventHistory.py
+++ /dev/null
@@ -1,252 +0,0 @@
-import json
-import threading
-from typing import List, Union
-
-from lldbsuite.test.tools.lldb_dap.dap_types import (
-    CapabilitiesEvent,
-    DAPError,
-    Event,
-    ExitedEvent,
-    InitializedEvent,
-    ModuleEvent,
-    OutputEvent,
-    ProcessEvent,
-    ProgressEndEvent,
-    TerminatedEvent,
-)
-from lldbsuite.test.tools.lldb_dap.lldb_dap_testcase import DAPTestCaseBase
-from lldbsuite.test.tools.lldb_dap.utils import EventHistory
-
-
-class TestDAPUtils_EventHistory(DAPTestCaseBase):
-    """
-    This test suite verifies the behavior of EventHistory, including event ordering validation,
-    querying specific events, handling closed histories, applying custom filtering conditions,
-    and managing timeouts during asynchronous event additions.
-    """
-
-    def test_history_order(self):
-        """Test that events added to EventHistory are in sequential order"""
-
-        history = EventHistory(timeout=10)
-        events = [
-            {
-                "body": {"category": "console", "output": "Running preInitCommands:\n"},
-                "event": "output",
-                "seq": 2,
-                "type": "event",
-            },
-            {
-                "body": {
-                    "category": "console",
-                    "output": "(lldb) log enable lldb process",
-                },
-                "event": "output",
-                "seq": 1,
-                "type": "event",
-            },
-        ]
-        events = [Event.from_json(event) for event in events]
-
-        with self.assertRaises(AssertionError):
-            for event in events:
-                history.record(event)
-
-    def test_wait_for_X_event_on_history_closed(self):
-        """Tests `wait_for_first_event`, `wait_for_event` and `wait_for_any_event` when the history is closed.
-
-        It should return a DAPError (HistoryClosed) instead of waiting for new events if the
-        history is closed and the requested event does not already exist in the history.
-        """
-        history = EventHistory(timeout=10)
-        self.populate_history(history)
-
-        # We only intend on working on existing events.
-        # so we don't get a Timeout error.
-        history.close()
-
-        init_event = history.wait_for_earliest_event(InitializedEvent)
-        self.assertEqual(init_event.seq, 9)
-        self.assertIsInstance(init_event, InitializedEvent)
-        self.assertEqual(init_event.type, "event")
-
-        cap_event = history.wait_for_event(CapabilitiesEvent, after=init_event)
-        self.assertGreater(cap_event.seq, init_event.seq)
-        self.assertIsInstance(cap_event, CapabilitiesEvent)
-
-        # There is only one capabilitiesEvent in the history.
-        with self.assertRaises(DAPError):
-            history.wait_for_event(CapabilitiesEvent, after=cap_event)
-
-        # There is no progressEndEvent in history.
-        with self.assertRaises(DAPError):
-            history.wait_for_earliest_event(ProgressEndEvent)
-
-        # Must wait for at least one event.
-        with self.assertRaises(AssertionError):
-            history.wait_for_any_event((), after=cap_event)
-
-        # Test wait_for_any_event.
-        # Wait for_any_event should return the first seen event that in in the tuple
-        any_event = history.wait_for_any_event(
-            (ProcessEvent, OutputEvent, ModuleEvent, TerminatedEvent), after=cap_event
-        )
-        # We should see the output event first.
-        self.assertIsInstance(any_event, OutputEvent)
-
-        any_event = history.wait_for_any_event(
-            (ModuleEvent, TerminatedEvent), after=cap_event
-        )
-
-        # We should see the ModuleEvent event first.
-        self.assertIsInstance(any_event, ModuleEvent)
-
-    def test_wait_for_X_event_until_Y_condition(self):
-        """
-        Tests the `until` parameter in wait_for_XXXX methods.
-
-        Verifies that `wait_for_event` and `wait_for_any_event` continue scanning
-        through matching event types until the provided condition function returns True.
-        or throws an error.
-        """
-        history = EventHistory(timeout=10)
-        self.populate_history(history)
-        history.close()
-
-        start_event = history.wait_for_earliest_event(OutputEvent)
-        self.assertEqual(start_event.seq, 1)
-        self.assertIsInstance(start_event, OutputEvent)
-        self.assertEqual(start_event.body.output, "Running preInitCommands:\n")
-
-        # Test wait_for_event_until success.
-        def has_got_token(event: OutputEvent):
-            return "GOT TOKEN" in event.body.output
-
-        output_event = history.wait_for_event(
-            OutputEvent, after=start_event, until=has_got_token
-        )
-        self.assertEqual(output_event.seq, 44)
-        self.assertIsInstance(output_event, OutputEvent)
-        self.assertIn("GOT TOKEN", output_event.body.output)
-
-        def sdl3_module_loaded(event: ModuleEvent):
-            module_is_sdl = event.body.module.name == "sdl3"
-            is_new_module = event.body.reason == "new"
-
-            return module_is_sdl and is_new_module
-
-        # Test wait_for_event_until failure.
-        # There is no sdl3 module in the event history.
-        with self.assertRaises(DAPError):
-            history.wait_for_event(
-                ModuleEvent, after=start_event, until=sdl3_module_loaded
-            )
-
-        # Test wait_for_any_event.
-        def sdl3_module_loaded_or_process_exited(
-            event: Union[ModuleEvent, ExitedEvent],
-        ):
-            if isinstance(event, ModuleEvent):
-                return sdl3_module_loaded(event)
-
-            return True
-
-        seen_event = history.wait_for_any_event(
-            (ModuleEvent, ExitedEvent),
-            after=start_event,
-            until=sdl3_module_loaded_or_process_exited,
-        )
-
-        self.assertEqual(seen_event.seq, 47)
-        self.assertIsInstance(seen_event, ExitedEvent)
-        self.assertEqual(seen_event.body.exitCode, 0)  # type: ignore
-
-    def test_wait_for_X_event_with_timeout(self):
-        """Tests the timeout functionality when waiting for events.
-
-        Verifies that a TimeoutError is raised if the expected event does not
-        arrive within the specified timeout. Also ensures that the wait methods
-        successfully block and retrieve events added asynchronously by another thread
-        before the timeout expires.
-        """
-        history = EventHistory(timeout=10)
-        self.populate_history(history)
-
-        delayed_append = threading.Event()
-        delayed_append.clear()
-
-        new_cap_events = [
-            Event.from_json(
-                {
-                    "body": {"capabilities": {"supportsModulesRequest": True}},
-                    "event": "capabilities",
-                    "seq": 50,
-                    "type": "event",
-                }
-            ),
-            Event.from_json(
-                {
-                    "body": {"capabilities": {"supportsRestartRequest": True}},
-                    "event": "capabilities",
-                    "seq": 52,
-                    "type": "event",
-                }
-            ),
-        ]
-
-        def add_delayed_events(events: List[Event]):
-            delayed_append.wait()
-            for event in events:
-                history.record(event)
-
-        event_thread = threading.Thread(
-            target=add_delayed_events, args=[new_cap_events]
-        )
-        event_thread.start()
-
-        try:
-            exited_event = history.wait_for_earliest_event(ExitedEvent)
-            self.assertEqual(exited_event.seq, 47)
-            self.assertEqual(exited_event.body.exitCode, 0)
-
-            def supports_restart_request(event: CapabilitiesEvent):
-                return event.body.capabilities.supportsRestartRequest == True
-
-            # No new CapabilitiesEvents have been added after exited event, so this should time out.
-            with self.assertRaises(TimeoutError):
-                history.wait_for_event(
-                    CapabilitiesEvent,
-                    after=exited_event,
-                    until=supports_restart_request,
-                    timeout=0.05,
-                )
-
-            # Unblock and Sync with the event thread.
-            delayed_append.set()
-            seen_event = history.wait_for_event(
-                CapabilitiesEvent,
-                after=exited_event,
-                until=supports_restart_request,
-                timeout=10,
-            )
-
-            # The supportsModuleRequest CapabilitiesEvent should be ignored.
-            self.assertEqual(seen_event.seq, 52)
-            self.assertIsInstance(seen_event, CapabilitiesEvent)
-            self.assertTrue(seen_event.body.capabilities.supportsRestartRequest)
-        finally:
-            # Clean up
-            delayed_append.set()
-            event_thread.join()
-
-    def generate_events(self) -> List[Event]:
-        """Generates the default events that is used in tests."""
-        message_log = self.getSourcePath("sample_message.json")
-        with open(message_log, "r") as file:
-            raw_events = json.loads(file.read())
-        events = [Event.from_json(raw_event) for raw_event in raw_events]
-        return events
-
-    def populate_history(self, history: EventHistory):
-        for event in self.generate_events():
-            history.record(event)
diff --git a/lldb/test/API/tools/lldb-dap/utils/sample_message.json b/lldb/test/API/tools/lldb-dap/utils/sample_message.json
deleted file mode 100644
index 2c2a5e3eed534..0000000000000
--- a/lldb/test/API/tools/lldb-dap/utils/sample_message.json
+++ /dev/null
@@ -1,38 +0,0 @@
-[
-    {"body": {"category": "console", "output": "Running preInitCommands:\n"}, "event": "output", "seq": 1, "type": "event"},
-    {"body": {"category": "console", "output": "(lldb) log enable lldb event -T -f dap_event.log\n"}, "event": "output", "seq": 3, "type": "event"},
-    {"body": {"category": "console", "output": "To get started with the debug console try \"<variable>\", \"<lldb-cmd>\" or \"help [<lldb-cmd>]\"\r\n"}, "event": "output", "seq": 4, "type": "event"},
-    {"body": {"category": "console", "output": "For more information visit https://lldb.llvm.org/use/lldbdap.html#debug-console.\r\n"}, "event": "output", "seq": 5, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4e6e000", "debugInfoSize": "563.1KB", "id": "A55BBBD8-5660-8820-2DBB-60DA08A95C6B-7697E8EE", "name": "ld-linux-x86-64.so.2", "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", "symbolFilePath": "/usr/lib/debug/.build-id/a5/5bbbd8566088202dbb60da08a95c6b7697e8ee.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 6, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4e6c000", "id": "E500AEFD-F104-2A72-8C7D-8390BFD2A311-02F8A183", "name": "[vdso]", "path": "[vdso]", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 7, "type": "event"},
-    {"body": {"module": {"addressRange": "0x5fd98276c000", "debugInfoSize": "1.1KB", "id": "2C18BEA2-9D64-2123-5FBA-0344D06183D9-E7DCA074", "name": "a.out", "path": "/home/buildbot/workspace/a.out", "symbolFilePath": "/home/buildbot/workspace/a.out", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 8, "type": "event"},
-    {"body": {}, "event": "initialized", "seq": 9, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 11, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 12, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 13, "type": "event"},
-    {"body": {"capabilities": {"supportsModuleSymbolsRequest": false, "supportsRestartRequest": false, "supportsStepInTargetsRequest": true}}, "event": "capabilities", "seq": 14, "type": "event"},
-    {"body": {"category": "console", "output": "Executable binary set to '/home/buildbot/workspace/a.out' (x86_64-unknown-linux-gnu).\r\n"}, "event": "output", "seq": 15, "type": "event"},
-    {"body": {"category": "console", "output": "Attached to process 2961472.\r\n"}, "event": "output", "seq": 16, "type": "event"},
-    {"body": {"isLocalProcess": true, "name": "/home/buildbot/workspace/a.out", "pointerSize": 64, "startMethod": "launch", "systemProcessId": 2961472}, "event": "process", "seq": 17, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4d3e000", "debugInfoSize": "936.7KB", "id": "0558EEC1-E2C0-3F68-8CF3-83E73579DEF7-863A61D0", "name": "libm.so.6", "path": "/lib/x86_64-linux-gnu/libm.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/05/58eec1e2c03f688cf383e73579def7863a61d0.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 21, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4600000", "debugInfoSize": "3.6MB", "id": "A5B27DB7-EF30-36C1-DACF-2E4DDC2E0527-67129439", "name": "libc.so.6", "path": "/lib/x86_64-linux-gnu/libc.so.6", "symbolFilePath": "/usr/lib/debug/.build-id/a5/b27db7ef3036c1dacf2e4ddc2e052767129439.debug", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 22, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4a00000", "id": "280F658F-43AA-E7A7-F92B-BA6EC4C5D96B-C2B53A7A", "name": "libstdc++.so.6", "path": "/lib/x86_64-linux-gnu/libstdc++.so.6", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 23, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4d11000", "id": "183187C1-CD58-CFF0-9C2A-25539EEB6AE6-14C67F19", "name": "libgcc_s.so.1", "path": "/lib/x86_64-linux-gnu/libgcc_s.so.1", "symbolStatus": "Symbols not found."}, "reason": "new"}, "event": "module", "seq": 24, "type": "event"},
-    {"body": {"allThreadsStopped": true, "description": "breakpoint 1.1", "hitBreakpointIds": [1], "reason": "breakpoint", "text": "breakpoint 1.1", "threadId": 2961472}, "event": "stopped", "seq": 25, "type": "event"},
-    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 27, "type": "event"},
-    {"body": {"category": "stdout", "output": "before dlopen\r\n"}, "event": "output", "seq": 28, "type": "event"},
-    {"body": {"module": {"addressRange": "0x7e4ec4e5f000", "debugInfoSize": "782B", "id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": "libother.so", "path": "/home/buildbot/workspace/libother.so", "symbolFilePath": "/home/buildbot/workspace/libother.so", "symbolStatus": "Symbols loaded."}, "reason": "new"}, "event": "module", "seq": 29, "type": "event"},
-    {"body": {"allThreadsStopped": true, "description": "breakpoint 2.1", "hitBreakpointIds": [2], "reason": "breakpoint", "text": "breakpoint 2.1", "threadId": 2961472}, "event": "stopped", "seq": 30, "type": "event"},
-    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 32, "type": "event"},
-    {"body": {"category": "stdout", "output": "before dlclose\r\n"}, "event": "output", "seq": 33, "type": "event"},
-    {"body": {"module": {"id": "F71ED270-47EF-5421-FD13-E6B6FAADED6B-3BC4D52D", "name": ""}, "reason": "removed"}, "event": "module", "seq": 34, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 1, "instructionReference": "0x5FD98276D191", "line": 13, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 35, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 2, "instructionReference": "0x5FD98276D1CD", "line": 18, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 36, "type": "event"},
-    {"body": {"breakpoint": {"column": 3, "id": 3, "instructionReference": "0x5FD98276D1E4", "line": 20, "verified": true}, "reason": "changed"}, "event": "breakpoint", "seq": 37, "type": "event"},
-    {"body": {"allThreadsStopped": true, "description": "breakpoint 3.1", "hitBreakpointIds": [3], "reason": "breakpoint", "text": "breakpoint 3.1", "threadId": 2961472}, "event": "stopped", "seq": 41, "type": "event"},
-    {"body": {"allThreadsContinued": true, "threadId": 2961472}, "event": "continued", "seq": 43, "type": "event"},
-    {"body": {"category": "stdout", "output": "GOT TOKEN 3200"}, "event": "output", "seq": 44, "type": "event"},
-    {"body": {"category": "console", "output": "Process 2961472 exited with status = 0 (0x00000000) \n"}, "event": "output", "seq": 45, "type": "event"},
-    {"body": {}, "event": "terminated", "seq": 46, "type": "event"},
-    {"body": {"exitCode": 0}, "event": "exited", "seq": 47, "type": "event"}
-]
\ No newline at end of file

>From be162909f54ca327ad6e811a5ea11e8ff4291618 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Tue, 16 Jun 2026 13:59:55 +0100
Subject: [PATCH 4/8] update the failed request message detail.

---
 lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index 22063815b111a..d8505b4a17c6b 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -110,7 +110,7 @@ def error(self, msg: Optional[str] = None) -> ErrorResponse:
 
         if isinstance(response, ErrorResponse):
             return response
-        detail = f"expected '{self.response_class.__name__}' got {response}."
+        detail = f"expected 'ErrorResponse' got {response}."
         raise DAPError(f"{msg}\n\t{detail}" if msg else detail)
 
     def result_or_error(self) -> AnyResponse | ErrorResponse:

>From 24a31dd9e22029cbc8dac8b578aa7438166dc82a Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Wed, 17 Jun 2026 14:05:08 +0100
Subject: [PATCH 5/8] support `disconnect_automatically` in server_mode.

---
 .../test/tools/lldb_dap/lldb_dap_testcase.py  | 26 +++++--------------
 .../lldbsuite/test/tools/lldb_dap/session.py  |  2 +-
 2 files changed, 8 insertions(+), 20 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
index b2f56b81c2cd2..2b2412bcea072 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/lldb_dap_testcase.py
@@ -1,10 +1,9 @@
-import gc
 import logging
 import os
 from pathlib import Path
 from typing import Any, Final, Optional, TypeVar, Union, cast
 
-from lldbsuite.test.lldbtest import Base, is_exe
+from lldbsuite.test.lldbtest import Base, LLDBTestCaseFactory, is_exe
 
 from .dap_types import AnyResponse, ErrorResponse, Response
 from .session_helpers import DAPTestSession
@@ -23,7 +22,7 @@ def strtobool(val: str) -> bool:
 T = TypeVar("T")
 
 
-class DAPTestCaseBase(Base):
+class DAPTestCaseBase(Base, metaclass=LLDBTestCaseFactory):
     """Base test case for DAP tests"""
 
     NO_DEBUG_INFO_TESTCASE = True
@@ -73,10 +72,6 @@ def close_log():
 
         self.addTearDownHook(close_log)
 
-    def tearDown(self):
-        gc.collect()
-        super().tearDown()
-
     def __create_default_debug_adapter(self):
         self.assertFalse(hasattr(self, "adapter"), "A default adapter already exists.")
 
@@ -94,19 +89,13 @@ def __create_default_debug_adapter(self):
     def create_session(
         self,
         adapter: Optional[DebugAdapter] = None,
-        disconnect_automatically: Optional[bool] = None,
+        disconnect_automatically: bool = True,
     ) -> DAPTestSession:
         if adapter is None:
             self.assertIsNotNone(self.adapter, "expected we already have an adapter.")
             adapter = self.adapter
         self.assertTrue(adapter.is_alive, "expected adapter process is alive.")
 
-        if adapter.is_server and disconnect_automatically is not None:
-            self.assertFalse(
-                disconnect_automatically,
-                "disconnect_automatically is not supported for lldb-dap running as a server",
-            )
-
         build_dir = Path(self.getBuildDir())
         session = DAPTestSession(
             self,
@@ -118,20 +107,19 @@ def create_session(
         )
 
         def cleanup_session():
-            # In server mode the adapter automatically shuts down after the last
-            # client disconnects.
-            if not adapter.is_server and disconnect_automatically is not False:
+            if disconnect_automatically:
+                self.logger.debug("Automatically disconnecting.")
                 session.disconnect(terminateDebuggee=True)
             session.stop()
 
-        self.addTearDownHook(cleanup_session)
         session.start()
+        self.addTearDownHook(cleanup_session)
         return session
 
     def build_and_create_session(
         self,
         adapter: Optional[DebugAdapter] = None,
-        disconnect_automatically: Optional[bool] = None,
+        disconnect_automatically: bool = True,
     ) -> DAPTestSession:
         self.build()
         return self.create_session(adapter, disconnect_automatically)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index d8505b4a17c6b..f1de7faadfd55 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -147,7 +147,7 @@ def wrapper(self, *args: Any, **kwargs: Any) -> R:
 class _DAPSessionState:
     def __init__(self):
         self._lock = threading.RLock()
-        self._initialized = False
+        self._initialized: bool = False
         self._capabilities = Capabilities()
         self.output_streams = {
             OutputCategory.STDOUT: io.StringIO(),

>From 54011ca3afb0b7c2c97e2a1cacfad1c6634bc20d Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Thu, 18 Jun 2026 12:43:41 +0100
Subject: [PATCH 6/8] add review changes

---
 .../test/tools/lldb_dap/dap_types.py          | 19 +++++++++++--------
 .../lldbsuite/test/tools/lldb_dap/session.py  |  2 +-
 .../test/tools/lldb_dap/session_helpers.py    | 13 ++++++++-----
 3 files changed, 20 insertions(+), 14 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index a1c0da50c25fd..5c0a369d445ce 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -30,6 +30,7 @@
     Type,
     TypeVar,
     Union,
+    cast,
     runtime_checkable,
 )
 
@@ -188,31 +189,32 @@ def __init_subclass__(cls, *, event: str, **kwargs):
 
     def __post_init__(self):
         if self.type != MessageType.EVENT:
-            raise ValueError(f"event must have type of 'event': {self}")
+            raise ValueError(f"event must have type of 'event': {self}.")
 
     @classmethod
-    def from_json(cls: Type[T], json: dict, check_class: bool = True) -> T:
+    def from_json(cls: Type[T], json: dict) -> T:
         event_type = json["event"]
 
         if event_type not in Event.__registry:
-            raise ValueError(f"event type '{event_type}' is not registered")
+            raise ValueError(f"event type '{event_type}' is not registered.")
 
         event_class = Event.__registry[event_type]
 
-        if check_class and type(cls) != type(event_class):
+        if cls not in (Event, event_class):
             raise ValueError(
-                f"class: {type(cls)} is not of the expected type {type(event_class)}"
+                f"class: {cls.__name__!r} is not of the expected type {event_class.__name__!r}."
             )
 
-        if not dataclasses.is_dataclass(cls):
-            raise ValueError(f"{cls.__name__} must be a dataclass")
+        if not dataclasses.is_dataclass(event_class):
+            raise ValueError(f"{cls.__name__!r} must be a dataclass.")
 
-        return dict_to_message(event_class, json)
+        return cast(T, dict_to_message(event_class, json))
 
 
 AnyEvent = TypeVar("AnyEvent", bound=Event)
 
 
+ at dataclass(frozen=True)
 class EmptyBodyResponse(Response):
     body: None = field(init=False, default=None)
 
@@ -1669,6 +1671,7 @@ class StepInTargetsArgs:
 
 @dataclass(frozen=True)
 class GotoTargetsResponse(Response):
+    @dataclass(frozen=True)
     class Body:
         targets: List[GotoTarget]
 
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
index f1de7faadfd55..a51d47e8b5c30 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session.py
@@ -118,7 +118,7 @@ def result_or_error(self) -> AnyResponse | ErrorResponse:
             raw = self._future.result(timeout=self._timeout)
         except (TimeoutError, futures.TimeoutError) as e:
             msg = f"\n\tRequest '{self._command}' (seq={self.seq}) timed out after {self._timeout}s"
-            e.args = (f"{e.args[0]}{msg}", *e.args)
+            e.args = (f"{e.args[0]}{msg}", *e.args) if e.args else (msg,)
             raise
         except ConnectionError as e:
             raise DAPError(
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index 5c36d5dbed2aa..da1e6c9c814db 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -1668,12 +1668,15 @@ def write_memory(
         `value` is Base64-encoded because the DAP protocol requires it.
         """
         if isinstance(value, int):
-            # (bit_length + 7 (rounding up to nearest byte)) // 8 = converts to bytes.
-            byte_length = (value.bit_length() + 7) // 8
-            val_bytes = value.to_bytes(byte_length, "little")
-            data = base64.b64encode(val_bytes).decode()
+            # The minimum bytes needed to represent 'value'.
+            byte_length = max(1, (value.bit_length() + 7) // 8)
+            is_negative = value < 0
+            val_bytes = value.to_bytes(byte_length, "little", signed=is_negative)
+        elif isinstance(value, str):
+            val_bytes = value.encode()
         else:
-            data = base64.b64encode(str(value).encode()).decode()
+            val_bytes = value
+        data = base64.b64encode(val_bytes).decode()
 
         before_request = self.last_response()
         write_args = WriteMemoryArgs(

>From 085e527ad25167bfcd4cb5c5d344828966ae1585 Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 19 Jun 2026 16:07:50 +0100
Subject: [PATCH 7/8] update wait_for_XXX_event functions. use consistent
 numbering

---
 .../test/tools/lldb_dap/session_helpers.py    | 74 +++++++++++--------
 1 file changed, 42 insertions(+), 32 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
index da1e6c9c814db..5478bb409ed80 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/session_helpers.py
@@ -480,9 +480,9 @@ class _ConfigureContext:
 
     Orchestrates the full DAP initialization sequence:
     On enter:
-        - Request and respond to the `Initialize` command.
-        - Send launch/attach request.
-        - Wait for InitializedEvent.
+        1. Request and respond to the `Initialize` command.
+        2. Send launch/attach request.
+        3. Wait for InitializedEvent.
 
     In between:
        The test can set breakpoints or perform any check it needs do.
@@ -746,14 +746,24 @@ def set_assembly_breakpoints(
 
     def set_function_breakpoints(
         self,
-        function_names: list[str],
+        breakpoints: list[str] | list[FunctionBreakpoint],
         condition: Optional[str] = None,
         hitCondition: Optional[str] = None,
     ):
-        f_breakpoints = [
-            FunctionBreakpoint(name, condition=condition, hitCondition=hitCondition)
-            for name in function_names
-        ]
+        f_breakpoints: list[FunctionBreakpoint] = []
+        for bp in breakpoints:
+            if isinstance(bp, str):
+                func_bp = FunctionBreakpoint(
+                    bp, condition=condition, hitCondition=hitCondition
+                )
+                f_breakpoints.append(func_bp)
+            elif isinstance(bp, FunctionBreakpoint):
+                f_breakpoints.append(bp)
+            else:
+                self.test_case.fail(
+                    "breakpoints must only contain 'str' or 'FunctionBreakpoints'."
+                    f" got '{bp}' of type '{type(bp)}'."
+                )
         response = self.send_request(SetFunctionBreakpointsArgs(f_breakpoints)).result()
         return response
 
@@ -786,11 +796,11 @@ def set_variable(
         )
         response = handle.result_or_error()
         if isinstance(response, SetVariableResponse):
-            invalidated_event = self.wait_for_invalidated(after=last_response)
+            invalidated_event = self.wait_for_invalidated_event(after=last_response)
             invalidated_areas = invalidated_event.body.areas
             self.test_case.assertEqual(["variables"], invalidated_areas)
 
-            memory_event = self.wait_for_memory(after=last_response)
+            memory_event = self.wait_for_memory_event(after=last_response)
             self.test_case.assertEqual(
                 memory_event.body.memoryReference, response.body.memoryReference
             )
@@ -824,7 +834,7 @@ def resolve_source_breakpoints(
 
     def resolve_function_breakpoints(
         self,
-        function_names: list[str],
+        breakpoints: list[str] | list[FunctionBreakpoint],
         condition: Optional[str] = None,
         hitCondition: Optional[str] = None,
     ) -> list[int]:
@@ -833,16 +843,14 @@ def resolve_function_breakpoints(
         ("1", "2") for each breakpoint that was set.
         """
         last_response = self.last_response()
-        response = self.set_function_breakpoints(
-            function_names, condition, hitCondition
-        )
-        breakpoints = response.body.breakpoints
+        response = self.set_function_breakpoints(breakpoints, condition, hitCondition)
+        resp_bps = response.body.breakpoints
 
-        all_verified = all(bp.verified for bp in breakpoints)
+        all_verified = all(bp.verified for bp in resp_bps)
         if not all_verified:
-            self.wait_until_all_breakpoints_verified(breakpoints, after=last_response)
+            self.wait_until_all_breakpoints_verified(resp_bps, after=last_response)
 
-        return self.breakpoints_to_ids(breakpoints)
+        return self.breakpoints_to_ids(resp_bps)
 
     def set_data_breakpoints(self, breakpoints: list[DataBreakpoint]):
         args = SetDataBreakpointsArgs(breakpoints=breakpoints)
@@ -852,7 +860,7 @@ def set_instruction_breakpoints(self, memory_references: list[str]):
         breakpoints = [InstructionBreakpoint(ref) for ref in memory_references]
         return self.send_request(SetInstructionBreakpointsArgs(breakpoints)).result()
 
-    def set_breakpoint_locations(
+    def get_breakpoint_locations(
         self,
         file_path: str,
         line: int,
@@ -949,7 +957,7 @@ def event_hit_id_in_breakpoint_ids(event: StoppedEvent):
 
             return False
 
-        event = self.wait_for_stopped(
+        event = self.wait_for_stopped_event(
             breakpoint_stop_reasons,
             after=after,
             until=event_hit_id_in_breakpoint_ids,
@@ -1001,7 +1009,7 @@ def all_breakpoints_verified(evt: BreakpointEvent):
         )
         return last_breakpoint_event
 
-    def wait_for_stopped_or_exited(
+    def wait_for_stopped_or_exited_event(
         self,
         *,
         after: Event | Response,
@@ -1016,7 +1024,7 @@ def wait_for_stopped_or_exited(
         )
         return event
 
-    def wait_for_stopped(
+    def wait_for_stopped_event(
         self,
         matching_any: Optional[Sequence[StoppedReason]] = None,
         *,
@@ -1057,7 +1065,7 @@ def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
 
             return True
 
-        event = self.wait_for_stopped_or_exited(
+        event = self.wait_for_stopped_or_exited_event(
             after=after, until=matches_any_reason_until, timeout_msg=timeout_msg
         )
 
@@ -1065,7 +1073,7 @@ def matches_any_reason_until(event: StoppedEvent | ExitedEvent):
         self.test_case.assertEqual(event.event, EventName.STOPPED)
         return cast(StoppedEvent, event)
 
-    def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
+    def wait_for_exited_event(self, *, after: Event | Response) -> ExitedEvent:
         """
         Wait for a process to exit.
 
@@ -1073,7 +1081,7 @@ def wait_for_exited(self, *, after: Event | Response) -> ExitedEvent:
         Raises an error if a StoppedEvent is encountered, as a stopped process
         cannot subsequently exit.
         """
-        event = self.wait_for_stopped_or_exited(after=after)
+        event = self.wait_for_stopped_or_exited_event(after=after)
         self.test_case.assertIsInstance(event, ExitedEvent)
         self.test_case.assertEqual(event.event, "exited", "expected ExitedEvent'")
         return cast(ExitedEvent, event)
@@ -1099,13 +1107,13 @@ def wait_for_module_event(
     ):
         return self.wait_for_event(ModuleEvent, after=after, until=until)
 
-    def wait_for_terminated(self, *, after: Event | Response):
+    def wait_for_terminated_event(self, *, after: Event | Response):
         return self.wait_for_event(TerminatedEvent, after=after)
 
-    def wait_for_invalidated(self, *, after: Event | Response):
+    def wait_for_invalidated_event(self, *, after: Event | Response):
         return self.wait_for_event(InvalidatedEvent, after=after)
 
-    def wait_for_memory(self, *, after: Event | Response):
+    def wait_for_memory_event(self, *, after: Event | Response):
         return self.wait_for_event(MemoryEvent, after=after)
 
     def do_continue(self):
@@ -1138,7 +1146,7 @@ def continue_to_next_stop(self, *, exp_reason: Optional[StoppedReason] = None):
         """Continue execution and wait for stopped event"""
         response = self.do_continue()
         if exp_reason is None:
-            return self.wait_for_stopped(after=response)
+            return self.wait_for_stopped_event(after=response)
 
         return self.verify_stopped(exp_reason, after=response)
 
@@ -1277,7 +1285,9 @@ def verify_stopped(
             reasons = [reasons]
 
         timeout_msg = f"waiting for 'StoppedEvent' matching reasons: {reasons}"
-        stopped_event = self.wait_for_stopped(after=after, timeout_msg=timeout_msg)
+        stopped_event = self.wait_for_stopped_event(
+            after=after, timeout_msg=timeout_msg
+        )
 
         body = stopped_event.body
         test_case = self.test_case
@@ -1364,7 +1374,7 @@ def verify_process_exited(
         self, *, after: Event | Response | None = None, exitCode: int = 0
     ):
         if after:
-            event = self.wait_for_exited(after=after)
+            event = self.wait_for_exited_event(after=after)
         else:
             event = self.wait_for_earliest_event(ExitedEvent)
 
@@ -1690,7 +1700,7 @@ def write_memory(
 
         # Check we sent invalidated event.
         if response.success and self.initialize_args.supportsInvalidatedEvent:
-            invalidated = self.wait_for_invalidated(after=before_request)
+            invalidated = self.wait_for_invalidated_event(after=before_request)
             self.test_case.assertEqual(invalidated.body.areas, ["all"])
         return response
 

>From 64e9103f8d7432efb0a84998ff48b9e3693922cb Mon Sep 17 00:00:00 2001
From: Ebuka Ezike <yerimyah1 at gmail.com>
Date: Fri, 26 Jun 2026 16:52:47 +0100
Subject: [PATCH 8/8] explicitly check if a default or default factory is
 provided remove all in flight request when stopping the readloop

---
 .../Python/lldbsuite/test/tools/lldb_dap/dap_types.py      | 7 +++++++
 .../packages/Python/lldbsuite/test/tools/lldb_dap/utils.py | 1 +
 2 files changed, 8 insertions(+)

diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
index 5c0a369d445ce..f5af6482d01f7 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/dap_types.py
@@ -488,6 +488,13 @@ def _dict_to_message_impl(
         for key, f in fields.items():
             data_key = f.metadata.get("alias", key)
             if data_key not in data:
+                if (
+                    f.default is dataclasses.MISSING
+                    and f.default_factory is dataclasses.MISSING
+                ):
+                    raise TypeError(
+                        f"expected field {data_key!r} in {data!r} at {full_path!r}."
+                    )
                 continue
 
             required_value = f.metadata.get("required", dataclasses.MISSING)
diff --git a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
index ca0cc8e13f441..6bd4b3f85bacd 100644
--- a/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
+++ b/lldb/packages/Python/lldbsuite/test/tools/lldb_dap/utils.py
@@ -630,6 +630,7 @@ def _read_loop(self, handler: MessageHandler):
             # full timeout when the adapter exits or gets killed.
             with self._in_flight_lock:
                 pending_futures = [f for _, f in self._in_flight_requests.values()]
+                self._in_flight_requests = {}
 
             resp_error = error or DAPError("DAP connection closed before response.")
             for future in pending_futures:



More information about the lldb-commits mailing list