@@ -2106,3 +2106,126 @@ async def drive() -> None:
21062106 c for c in transport .send_calls if "Auto-cancelled" in c ["message" ].text
21072107 ]
21082108 assert len (auto_cancel_msgs ) == 1
2109+
2110+
2111+ @pytest .mark .anyio
2112+ async def test_stall_suppressed_while_waiting_for_approval () -> None :
2113+ """Stall monitor uses longer threshold when pending approval action exists."""
2114+ transport = FakeTransport ()
2115+ presenter = _KeyboardPresenter ()
2116+ clock = _FakeClock (start = 100.0 )
2117+ edits = _make_edits (transport , presenter , clock = clock )
2118+ edits ._stall_check_interval = 0.01
2119+ edits ._STALL_THRESHOLD_SECONDS = 0.05
2120+ edits ._STALL_THRESHOLD_APPROVAL = 0.5 # 500ms for test
2121+
2122+ # Simulate a pending approval action (has inline_keyboard in detail)
2123+ from untether .model import Action , ActionEvent
2124+
2125+ evt = ActionEvent (
2126+ engine = "codex" ,
2127+ action = Action (
2128+ id = "ctrl.1" ,
2129+ kind = "warning" ,
2130+ title = "Permission Request [CanUseTool] - tool: ExitPlanMode" ,
2131+ detail = {"inline_keyboard" : {"buttons" : [[{"text" : "Approve" }]]}},
2132+ ),
2133+ phase = "started" ,
2134+ )
2135+ await edits .on_event (evt )
2136+ clock .set (100.0 ) # reset after on_event
2137+
2138+ async with anyio .create_task_group () as tg :
2139+
2140+ async def drive () -> None :
2141+ # Advance past normal threshold (0.05) but NOT past approval threshold (0.5)
2142+ clock .set (100.2 )
2143+ await anyio .sleep (0.05 )
2144+ # Should NOT have warned yet
2145+ edits .signal_send .close ()
2146+
2147+ tg .start_soon (edits .run )
2148+ tg .start_soon (drive )
2149+
2150+ # No stall warning should have fired
2151+ assert edits ._stall_warn_count == 0
2152+ stall_msgs = [c for c in transport .send_calls if "No progress" in c ["message" ].text ]
2153+ assert len (stall_msgs ) == 0
2154+
2155+
2156+ @pytest .mark .anyio
2157+ async def test_stall_fires_after_approval_threshold () -> None :
2158+ """Stall monitor fires after the longer approval threshold is exceeded."""
2159+ transport = FakeTransport ()
2160+ presenter = _KeyboardPresenter ()
2161+ clock = _FakeClock (start = 100.0 )
2162+ edits = _make_edits (transport , presenter , clock = clock )
2163+ edits ._stall_check_interval = 0.01
2164+ edits ._STALL_THRESHOLD_SECONDS = 0.05
2165+ edits ._STALL_THRESHOLD_APPROVAL = 0.1 # short for test
2166+
2167+ from untether .model import Action , ActionEvent
2168+
2169+ evt = ActionEvent (
2170+ engine = "codex" ,
2171+ action = Action (
2172+ id = "ctrl.1" ,
2173+ kind = "warning" ,
2174+ title = "Permission Request [CanUseTool] - tool: Bash" ,
2175+ detail = {"inline_keyboard" : {"buttons" : [[{"text" : "Approve" }]]}},
2176+ ),
2177+ phase = "started" ,
2178+ )
2179+ await edits .on_event (evt )
2180+ clock .set (100.0 )
2181+
2182+ async with anyio .create_task_group () as tg :
2183+
2184+ async def drive () -> None :
2185+ # Advance past the approval threshold (0.1)
2186+ clock .set (100.2 )
2187+ await anyio .sleep (0.05 )
2188+ edits .signal_send .close ()
2189+
2190+ tg .start_soon (edits .run )
2191+ tg .start_soon (drive )
2192+
2193+ assert edits ._stall_warn_count >= 1
2194+ stall_msgs = [c for c in transport .send_calls if "No progress" in c ["message" ].text ]
2195+ assert len (stall_msgs ) >= 1
2196+
2197+
2198+ @pytest .mark .anyio
2199+ async def test_stall_normal_threshold_without_approval () -> None :
2200+ """Stall monitor uses normal threshold when no pending approval."""
2201+ transport = FakeTransport ()
2202+ presenter = _KeyboardPresenter ()
2203+ clock = _FakeClock (start = 100.0 )
2204+ edits = _make_edits (transport , presenter , clock = clock )
2205+ edits ._stall_check_interval = 0.01
2206+ edits ._STALL_THRESHOLD_SECONDS = 0.05
2207+ edits ._STALL_THRESHOLD_APPROVAL = 10.0 # very long, shouldn't matter
2208+
2209+ # No pending approval — normal action without inline_keyboard
2210+ from untether .model import Action , ActionEvent
2211+
2212+ evt = ActionEvent (
2213+ engine = "codex" ,
2214+ action = Action (id = "a1" , kind = "tool" , title = "Bash" ),
2215+ phase = "started" ,
2216+ )
2217+ await edits .on_event (evt )
2218+ clock .set (100.0 )
2219+
2220+ async with anyio .create_task_group () as tg :
2221+
2222+ async def drive () -> None :
2223+ clock .set (100.1 ) # past normal threshold (0.05)
2224+ await anyio .sleep (0.05 )
2225+ edits .signal_send .close ()
2226+
2227+ tg .start_soon (edits .run )
2228+ tg .start_soon (drive )
2229+
2230+ # Should have warned using the normal threshold
2231+ assert edits ._stall_warn_count >= 1
0 commit comments