TelTest Phase 1: Core Client & Protocol — Implementation Plan¶
Design Document: docs/designs/teltest/README.md
Priority: P1 — Critical for regression safety
Estimated Duration: 3 weeks across 6 sections (1.1–1.6)
Summary¶
This plan implements the foundational layer of the TelTest E2E testing framework: the standalone async telnet client and its supporting infrastructure. All code lives in packages/teltest/ with zero MAID dependencies — pure Python 3.12+ and asyncio only.
The implementation adds:
TelnetProtocol— Byte-level IAC handling, negotiation state machine, GMCP subnegotiation extractionANSIStripper— Removal of ANSI/VT100 escape sequences from output textOutputBuffer— Line splitting, prompt detection (multi-signal: GA, regex, quiescence), ring buffer with read cursor trackingMUDClient— The top-level async client tying together connection lifecycle, background reader task, send/receive, and buffer access
Existing infrastructure leveraged: None — this is a greenfield standalone package.
Package layout after this phase:
packages/teltest/
├── pyproject.toml
├── src/teltest/
│ ├── __init__.py
│ ├── protocol.py # TelnetProtocol
│ ├── ansi.py # ANSIStripper
│ ├── buffer.py # OutputBuffer
│ ├── client.py # MUDClient
│ ├── exceptions.py # ConnectionClosed, etc.
│ └── types.py # Shared types, constants, dataclasses
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_protocol.py
│ ├── test_ansi.py
│ ├── test_buffer.py
│ ├── test_client.py
│ └── test_integration.py
└── README.md
Section 1.1: Package Scaffolding & Shared Types¶
Package:
teltest| Priority: P0 | Dependencies: none
1.1.1 Package Structure¶
- [ ] Create directory tree:
packages/teltest/src/teltest/andpackages/teltest/tests/ - [ ] Create
packages/teltest/pyproject.toml: name = "teltest",version = "0.1.0"requires-python = ">=3.12"- Zero runtime dependencies (pure stdlib + asyncio)
- Dev dependencies:
pytest,pytest-asyncio - Package source:
src/teltest - [ ] Create
packages/teltest/src/teltest/__init__.pywith public exports: MUDClient,TelnetProtocol,ANSIStripper,OutputBufferMatch,TranscriptEntry,PromptSignalConnectionClosed,ExpectTimeout,UnexpectedMatch,NotConnected- [ ] Create
packages/teltest/tests/__init__.py(empty) - [ ] Create
packages/teltest/tests/conftest.pywith shared fixtures (see 1.5.1) - [ ] Create
packages/teltest/README.mdwith basic package description
1.1.2 Telnet Constants¶
File:
packages/teltest/src/teltest/types.py
- [ ] Define telnet byte constants as module-level
intvalues:# Telnet command bytes IAC: int = 255 # Interpret As Command DONT: int = 254 DO: int = 253 WONT: int = 252 WILL: int = 251 SB: int = 250 # Subnegotiation Begin SE: int = 240 # Subnegotiation End GA: int = 249 # Go Ahead NOP: int = 241 # No Operation # Telnet option codes OPT_ECHO: int = 1 OPT_SGA: int = 3 # Suppress Go-Ahead OPT_TTYPE: int = 24 # Terminal Type OPT_NAWS: int = 31 # Negotiate About Window Size OPT_MSDP: int = 69 # MUD Server Data Protocol OPT_MSSP: int = 70 # MUD Server Status Protocol OPT_MCCP2: int = 86 # MUD Client Compression Protocol v2 OPT_MXP: int = 91 # MUD eXtension Protocol OPT_GMCP: int = 201 # Generic MUD Communication Protocol # TTYPE subnegotiation TTYPE_IS: int = 0 TTYPE_SEND: int = 1
1.1.3 Shared Dataclasses & Exceptions¶
File:
packages/teltest/src/teltest/types.py(continued)
- [ ] Define
PromptSignalenum: - [ ] Define
TranscriptEntryfrozen dataclass:
File:
packages/teltest/src/teltest/exceptions.py
- [ ] Define
ConnectionClosed(Exception): - Attributes:
buffer: str— text received before connection closed __init__(self, buffer: str) -> None__str__includes truncated buffer (last 500 chars)- [ ] Define
ExpectTimeout(AssertionError): - Attributes:
pattern: str,timeout: float,buffer: str __init__(self, pattern: str, timeout: float, buffer: str) -> None__str__includes pattern, timeout, and formatted buffer dump (last 20 lines)- [ ] Define
UnexpectedMatch(AssertionError): - Attributes:
pattern: str,matched_text: str,buffer: str __init__(self, pattern: str, matched_text: str, buffer: str) -> None- [ ] Define
NotConnected(Exception): - Raised when
send()orexpect()is called on a disconnected client __init__(self, message: str = "Client is not connected") -> None
Section 1.2: TelnetProtocol — IAC Handling & Negotiation State Machine¶
Package:
teltest| Priority: P0 | Dependencies: 1.1File:
packages/teltest/src/teltest/protocol.py
The TelnetProtocol class is a stateful byte-level parser that processes raw TCP data and separates telnet control sequences from application text. It operates synchronously on byte buffers — no I/O, no async. The caller feeds it raw bytes and receives clean text plus any GMCP messages.
1.2.1 Negotiation State Machine¶
The parser uses a state machine to track its position within IAC sequences:
class _TelnetState(enum.Enum):
DATA = "data" # Normal text data
IAC = "iac" # Received IAC byte, waiting for command
WILL = "will" # Received IAC WILL, waiting for option
WONT = "wont" # Received IAC WONT, waiting for option
DO = "do" # Received IAC DO, waiting for option
DONT = "dont" # Received IAC DONT, waiting for option
SB = "sb" # Inside subnegotiation, collecting bytes
SB_IAC = "sb_iac" # Inside SB, received IAC (checking for SE)
1.2.2 TelnetProtocol Class¶
- [ ]
class TelnetProtocol: - [ ]
__init__(self, *, negotiate_gmcp: bool = False) -> None:self._state: _TelnetState = _TelnetState.DATAself._negotiate_gmcp: bool = negotiate_gmcpself._sb_buffer: bytearray = bytearray()— accumulates subnegotiation bytesself._sb_option: int = 0— which option the current SB is forself._text_buffer: bytearray = bytearray()— accumulates clean text outputself._response_buffer: bytearray = bytearray()— IAC responses to send backself._gmcp_messages: list[tuple[str, dict[str, Any]]]— extracted GMCP messagesself._ga_received: bool = False— set True when GA is received, cleared on readself._option_state: dict[int, dict[str, bool]]— tracks negotiated option state per option; keys:"local"(we WILL),"remote"(they WILL)
- [ ]
feed(self, data: bytes) -> bytes:- Feed raw TCP bytes through the state machine
- Returns clean text bytes (application data with all IAC sequences removed)
- Populates
self._response_bufferwith IAC negotiation responses - Populates
self._gmcp_messageswith any extracted GMCP messages - Sets
self._ga_received = Truewhen GA (IAC GA) is encountered - State machine transitions per byte:
DATA+IAC→IACDATA+ any other → append to_text_bufferIAC+IAC→ escaped IAC (0xFF literal), append to_text_buffer, →DATAIAC+WILL→WILLIAC+WONT→WONTIAC+DO→DOIAC+DONT→DONTIAC+SB→SB, clear_sb_bufferIAC+GA→ set_ga_received = True, →DATAIAC+NOP→ ignore, →DATAIAC+ any other → ignore unknown command, →DATAWILL+ option → call_handle_will(option), →DATAWONT+ option → call_handle_wont(option), →DATADO+ option → call_handle_do(option), →DATADONT+ option → call_handle_dont(option), →DATASB+ first byte → store as_sb_option, remain inSBSB+IAC→SB_IACSB+ any other → append to_sb_bufferSB_IAC+SE→ call_handle_subnegotiation(_sb_option, _sb_buffer), →DATASB_IAC+IAC→ escaped IAC inside SB, append 0xFF to_sb_buffer, →SBSB_IAC+ any other → append to_sb_buffer, →SB(robustness)- At end: drain
_text_bufferinto return value, clear_text_buffer
- [ ]
get_response(self) -> bytes:- Returns and clears
_response_buffer - Called by
MUDClientafter eachfeed()to get bytes to send back to server
- Returns and clears
- [ ]
get_gmcp_messages(self) -> list[tuple[str, dict[str, Any]]]:- Returns and clears
_gmcp_messageslist
- Returns and clears
- [ ]
consume_ga(self) -> bool:- Returns current
_ga_receivedand resets it toFalse - Used by
OutputBufferto detect prompt via GA signal
- Returns current
- [ ]
is_option_active(self, option: int, *, local: bool = False) -> bool:- Check if a specific option has been successfully negotiated
local=Falsechecks remote side (they WILL),local=Truechecks our side (we WILL)
1.2.3 Negotiation Handlers¶
- [ ]
_handle_will(self, option: int) -> None: - Server offers to enable an option
- Accepted options (respond
DO):OPT_ECHO— password hidingOPT_SGA— suppress go-ahead (standard)OPT_GMCP— only ifself._negotiate_gmcpis True
- Refused options (respond
DONT):OPT_MCCP2— compression adds complexity; declineOPT_MSDP,OPT_MXP,OPT_MSSP— not needed- Everything else — safe default: refuse unknown
- Update
_option_state[option]["remote"] = Truefor accepted - Append
bytes([IAC, DO, option])orbytes([IAC, DONT, option])to_response_buffer - Guard: If option already negotiated (already in
_option_stateas active), do NOT re-send response (prevents negotiation loops) - [ ]
_handle_wont(self, option: int) -> None: - Server disabling an option
- Update
_option_state[option]["remote"] = False - Respond
bytes([IAC, DONT, option])(acknowledge) - No guard needed — WONT is always safe to acknowledge
- [ ]
_handle_do(self, option: int) -> None: - Server requests we enable an option
- Accepted options (respond
WILL):OPT_TTYPE— we will send terminal type; also queue TTYPE subnegotiation responseOPT_NAWS— we will send window size; also queue NAWS subnegotiation response
- Refused options (respond
WONT):- Everything else
- Update
_option_state[option]["local"] = Truefor accepted - Append response to
_response_buffer - Guard: Same loop-prevention as
_handle_will - [ ]
_handle_dont(self, option: int) -> None: - Server requests we disable an option
- Update
_option_state[option]["local"] = False - Respond
bytes([IAC, WONT, option])(acknowledge) - [ ]
_queue_ttype_response(self) -> None: - Build and append TTYPE IS subnegotiation:
IAC SB TTYPE IS "TELTEST" IAC SE - Bytes:
bytes([IAC, SB, OPT_TTYPE, TTYPE_IS]) + b"TELTEST" + bytes([IAC, SE]) - [ ]
_queue_naws_response(self, width: int = 80, height: int = 24) -> None: - Build and append NAWS subnegotiation:
IAC SB NAWS <w_hi> <w_lo> <h_hi> <h_lo> IAC SE - Width/height encoded as two big-endian bytes each
- Must escape any 0xFF bytes within the payload (replace
0xFFwith0xFF 0xFF)
1.2.4 Subnegotiation Handler¶
- [ ]
_handle_subnegotiation(self, option: int, data: bytes) -> None: - Dispatch based on option:
OPT_TTYPE: If data starts withTTYPE_SEND, queue_queue_ttype_response()OPT_GMCP: Call_handle_gmcp_subnegotiation(data)- All others: ignore (log at debug level if needed)
- [ ]
_handle_gmcp_subnegotiation(self, data: bytes) -> None: - Parse GMCP payload:
<package_name> <json_data>(space-separated) - Split on first space:
package = data[:space_idx].decode("utf-8"),json_str = data[space_idx+1:] - If no space found,
package = data.decode("utf-8"),json_data = {} - Parse JSON via
json.loads(json_str)— onjson.JSONDecodeError, store raw string as{"_raw": json_str.decode("utf-8")} - Append
(package, json_data)toself._gmcp_messages
1.2.5 GMCP Sending Helper¶
- [ ]
build_gmcp_message(self, package: str, data: dict[str, Any]) -> bytes: - Build a GMCP subnegotiation for sending:
IAC SB GMCP <package> <json> IAC SE json_payload = json.dumps(data, separators=(",", ":"))(compact)payload = package.encode("utf-8") + b" " + json_payload.encode("utf-8")- Return
bytes([IAC, SB, OPT_GMCP]) + payload + bytes([IAC, SE]) - Does not append to
_response_buffer— returned directly forsend_raw()
1.2.6 Unit Tests¶
File:
packages/teltest/tests/test_protocol.py
- [ ]
test_plain_text_passes_through— Feed ASCII text with no IAC;feed()returns identical bytes - [ ]
test_escaped_iac_becomes_literal— FeedIAC IAC;feed()returns single0xFFbyte - [ ]
test_will_echo_responds_do_echo— FeedIAC WILL ECHO;get_response()returnsIAC DO ECHO - [ ]
test_will_sga_responds_do_sga— FeedIAC WILL SGA;get_response()returnsIAC DO SGA - [ ]
test_will_mccp2_responds_dont_mccp2— FeedIAC WILL MCCP2;get_response()returnsIAC DONT MCCP2 - [ ]
test_will_msdp_responds_dont_msdp— FeedIAC WILL MSDP;get_response()returnsIAC DONT MSDP - [ ]
test_will_mxp_responds_dont_mxp— FeedIAC WILL MXP;get_response()returnsIAC DONT MXP - [ ]
test_will_unknown_option_responds_dont— FeedIAC WILL 99;get_response()returnsIAC DONT 99 - [ ]
test_wont_responds_dont— FeedIAC WONT ECHO;get_response()returnsIAC DONT ECHO - [ ]
test_do_ttype_responds_will_ttype_and_subneg— FeedIAC DO TTYPE;get_response()includesIAC WILL TTYPE; then feedIAC SB TTYPE SEND IAC SE;get_response()includesIAC SB TTYPE IS TELTEST IAC SE - [ ]
test_do_naws_responds_will_naws_and_subneg— FeedIAC DO NAWS;get_response()includesIAC WILL NAWSand an NAWS subnegotiation with 80×24 - [ ]
test_do_unknown_option_responds_wont— FeedIAC DO 99;get_response()returnsIAC WONT 99 - [ ]
test_dont_responds_wont— FeedIAC DONT TTYPE;get_response()returnsIAC WONT TTYPE - [ ]
test_ga_sets_flag— FeedIAC GA;consume_ga()returnsTrue, subsequent call returnsFalse - [ ]
test_nop_is_ignored— FeedIAC NOP;feed()returns empty bytes, no response queued - [ ]
test_iac_sequences_stripped_from_text— Feedb"Hello" + IAC WILL ECHO + b"World";feed()returnsb"HelloWorld" - [ ]
test_mixed_text_and_commands— Feed interleaved text and multiple IAC sequences; verify only clean text returned - [ ]
test_partial_iac_at_buffer_boundary— Feedb"text" + bytes([IAC])in one call, thenbytes([WILL, OPT_ECHO])in second call; verify text from first call, response from second - [ ]
test_subnegotiation_basic— FeedIAC SB <option> <data> IAC SE; verify data is consumed, no text output - [ ]
test_subnegotiation_with_escaped_iac— Feed SB containingIAC IAC(escaped 0xFF); verify the 0xFF is preserved in SB data - [ ]
test_gmcp_disabled_responds_dont— Protocol withnegotiate_gmcp=False; feedIAC WILL GMCP; response isIAC DONT GMCP - [ ]
test_gmcp_enabled_responds_do— Protocol withnegotiate_gmcp=True; feedIAC WILL GMCP; response isIAC DO GMCP - [ ]
test_gmcp_message_extraction— Feed GMCP subnegotiationIAC SB GMCP "Char.Vitals {"hp":100}" IAC SE;get_gmcp_messages()returns[("Char.Vitals", {"hp": 100})] - [ ]
test_gmcp_message_no_json_body— Feed GMCP subneg with package name only (no space, no JSON);get_gmcp_messages()returns[("PackageName", {})] - [ ]
test_gmcp_message_invalid_json— Feed GMCP subneg with invalid JSON; result stores raw string in{"_raw": ...} - [ ]
test_build_gmcp_message— Verifybuild_gmcp_message("Char.Login", {"name": "Test"})produces correct byte sequence - [ ]
test_negotiation_loop_prevention— FeedIAC WILL ECHOtwice; only oneIAC DO ECHOin response (second is suppressed) - [ ]
test_option_state_tracking— Negotiate ECHO;is_option_active(OPT_ECHO)returnsTrue; feedIAC WONT ECHO; returnsFalse - [ ]
test_naws_escapes_0xff_in_payload— FeedIAC DO NAWSwith protocol configured for width 255; verify NAWS subneg payload escapes the 0xFF byte - [ ]
test_empty_feed— Feed empty bytes; returns empty bytes, no response - [ ]
test_feed_only_iac_sequences— Feed only IAC commands with no text; returns empty bytes
Section 1.3: ANSIStripper — Escape Code Removal¶
Package:
teltest| Priority: P0 | Dependencies: 1.1File:
packages/teltest/src/teltest/ansi.py
1.3.1 ANSIStripper Class¶
- [ ]
class ANSIStripper: - [ ]
ANSI_PATTERN: re.Pattern[str]— class-level compiled regex- Pattern:
r"\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[()][AB012]|\x1b[>=<]?" - Covers:
- CSI sequences:
ESC [ <params> <letter>(colors, cursor movement, etc.) - OSC sequences:
ESC ] ... BELorESC ] ... ESC \(window title, etc.) - Character set selectors:
ESC ( B,ESC ) 0, etc. - Simple escapes:
ESC >,ESC =,ESC <
- Pattern:
- [ ]
__init__(self) -> None:self._partial: str = ""— stores partial escape sequence split across chunks
- [ ]
strip(self, text: str) -> str:- Prepend
self._partialtotext - If text ends with
\x1bor with\x1b[followed by digits/semicolons (but no terminating letter), save the incomplete sequence toself._partialand remove from text - Apply
ANSI_PATTERN.sub("", text)to remove complete sequences - Return cleaned text
- Prepend
- [ ]
reset(self) -> None:- Clear
self._partial
- Clear
1.3.2 Module-Level Convenience Function¶
- [ ]
def strip_ansi(text: str) -> str: - Stateless single-call version (no partial tracking)
return ANSIStripper.ANSI_PATTERN.sub("", text)
1.3.3 Unit Tests¶
File:
packages/teltest/tests/test_ansi.py
- [ ]
test_plain_text_unchanged—strip_ansi("Hello World")returns"Hello World" - [ ]
test_strip_sgr_color_codes—strip_ansi("\x1b[31mRed\x1b[0m")returns"Red" - [ ]
test_strip_bold—strip_ansi("\x1b[1mBold\x1b[0m")returns"Bold" - [ ]
test_strip_256_color—strip_ansi("\x1b[38;5;196mRed\x1b[0m")returns"Red" - [ ]
test_strip_24bit_color—strip_ansi("\x1b[38;2;255;0;0mRed\x1b[0m")returns"Red" - [ ]
test_strip_cursor_movement—strip_ansi("\x1b[2J\x1b[HHello")returns"Hello" - [ ]
test_strip_multiple_sequences— Text with several interleaved codes; all removed, text preserved - [ ]
test_empty_string—strip_ansi("")returns"" - [ ]
test_no_escape_codes— ASCII text with special chars ([],;) but no ESC; unchanged - [ ]
test_osc_sequence_with_bel—strip_ansi("\x1b]0;Window Title\x07Text")returns"Text" - [ ]
test_osc_sequence_with_st—strip_ansi("\x1b]0;Title\x1b\\Text")returns"Text" - [ ]
test_strip_preserves_newlines—strip_ansi("\x1b[32mLine1\r\nLine2\x1b[0m")returns"Line1\r\nLine2" - [ ]
test_stateful_partial_sequence_across_chunks— ANSIStripper instance:strip("\x1b[31mHello\x1b")returns"Hello", thenstrip("[0mWorld")returns"World" - [ ]
test_stateful_partial_csi_params_across_chunks—strip("Text\x1b[38;5")returns"Text", thenstrip(";196mMore")returns"More" - [ ]
test_stateful_reset_clears_partial— After partial,reset(), then new text with]is not treated as continuation - [ ]
test_only_escape_codes—strip_ansi("\x1b[0m\x1b[31m\x1b[0m")returns"" - [ ]
test_realistic_mud_output— A line like"\x1b[1;33m[HP:100]\x1b[0m \x1b[32mYou are standing...\x1b[0m"returns"[HP:100] You are standing..."
Section 1.4: OutputBuffer — Line Splitting, Prompt Detection, Ring Buffer¶
Package:
teltest| Priority: P0 | Dependencies: 1.1File:
packages/teltest/src/teltest/buffer.py
The OutputBuffer is the central data structure that stores received text, splits it into lines, detects prompts via multiple signals, and tracks a read cursor for cursor-based expect() matching.
1.4.1 OutputBuffer Class¶
- [ ]
class OutputBuffer: - [ ]
__init__(self, *, max_lines: int = 10_000, prompt_pattern: re.Pattern[str] | None = None, prompt_settle_time: float = 0.3) -> None:self._lines: collections.deque[str] = collections.deque(maxlen=max_lines)— ring buffer of complete linesself._partial: str = ""— current unterminated line (potential prompt)self._cursor: int = 0— read cursor position (index into logical character stream)self._total_chars: int = 0— total characters ever appended (monotonic)self._text: str = ""— full buffer text (rebuilt on access, cached)self._text_dirty: bool = True— invalidate cache on appendself._prompt_pattern: re.Pattern[str] | None = prompt_patternself._prompt_settle_time: float = prompt_settle_timeself._ga_received: bool = False— GA signal forwarded from protocolself._last_data_time: float = 0.0— monotonic time of lastappend()callself._new_data: asyncio.Event = asyncio.Event()— signaled on new data arrivalself._closed: bool = False— set on EOFself._max_lines: int = max_lines
- [ ]
append(self, text: str) -> None:- Split
texton\r\nand\n(handle both; normalize\r\nto\ninternally) - For each complete line: append to
self._lines, incrementself._total_charsbylen(line) + 1(for newline) - Any trailing non-newline-terminated text goes into
self._partial - If
self._partialwas non-empty before this call and new text starts without newline, concatenate - Update
self._last_data_time = time.monotonic() - Set
self._text_dirty = True - Call
self._new_data.set()to wake up any waitingexpect()
- Split
- [ ]
signal_ga(self) -> None:self._ga_received = True- Call
self._new_data.set()to wake up prompt waiters
- [ ]
signal_closed(self) -> None:self._closed = True- Call
self._new_data.set()to unblock any waitingexpect()with EOF
- [ ]
@property text(self) -> str:- If
self._text_dirty: rebuildself._textfrom"\n".join(self._lines)+ ("\n" + self._partialif partial exists, or"\n"if lines exist and partial is empty) - Return
self._text
- If
- [ ]
@property lines(self) -> list[str]:- Return
list(self._lines)
- Return
- [ ]
@property partial_line(self) -> str:- Return
self._partial(current unterminated text)
- Return
- [ ]
@property is_closed(self) -> bool:- Return
self._closed
- Return
1.4.2 Cursor Management¶
- [ ]
@property cursor(self) -> int: - Return
self._cursor - [ ]
advance_cursor(self) -> None: - Set
self._cursorto current end of buffer (len(self.text)) - Used by
send()to skip past all currently buffered text - [ ]
advance_cursor_past(self, position: int) -> None: - Set
self._cursor = position - Used by
expect()to advance cursor past the match - [ ]
text_from_cursor(self) -> str: - Return
self.text[self._cursor:] - Text received since last cursor advance (what
expect()searches) - [ ]
reset(self) -> None: - Clear
self._lines,self._partial, resetself._cursor = 0,self._total_chars = 0 - Set
self._text_dirty = True - Clear
self._ga_received - Clear
self._new_dataevent
1.4.3 Prompt Detection¶
- [ ]
detect_prompt(self) -> tuple[bool, PromptSignal | None, str]: - Returns
(is_prompt, signal, prompt_text)tuple - Signal 1 — GA: If
self._ga_receivedis True andself._partialis non-empty:self._ga_received = False- Return
(True, PromptSignal.GA, self._partial)
- Signal 2 — Regex: If
self._prompt_patternis not None andself._partialis non-empty:- If
self._prompt_pattern.search(self._partial)matches: - Return
(True, PromptSignal.REGEX, self._partial)
- If
- Signal 3 — Quiescence: If
self._partialis non-empty:- If
time.monotonic() - self._last_data_time >= self._prompt_settle_time: - Return
(True, PromptSignal.QUIESCENCE, self._partial)
- If
- Otherwise:
(False, None, "")
1.4.4 Async Waiting¶
- [ ]
async def wait_for_data(self, timeout: float) -> bool: - Wait for
self._new_dataevent with timeout - Clear the event after waking
- Returns
Trueif new data arrived,Falseon timeout - Raises nothing — caller checks
self._closedseparately - Implementation: wrap
self._new_data.wait()withasyncio.wait_for(..., timeout), catchasyncio.TimeoutError→ return False, then clear event
1.4.5 Unit Tests¶
File:
packages/teltest/tests/test_buffer.py
- [ ]
test_append_complete_lines— Append"Line1\r\nLine2\r\n";linesis["Line1", "Line2"],partial_lineis"" - [ ]
test_append_partial_line— Append"Hello";linesis[],partial_lineis"Hello" - [ ]
test_append_mixed— Append"Line1\r\nPartial";linesis["Line1"],partial_lineis"Partial" - [ ]
test_append_continues_partial— Append"Hel", then"lo\r\n";linesis["Hello"],partial_lineis"" - [ ]
test_append_unix_newlines— Append"A\nB\n";linesis["A", "B"] - [ ]
test_append_crlf_normalization— Append"A\r\nB\r\n";linesis["A", "B"] - [ ]
test_text_property_full_content— Append lines and partial;textcontains all concatenated with newlines - [ ]
test_ring_buffer_max_lines— Create buffer withmax_lines=3; append 5 lines; only last 3 inlines - [ ]
test_ring_buffer_drops_oldest— Verify first lines are dropped when capacity exceeded - [ ]
test_cursor_starts_at_zero— New buffer hascursor == 0 - [ ]
test_advance_cursor— Append text,advance_cursor();text_from_cursor()returns""; append more;text_from_cursor()returns new text only - [ ]
test_advance_cursor_past— Append"ABCDEF";advance_cursor_past(3);text_from_cursor()starts at char index 3 - [ ]
test_text_from_cursor_after_send— Simulates send scenario: append text, advance cursor, append response;text_from_cursor()only shows response - [ ]
test_reset_clears_everything— Append text,reset();textis"",linesis[],cursoris0 - [ ]
test_signal_ga_sets_flag—signal_ga();detect_prompt()returns(True, PromptSignal.GA, partial)when partial is non-empty - [ ]
test_detect_prompt_ga_signal— Append"> "(partial);signal_ga();detect_prompt()returns GA signal with"> " - [ ]
test_detect_prompt_ga_clears_after_read— Afterdetect_prompt()returns GA, second call does not return GA again (if no new GA) - [ ]
test_detect_prompt_regex_signal— Create buffer withprompt_pattern=re.compile(r"^> $"); append"> ";detect_prompt()returns REGEX signal - [ ]
test_detect_prompt_regex_no_match— Partial text"some text"does not match prompt regex; returns(False, None, "") - [ ]
test_detect_prompt_quiescence— Append"> ", wait forprompt_settle_time;detect_prompt()returns QUIESCENCE signal - [ ]
test_detect_prompt_quiescence_too_soon— Append"> "immediately;detect_prompt()returns(False, None, "")(not enough time elapsed) - [ ]
test_detect_prompt_priority_ga_over_regex— Both GA flag set and regex matches; GA is returned (priority 1) - [ ]
test_detect_prompt_empty_partial— No partial text;detect_prompt()returns(False, None, "")regardless of GA flag - [ ]
test_signal_closed—signal_closed();is_closedreturnsTrue - [ ]
test_wait_for_data_returns_true— Append data from a background task;wait_for_data(1.0)returnsTrue - [ ]
test_wait_for_data_timeout— No data appended;wait_for_data(0.1)returnsFalse - [ ]
test_wait_for_data_unblocked_by_close—signal_closed()from background task;wait_for_data(5.0)returnsTrue(event was set) - [ ]
test_wait_for_data_unblocked_by_ga—signal_ga()from background task;wait_for_data(5.0)returnsTrue - [ ]
test_new_data_event_cleared_after_wait— Afterwait_for_data()returns, event is cleared; subsequentwait_for_data(0.01)returnsFalseif no new data - [ ]
test_append_empty_string— Append""; no change to buffer state - [ ]
test_multiple_appends_accumulate— Multipleappend()calls;textcontains all data in order
Section 1.5: MUDClient — Connection Lifecycle & Background Reader¶
Package:
teltest| Priority: P0 | Dependencies: 1.2, 1.3, 1.4File:
packages/teltest/src/teltest/client.py
The MUDClient is the top-level class that ties together TelnetProtocol, ANSIStripper, and OutputBuffer with TCP connection management and a background reader task.
1.5.1 Test Fixtures¶
File:
packages/teltest/tests/conftest.py
- [ ]
echo_serverfixture — An async TCP server that echoes back whatever it receives: - Binds to
("127.0.0.1", 0)to get a free port - For each connection: reads data, writes it back
- Yields
(host, port)tuple - Cleans up server on teardown
- [ ]
telnet_serverfixture — An async TCP server that sends telnet IAC negotiation: TelnetServerHelperclass with:host: str— server bind addressport: int— server bind portsend(data: bytes) -> None— send raw bytes to connected clientsend_line(text: str) -> None— send text with\r\nappendedsend_iac(command: int, option: int) -> None— send 3-byte IAC sequencesend_ga() -> None— sendbytes([IAC, GA])send_gmcp(package: str, data: dict[str, Any]) -> None— send GMCP subnegotiationreceived: list[bytes]— bytes received from clientclose() -> None— close the client connection (simulate server crash/disconnect)async wait_for_connection(timeout: float = 2.0) -> None— wait for a client to connect
- Binds to ephemeral port
- Accepts one connection at a time
- Yields the helper
- Cleans up on teardown
- [ ]
silent_serverfixture — A TCP server that accepts connections but sends nothing: - [ ]
closing_serverfixture — A TCP server that accepts and immediately closes:
1.5.2 MUDClient Class¶
- [ ]
class MUDClient: - [ ]
__init__(self, host: str = "localhost", port: int = 4000, *, timeout: float = 5.0, strip_ansi: bool = True, encoding: str = "utf-8", negotiate_gmcp: bool = False, prompt_pattern: re.Pattern[str] | None = None, prompt_settle_time: float = 0.3, max_history_lines: int = 10_000) -> None:self._host: str = hostself._port: int = portself._timeout: float = timeoutself._strip_ansi: bool = strip_ansiself._encoding: str = encodingself._protocol: TelnetProtocol = TelnetProtocol(negotiate_gmcp=negotiate_gmcp)self._ansi_stripper: ANSIStripper = ANSIStripper()(only used ifstrip_ansi)self._buffer: OutputBuffer = OutputBuffer(max_lines=max_history_lines, prompt_pattern=prompt_pattern, prompt_settle_time=prompt_settle_time)self._reader: asyncio.StreamReader | None = Noneself._writer: asyncio.StreamWriter | None = Noneself._reader_task: asyncio.Task[None] | None = Noneself._reader_error: BaseException | None = None— last exception from reader taskself._connected: bool = Falseself._transcript: collections.deque[TranscriptEntry] = collections.deque(maxlen=max_history_lines)self._gmcp_messages: list[tuple[str, dict[str, Any]]] = []— accumulated GMCP messages
1.5.3 Connection Lifecycle¶
- [ ]
async def connect(self) -> None: - Open TCP connection:
self._reader, self._writer = await asyncio.wait_for(asyncio.open_connection(self._host, self._port), timeout=self._timeout) - Set
self._connected = True - Clear
self._reader_error = None - Start background reader:
self._reader_task = asyncio.create_task(self._reader_loop(), name="teltest-reader") - Attach error handler:
self._reader_task.add_done_callback(self._on_reader_done) - [ ]
async def disconnect(self) -> None: - If not connected, return silently (idempotent)
- Set
self._connected = False - Cancel the reader task:
self._reader_task.cancel() - Await the reader task with suppressed
CancelledError: - Close the writer:
self._writer.close(),await self._writer.wait_closed() - Signal buffer closed:
self._buffer.signal_closed() - Set
self._reader = None,self._writer = None,self._reader_task = None - [ ]
async def __aenter__(self) -> "MUDClient": await self.connect()return self- [ ]
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object) -> None: await self.disconnect()- [ ]
@property is_connected(self) -> bool: return self._connected
1.5.4 Background Reader Task¶
- [ ]
async def _reader_loop(self) -> None: - Continuous loop reading from
self._reader:while self._connected: data = await self._reader.read(4096) if not data: # EOF — server closed connection self._buffer.signal_closed() self._connected = False return # Process through telnet protocol layer clean_bytes = self._protocol.feed(data) # Send any negotiation responses back to server response = self._protocol.get_response() if response: self._writer.write(response) await self._writer.drain() # Check for Go-Ahead signal if self._protocol.consume_ga(): self._buffer.signal_ga() # Extract GMCP messages for package, gmcp_data in self._protocol.get_gmcp_messages(): self._gmcp_messages.append((package, gmcp_data)) self._transcript.append(TranscriptEntry( timestamp=time.monotonic(), direction="gmcp_recv", text=f"{package} {json.dumps(gmcp_data)}", )) # Decode bytes to text and optionally strip ANSI text = clean_bytes.decode(self._encoding, errors="replace") if self._strip_ansi: text = self._ansi_stripper.strip(text) # Append to output buffer if text: self._buffer.append(text) self._transcript.append(TranscriptEntry( timestamp=time.monotonic(), direction="recv", text=text, )) - Exception handling: The loop does NOT catch exceptions. They propagate to the task's result, handled by
_on_reader_done. - [ ]
def _on_reader_done(self, task: asyncio.Task[None]) -> None: - Callback attached via
add_done_callbackon the reader task - If
task.cancelled(): do nothing (expected duringdisconnect()) - If
task.exception()is not None:- Store:
self._reader_error = task.exception() - Set:
self._connected = False - Signal buffer:
self._buffer.signal_closed()
- Store:
1.5.5 Sending¶
- [ ]
async def send(self, text: str) -> None: - Raise
NotConnectedifnot self._connected - Encode:
data = (text + "\r\n").encode(self._encoding) - Write:
self._writer.write(data),await self._writer.drain() - Advance read cursor:
self._buffer.advance_cursor() - Record transcript:
self._transcript.append(TranscriptEntry(timestamp=time.monotonic(), direction="send", text=text)) - [ ]
async def send_raw(self, data: bytes) -> None: - Raise
NotConnectedifnot self._connected - Write:
self._writer.write(data),await self._writer.drain() - Do NOT advance cursor (raw sends are for protocol-level interaction)
- Record transcript with
direction="send",text=repr(data) - [ ]
async def send_gmcp(self, package: str, data: dict[str, Any]) -> None: - Raise
NotConnectedifnot self._connected - Build message:
msg = self._protocol.build_gmcp_message(package, data) - Write:
self._writer.write(msg),await self._writer.drain() - Record transcript:
TranscriptEntry(timestamp=..., direction="gmcp_send", text=f"{package} {json.dumps(data)}")
1.5.6 Buffer Access Properties¶
- [ ]
@property output(self) -> str: return self._buffer.text- [ ]
@property recent(self) -> str: return self._buffer.text_from_cursor()- [ ]
def clear_buffer(self) -> None: self._buffer.reset()- [ ]
@property transcript(self) -> list[TranscriptEntry]: return list(self._transcript)- [ ]
@property reader_error(self) -> BaseException | None: return self._reader_error- [ ]
@property gmcp_messages(self) -> list[tuple[str, dict[str, Any]]]: return list(self._gmcp_messages)
1.5.7 Reader Error Re-raise Contract¶
The design specifies that reader exceptions are captured and re-raised in the next expect() call. Since expect() is implemented in Phase 2 (Expectation Engine), MUDClient exposes the mechanism for it:
- [ ]
def _check_reader_health(self) -> None: - Called at the start of every
expect*()method (Phase 2 will call this) - If
self._reader_error is not None:- Raise
ConnectionClosed(buffer=self._buffer.text)fromself._reader_error
- Raise
- If
self._buffer.is_closed and not self._connected:- Raise
ConnectionClosed(buffer=self._buffer.text)
- Raise
1.5.8 Unit Tests¶
File:
packages/teltest/tests/test_client.py
Connection lifecycle tests:
- [ ]
test_connect_opens_tcp_connection— Connect toecho_server;is_connectedreturnsTrue - [ ]
test_connect_starts_reader_task— Connect;_reader_taskis not None and not done - [ ]
test_disconnect_closes_connection— Connect then disconnect;is_connectedreturnsFalse - [ ]
test_disconnect_cancels_reader_task— Connect then disconnect;_reader_taskis None (cleaned up) - [ ]
test_disconnect_idempotent— Disconnect twice; no error on second call - [ ]
test_connect_timeout_raises— Connect to non-listening port with short timeout; raisesasyncio.TimeoutErrororConnectionRefusedError - [ ]
test_context_manager_connect_disconnect—async with MUDClient(host, port) as c:—is_connectedisTrueinside,Falseafter - [ ]
test_context_manager_disconnect_on_exception— Exception insideasync with; client still disconnects cleanly
Send tests:
- [ ]
test_send_appends_crlf— Send"hello"totelnet_server; server receivesb"hello\r\n" - [ ]
test_send_advances_cursor— Connect totelnet_server; server sends text; after brief wait,send("cmd"); cursor is at end of pre-send buffer - [ ]
test_send_raw_no_crlf—send_raw(b"raw")totelnet_server; server receives exactlyb"raw" - [ ]
test_send_raw_does_not_advance_cursor—send_raw(b"data"); cursor unchanged from before call - [ ]
test_send_when_disconnected_raises— Disconnect thensend("x"); raisesNotConnected - [ ]
test_send_records_transcript— Send text;transcriptcontains entry withdirection="send"and the sent text
Reader task tests:
- [ ]
test_reader_receives_plain_text—telnet_serversends"Hello\r\n"; after short wait,outputcontains"Hello" - [ ]
test_reader_handles_iac_negotiation—telnet_serversendsIAC WILL ECHO; server receivesIAC DO ECHOresponse;outputdoes not contain IAC bytes - [ ]
test_reader_strips_ansi_when_enabled—telnet_serversends"\x1b[31mRed\x1b[0m\r\n";outputcontains"Red"(stripped) - [ ]
test_reader_preserves_ansi_when_disabled— MUDClient withstrip_ansi=False;telnet_serversends ANSI;outputcontains escape codes - [ ]
test_reader_detects_eof—telnet_servercloses connection; after short wait,is_connectedisFalseand_buffer.is_closedisTrue - [ ]
test_reader_error_stored_on_exception— Force an exception scenario in reader;reader_erroris set to the exception - [ ]
test_reader_eof_sets_connection_closed— Server closes connection;_check_reader_health()raisesConnectionClosed - [ ]
test_reader_error_reraised_as_connection_closed— Reader encounters error;_check_reader_health()raisesConnectionClosedwith__cause__set to the original - [ ]
test_reader_records_recv_transcript—telnet_serversends text;transcriptcontainsdirection="recv"entries with the text
GMCP tests:
- [ ]
test_send_gmcp_builds_correct_bytes—send_gmcp("Char.Login", {"name": "X"}); server receives valid GMCP subnegotiation bytes - [ ]
test_reader_extracts_gmcp_messages—telnet_serversends GMCP subneg after GMCP negotiation;gmcp_messagescontains extracted(package, data)tuple - [ ]
test_gmcp_disabled_no_negotiation— MUDClient withnegotiate_gmcp=False; server sendsIAC WILL GMCP; server receivesIAC DONT GMCP
Buffer access tests:
- [ ]
test_output_returns_all_text— Server sends multiple lines;outputcontains all of them - [ ]
test_recent_returns_text_since_cursor— Server sends text;send("cmd"); server sends more;recentonly contains post-send text - [ ]
test_clear_buffer_resets_all— Send text,clear_buffer();outputis"",recentis""
Multi-line and timing tests:
- [ ]
test_multiple_lines_buffered— Server sends 5 lines rapidly; all appear inoutput - [ ]
test_partial_line_buffered_as_prompt— Server sends"> "without newline; appears in_buffer.partial_line - [ ]
test_transcript_ordering— Mix of sends and receives; transcript entries are in chronological order
Edge case tests:
- [ ]
test_connect_to_closing_server— Connect toclosing_server; reader detects EOF;is_connectedbecomesFalse - [ ]
test_large_data_read— Server sends 100KB of text; all data received without error - [ ]
test_encoding_error_replaced— Server sends invalid UTF-8 bytes (after IAC stripping); decoded with replacement chars, no crash
Section 1.6: Integration Tests¶
Package:
teltest| Priority: P0 | Dependencies: 1.2, 1.3, 1.4, 1.5File:
packages/teltest/tests/test_integration.py
These tests verify the full stack (protocol → ANSI → buffer → client) working together end-to-end.
- [ ]
test_full_telnet_handshake_and_text—telnet_serversendsIAC WILL ECHO,IAC WILL SGA,IAC DO TTYPE,IAC DO NAWS, then text lines; client auto-negotiates all and receives clean text - [ ]
test_gmcp_roundtrip— Client withnegotiate_gmcp=True; server sendsIAC WILL GMCP, then a GMCP message; client extracts message; client sends GMCP back; server receives correct bytes - [ ]
test_prompt_detection_via_ga— Server sends"Hello\r\n> "thenIAC GA; bufferdetect_prompt()returns(True, PromptSignal.GA, "> ") - [ ]
test_prompt_detection_via_regex— Client withprompt_pattern=re.compile(r"> $"); server sends"> "; after settle time,detect_prompt()returns regex signal - [ ]
test_ansi_stripped_before_buffering— Server sends ANSI-colored text interspersed with IAC sequences;outputcontains only clean text - [ ]
test_interleaved_iac_and_text— Server sends text, IAC sequences, more text, GMCP, more text in one large TCP write; all text arrives clean, GMCP extracted, negotiation responded to - [ ]
test_multiple_rapid_sends— Client sends 10 commands rapidly; all appear in server's received data with\r\nterminators - [ ]
test_server_disconnect_during_read— Server sends partial text then closes; client detects EOF,is_connectedisFalse, partial text preserved in buffer
Dependency Graph¶
1.1 Package Scaffolding & Shared Types
│
├── 1.2 TelnetProtocol (depends on 1.1 for constants/types)
│
├── 1.3 ANSIStripper (depends on 1.1 for package structure)
│
├── 1.4 OutputBuffer (depends on 1.1 for PromptSignal, asyncio.Event)
│
└── 1.5 MUDClient (depends on 1.2 + 1.3 + 1.4)
│
└── 1.6 Integration Tests (depends on 1.5)
Sections 1.2, 1.3, and 1.4 can be implemented in parallel since they have no cross-dependencies. Section 1.5 requires all three. Section 1.6 requires 1.5.
Verification Checklist¶
After all sections complete:
- [ ]
uv run pytest packages/teltest/tests/ -v— all tests pass - [ ]
uv run mypy packages/teltest/src/teltest/ --strict— no type errors - [ ]
uv run ruff check packages/teltest/— no lint errors - [ ] Zero MAID imports anywhere in
packages/teltest/ - [ ] All public API types exported from
packages/teltest/src/teltest/__init__.py - [ ]
packages/teltest/pyproject.tomlhas zero runtime dependencies