Skip to content

Commit f3e8727

Browse files
committed
Contain main-thread question/todos render crashes
_ask_question_blocking is now a containment boundary: a crash while rendering a model-controlled question answers the pending question with an error string (the worker blocked in _ask_sync unblocks), and the message lands on the status bar instead of killing the TUI from the main thread. Non-string question prompts are coerced before rich rendering. Session.update_todos normalizes model-supplied todo items (non-dict items, non-str fields) and _todos_panel tolerates malformed data, fixing the same crash class for TodoWrite.
1 parent 80f439f commit f3e8727

6 files changed

Lines changed: 191 additions & 16 deletions

File tree

python_agent_harness/session/session.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,30 @@ def connect_mcp(self) -> list[tuple[str, str]]:
448448
# ToolContext-facing API
449449
# ------------------------------------------------------------------
450450
def update_todos(self, todos: list[dict]) -> None:
451-
"""Store TODOS so the pinned TUI panel shows the current list."""
452-
self.todos = list(todos)
451+
"""Store TODOS so the pinned TUI panel shows the current list.
452+
453+
Normalizes model-supplied items at the boundary: the panel and
454+
row-cap math on the main render thread do ``todo.get(...)``
455+
every frame, so anything that is not a {content, status}-like
456+
dict would crash the TUI there (outside every worker-side tool
457+
containment). Non-dict items and missing "content" become a
458+
deterministic string form instead.
459+
"""
460+
normalized: list[dict] = []
461+
if isinstance(todos, (list, tuple)):
462+
for t in todos:
463+
if isinstance(t, dict):
464+
item = dict(t)
465+
# coerce non-str fields: the panel does dict lookups
466+
# and str() rendering on the main thread every frame
467+
if "content" in item and not isinstance(item["content"], str):
468+
item["content"] = str(item["content"])
469+
if "status" in item and not isinstance(item["status"], str):
470+
item["status"] = str(item["status"])
471+
normalized.append(item)
472+
else:
473+
normalized.append({"content": repr(t), "status": ""})
474+
self.todos = normalized
453475
self.notify("todos")
454476

455477
def clear_todos(self) -> None:

python_agent_harness/tui/input.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ def _render_frame(self) -> Any: ...
354354

355355
def _flush(self) -> None: ...
356356

357+
def _on_log(self, msg: str) -> None: ...
358+
357359
def _input_prompt(self) -> FormattedText:
358360
"""Styled input prompt: short model name + caret.
359361
@@ -396,18 +398,52 @@ def _read_multiline(self) -> str | None:
396398
return text
397399

398400
def _ask_question_blocking(self) -> None:
401+
"""Answer the pending question on the main thread.
402+
403+
Containment boundary for the whole method: the question text is
404+
model-controlled, and a render/resolve crash here would kill the
405+
TUI from the main thread — outside every tool-containment
406+
try/except in the worker (the worker only blocks on
407+
``q.event.wait`` in ``_ask_sync``). On any unexpected exception
408+
the question is answered with an error string (the blocked
409+
worker unblocks and the failure reaches the model as a tool
410+
result instead of wedging it forever), and the message goes to
411+
the status bar.
412+
"""
399413
q = self.question
400414
if q is None:
401415
return
416+
try:
417+
answer = self._render_and_ask(q)
418+
except (EOFError, KeyboardInterrupt):
419+
raise
420+
except Exception as e: # noqa: BLE001 - containment boundary
421+
self.question = None
422+
q.answer = f"Error: question render failed — {e}"
423+
q.event.set()
424+
self._data_event.set()
425+
self._on_log(f"error: question render failed — {e}")
426+
return
427+
q.answer = answer
428+
q.event.set()
429+
self.question = None
430+
self._data_event.set() # re-render promptly after the answer
431+
432+
def _render_and_ask(self, q: UiQuestion) -> str:
433+
"""Render the question UI and read one answer (may raise)."""
402434
self.console.print(self._render_frame())
403435
self.console.print()
404436
self._flush()
437+
# prompt/options come from the model: coerce to str so a
438+
# non-string "question" field can never raise rich's
439+
# TypeError from Text.append / concatenation on the main thread
440+
prompt_text = str(q.prompt)
405441
options = q.options or []
406442
keys = q.keys or []
407443
if keys and options and len(keys) == len(options):
408444
# keyed choices (e.g. y/n confirm): type the key to pick —
409445
# same list look as the Question tool, keys instead of numbers
410-
self.console.print(Text(q.prompt))
446+
self.console.print(Text(prompt_text))
411447
for key, opt in zip(keys, options, strict=True):
412448
line = Text(f" {key}) ", style="cyan")
413449
line.append(opt)
@@ -419,7 +455,7 @@ def _ask_question_blocking(self) -> None:
419455
prompt = "> "
420456
elif options:
421457
# option labels get a numbered list: type the number to pick
422-
self.console.print(Text(q.prompt))
458+
self.console.print(Text(prompt_text))
423459
for i, opt in enumerate(options, 1):
424460
line = Text(f" {i}) ", style="cyan")
425461
line.append(opt)
@@ -430,19 +466,15 @@ def _ask_question_blocking(self) -> None:
430466
self.console.print(f"[dim]{hint}[/dim]")
431467
prompt = "> "
432468
else:
433-
prompt = q.prompt + " > "
469+
prompt = prompt_text + " > "
434470
try:
435471
with _safe_patch_stdout():
436472
answer = self.prompt_session.prompt(prompt, multiline=False)
437473
except (EOFError, KeyboardInterrupt):
438474
answer = ""
439475
if keys:
440-
q.answer = _resolve_keyed_choice(answer, options, keys)
441-
else:
442-
q.answer = _resolve_numbered_choice(answer, options)
443-
q.event.set()
444-
self.question = None
445-
self._data_event.set() # re-render promptly after the answer
476+
return _resolve_keyed_choice(answer, options, keys)
477+
return _resolve_numbered_choice(answer, options)
446478

447479
def _ask_sync(self, q: UiQuestion) -> str:
448480
"""Block the worker thread until the main thread answers.

