Replay companion for ``resume_agent_background`` (Chunk F / WI-7.4). Reads the JSONL transcript line-by-line, parsing each into a Python object (typically a dict mirroring the original ``asdict(Message)`` payload). Callers with a typed Message hierarchy can hydrate the dicts back in
| 241 | |
| 242 | |
| 243 | class TranscriptReader: |
| 244 | """Replay companion for ``resume_agent_background`` (Chunk F / WI-7.4). |
| 245 | |
| 246 | Reads the JSONL transcript line-by-line, parsing each into a Python |
| 247 | object (typically a dict mirroring the original ``asdict(Message)`` |
| 248 | payload). Callers with a typed Message hierarchy can hydrate the |
| 249 | dicts back into ``AssistantMessage``/``UserMessage`` etc. via their |
| 250 | own factories — this reader stays loose-typed to avoid a cycle |
| 251 | between the agent module and the transcript module. |
| 252 | |
| 253 | Tolerant of: |
| 254 | * **Missing file** — yields nothing rather than raising. |
| 255 | * **Trailing partial line** — log-once-and-skip rather than poison |
| 256 | the entire history. Mirrors the chapter §"Mailbox" approach. |
| 257 | * **Embedded blank lines** — skipped. |
| 258 | |
| 259 | Defining the reader here (per critic concern C6) so Chunk F's |
| 260 | ``resume_agent_background`` SOLID DIP claim has a real interface |
| 261 | to depend on rather than the writer's IO layer. |
| 262 | """ |
| 263 | |
| 264 | def __init__(self, path: str | Path) -> None: |
| 265 | self._path = str(path) |
| 266 | self._logged_partial = False |
| 267 | |
| 268 | @property |
| 269 | def path(self) -> str: |
| 270 | return self._path |
| 271 | |
| 272 | def __iter__(self) -> Iterator[Any]: |
| 273 | return self._iterate() |
| 274 | |
| 275 | def _iterate(self) -> Iterator[Any]: |
| 276 | """Yield one parsed object per line; skip blank/unparseable lines.""" |
| 277 | try: |
| 278 | handle = open(self._path, "rb") |
| 279 | except FileNotFoundError: |
| 280 | return |
| 281 | try: |
| 282 | for raw_line in handle: |
| 283 | # raw_line still has its trailing newline; strip and skip |
| 284 | # blanks. Decode is utf-8 with replacement so a corrupt |
| 285 | # byte doesn't crash the iterator. |
| 286 | # |
| 287 | # N2 caveat (Chunk-D fold-in): ``errors="replace"`` maps |
| 288 | # corrupt bytes to U+FFFD. In theory a corrupted line |
| 289 | # could still parse as JSON if the U+FFFD substitutions |
| 290 | # land inside string literals — yielding "garbage but |
| 291 | # technically valid JSON". For chapter-10 transcripts |
| 292 | # the writer is the only producer and uses utf-8 |
| 293 | # round-trip, so the regime is safe; downstream replay |
| 294 | # consumers should validate message shape post-parse. |
| 295 | line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") |
| 296 | if not line: |
| 297 | continue |
| 298 | try: |
| 299 | yield json.loads(line) |
| 300 | except json.JSONDecodeError: |
no outgoing calls