python_agent_harness/tui/render.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,14 +270,26 @@ def _build_history_rows(self, full: bool = False) -> list[Any]:
270270

271271
def _todos_panel(self) -> Group | None:
272272
"""Todos section — rebuilt every frame (not cached), so a
273-
TodoWrite call shows up immediately even mid-run."""
273+
TodoWrite call shows up immediately even mid-run.
274+
275+
Defensive against non-dict items / non-str fields: todos come
276+
from the model, and this runs on the main render thread where a
277+
raise would kill the TUI (session.update_todos already
278+
normalizes at the boundary; this covers direct assignment).
279+
"""
274280
if not self._controller.todos:
275281
return None
276282
t = Table.grid(padding=(0, 1))
277-
for todo in self._controller.todos[-8:]:
278-
status = todo.get("status", "")
279-
mark = {"completed": "✅", "in_progress": "⏳", "pending": "⬜"}.get(status, "•")
280-
t.add_row(mark, todo.get("content", ""))
283+
for raw in self._controller.todos[-8:]:
284+
if isinstance(raw, dict):
285+
status = raw.get("status", "")
286+
if not isinstance(status, str):
287+
status = "" # dict.get needs a hashable key
288+
mark = {"completed": "✅", "in_progress": "⏳", "pending": "⬜"}.get(status, "•")
289+
content = str(raw.get("content", ""))
290+
else:
291+
mark, content = "•", repr(raw)
292+
t.add_row(mark, content)
281293
return Group(Text("Todos", style="bold"), t)
282294

283295
def _history_rows(self) -> list[Any]:

tests/test_session.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,40 @@ def test_clear_todos_drops_and_notifies(self):
770770
self.assertEqual(session.todos, [])
771771
self.assertEqual(notified, ["todos"])
772772

773+
def test_update_todos_normalizes_non_dict_items(self):
774+
"""Non-dict items (model schema violation) become deterministic
775+
string rows instead of crashing the render thread's todo.get."""
776+
session = RecordingSession()
777+
session.update_todos(["junk", 42, ["nested"], {"content": "real", "status": "pending"}])
778+
self.assertEqual(
779+
session.todos,
780+
[
781+
{"content": "'junk'", "status": ""},
782+
{"content": "42", "status": ""},
783+
{"content": "['nested']", "status": ""},
784+
{"content": "real", "status": "pending"},
785+
],
786+
)
787+
788+
def test_update_todos_normalizes_bad_content_and_status(self):
789+
"""Dicts with non-str content or non-str status are coerced in
790+
place; str fields pass through untouched."""
791+
session = RecordingSession()
792+
session.update_todos([{"content": 7, "status": "x"}, {"content": "ok", "status": 3}])
793+
self.assertEqual(
794+
session.todos,
795+
[{"content": "7", "status": "x"}, {"content": "ok", "status": "3"}],
796+
)
797+
798+
def test_update_todos_rejects_non_sequence(self):
799+
"""A model sending todos as a bare string/list-like junk stores
800+
an empty list instead of blowing up the render loop."""
801+
session = RecordingSession()
802+
session.update_todos("abc") # type: ignore[arg-type]
803+
self.assertEqual(session.todos, [])
804+
session.update_todos(None) # type: ignore[arg-type]
805+
self.assertEqual(session.todos, [])
806+
773807

774808
class TestPlanExit(unittest.TestCase):
775809
"""PlanExit: approved switches to build and queues the approved

tests/tui/test_tui_questions.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,66 @@ def test_option_label_coercion(self):
271271
self.assertEqual(_option_label(42), "42")
272272
self.assertEqual(_option_label(None), "None")
273273

274+
# ------------------------------------------------------------------
275+
# containment: a crash while rendering a model-controlled question
276+
# must resolve the pending question instead of killing the TUI
277+
# (the worker blocks on q.event in _ask_sync and only tool execution
278+
# is guarded — this render runs on the main thread)
279+
# ------------------------------------------------------------------
280+
def test_ask_question_crash_resolves_question_and_reports_error(self):
281+
"""A render crash inside _ask_question_blocking answers the
282+
question with an error string, clears tui.question, sets the
283+
worker's event, and surfaces the message on the status bar."""
284+
tui, _ = make_tui()
285+
q = UiQuestion("Pick", options=["a", "b"])
286+
tui.question = q
287+
with mock.patch.object(tui, "_render_and_ask", side_effect=TypeError("boom")):
288+
tui._ask_question_blocking()
289+
self.assertIsNone(tui.question)
290+
self.assertTrue(q.event.is_set())
291+
self.assertEqual(q.answer, "Error: question render failed — boom")
292+
self.assertIn("error", tui.status.lower())
293+
self.assertIn("question render failed", tui.status)
294+
295+
def test_ask_question_eof_propagates_out_of_containment(self):
296+
"""EOFError/KeyboardInterrupt pass through the containment: they
297+
are interactive-cancel signals, not render bugs, and the
298+
pending question stays for a later retry (run loop re-prompts)."""
299+
tui, _ = make_tui()
300+
q = UiQuestion("Pick", options=["a", "b"])
301+
tui.question = q
302+
with (
303+
mock.patch.object(tui, "_render_and_ask", side_effect=EOFError),
304+
self.assertRaises(EOFError),
305+
):
306+
tui._ask_question_blocking()
307+
self.assertIs(tui.question, q)
308+
self.assertIsNone(q.answer)
309+
self.assertFalse(q.event.is_set())
310+
311+
def test_ask_question_non_string_prompt_renders(self):
312+
"""A model sending a non-string 'question' field must not crash
313+
the render thread with rich's TypeError."""
314+
tui, buf = make_tui()
315+
q = UiQuestion(123, options=["a", "b"])
316+
tui.question = q
317+
with mock.patch.object(tui.prompt_session, "prompt", return_value="1"):
318+
tui._ask_question_blocking()
319+
self.assertEqual(q.answer, "a")
320+
self.assertIn("123", buf.getvalue())
321+
322+
def test_ask_question_still_answers_on_success_path(self):
323+
"""Success path unchanged after the containment refactor."""
324+
tui, buf = make_tui()
325+
q = UiQuestion("Proceed?", options=["y", "n"])
326+
tui.question = q
327+
with mock.patch.object(tui.prompt_session, "prompt", return_value="2"):
328+
tui._ask_question_blocking()
329+
self.assertEqual(q.answer, "n")
330+
self.assertIsNone(tui.question)
331+
self.assertTrue(q.event.is_set())
332+
self.assertIn("1) y", buf.getvalue())
333+
274334

275335
if __name__ == "__main__":
276336
unittest.main()

tests/tui/test_tui_render.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,21 @@ def test_todos_panel_hidden_when_empty(self):
892892
out = buf.getvalue()
893893
self.assertNotIn("Todos", out)
894894

895+
def test_todos_panel_survives_malformed_items(self):
896+
"""Regression: todos are model-controlled; a non-dict item or a
897+
non-str content/status (which bypasses the session normalizer
898+
via direct assignment) must not crash the main render thread."""
899+
tui, buf = make_tui()
900+
tui.session.todos = ["junk", 42, {"content": "real", "status": "pending"}] # type: ignore[list-item]
901+
tui.console.print(tui._render_frame())
902+
out = buf.getvalue()
903+
self.assertIn("Todos", out)
904+
self.assertIn("real", out)
905+
self.assertIn("'junk'", out)
906+
tui.session.todos = [{"content": "a", "status": ["weird", "list"]}] # type: ignore[dict-item]
907+
tui.console.print(tui._render_frame())
908+
self.assertIn("a", buf.getvalue())
909+
895910
# ------------------------------------------------------------------
896911
# status bar markers / compacted summary rendering
897912
# ------------------------------------------------------------------

0 commit comments

Comments
 (0)