diff --git a/hermes_progress_tail/runtime/clarify.py b/hermes_progress_tail/runtime/clarify.py index e469a0c..073bd1c 100644 --- a/hermes_progress_tail/runtime/clarify.py +++ b/hermes_progress_tail/runtime/clarify.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import json import logging import re @@ -11,7 +10,7 @@ from ..models.decision import CheckpointStatus from ..utils.redaction import redact_text -from ..utils.scheduling import create_task_or_close, submit_or_close +from ..utils.scheduling import create_task_or_close, on_loop_thread, submit_or_close logger = logging.getLogger(__name__) @@ -38,13 +37,6 @@ def _usable_loop(ctx: Any) -> Any | None: return loop -def _on_loop(loop: Any) -> bool: - try: - return asyncio.get_running_loop() is loop - except RuntimeError: - return False - - def _diagnostic(label: str, exc: BaseException | str) -> None: text = _CONTROL.sub(" ", redact_text(str(exc))) logger.debug("hermes-progress-tail clarify %s: %s", label, " ".join(text.split())[:160]) @@ -69,7 +61,7 @@ def run_clarify_freeze_barrier( return False guard = FreezeAttemptGuard(threading.Event()) try: - if _on_loop(loop): + if on_loop_thread(loop): create_task_or_close(loop, operation(guard)).add_done_callback( _consume("same-loop freeze failed") ) @@ -90,7 +82,7 @@ def run_clarify_resolution_bridge(ctx: Any, operation: Callable[[], Awaitable[bo if loop is None: return try: - if _on_loop(loop): + if on_loop_thread(loop): create_task_or_close(loop, operation()).add_done_callback( _consume("same-loop resolution failed") ) diff --git a/hermes_progress_tail/runtime/tool_events.py b/hermes_progress_tail/runtime/tool_events.py index 8843d1c..6915523 100644 --- a/hermes_progress_tail/runtime/tool_events.py +++ b/hermes_progress_tail/runtime/tool_events.py @@ -18,7 +18,7 @@ ToolEvent, ) from ..rendering.formatter import extract_todo_items, format_tool_line -from ..utils.scheduling import create_task_or_close, submit_or_close +from ..utils.scheduling import create_task_or_close, schedule_task_on_loop, submit_or_close from .clarify import ( classify_clarify_result, run_clarify_freeze_barrier, @@ -199,7 +199,7 @@ async def _cleanup() -> None: force=True, ) - create_task_or_close(ctx.loop, _cleanup()) + schedule_task_on_loop(ctx.loop, lambda: create_task_or_close(ctx.loop, _cleanup())) def _schedule_background_job_poll(ctx: SessionContext, process_id: str) -> None: @@ -210,11 +210,11 @@ def _schedule_background_job_poll(ctx: SessionContext, process_id: str) -> None: or not runtime_plugin.get_renderer().settings.background_jobs.enabled ): return - job = ctx.background.jobs.get(process_id) - if job is not None and job.poll_task is not None and not job.poll_task.done(): - return async def _poll() -> None: + current = ctx.background.jobs.get(process_id) + if current is not None and current.poll_task is None: + current.poll_task = asyncio.current_task() try: while True: await asyncio.sleep( @@ -267,10 +267,17 @@ async def _poll() -> None: except Exception: logger.debug("hermes-progress-tail background job poll failed", exc_info=True) - task = create_task_or_close(ctx.loop, _poll()) - job = ctx.background.jobs.get(process_id) - if job is not None: - job.poll_task = task + def _start() -> asyncio.Task[Any]: + job = ctx.background.jobs.get(process_id) + if job is not None and job.poll_task is not None and not job.poll_task.done(): + return job.poll_task + task = create_task_or_close(ctx.loop, _poll()) + job = ctx.background.jobs.get(process_id) + if job is not None: + job.poll_task = task + return task + + schedule_task_on_loop(ctx.loop, _start) def _is_context_owner_thread(ctx: SessionContext) -> bool: diff --git a/hermes_progress_tail/utils/scheduling.py b/hermes_progress_tail/utils/scheduling.py index 57d30ab..1d3ad8f 100644 --- a/hermes_progress_tail/utils/scheduling.py +++ b/hermes_progress_tail/utils/scheduling.py @@ -1,11 +1,18 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from concurrent.futures import Future from typing import Any +def on_loop_thread(loop: asyncio.AbstractEventLoop) -> bool: + try: + return asyncio.get_running_loop() is loop + except RuntimeError: + return False + + def submit_or_close(coro: Coroutine[Any, Any, Any], loop: asyncio.AbstractEventLoop) -> Future[Any]: try: return asyncio.run_coroutine_threadsafe(coro, loop) @@ -22,3 +29,12 @@ def create_task_or_close( except BaseException: coro.close() raise + + +def schedule_task_on_loop( + loop: asyncio.AbstractEventLoop, start: Callable[[], asyncio.Task[Any]] +) -> asyncio.Task[Any] | None: + if on_loop_thread(loop): + return start() + loop.call_soon_threadsafe(start) + return None diff --git a/tests/fixtures/session_context_constructors.json b/tests/fixtures/session_context_constructors.json index 6b1b82c..25be2eb 100644 --- a/tests/fixtures/session_context_constructors.json +++ b/tests/fixtures/session_context_constructors.json @@ -1 +1 @@ -{"aliases":[],"calls":[{"callee":"SessionContext","keywords":["adapter","chat_id","loop","owner_thread_id","owner_thread_name","platform","routing","session_id","session_key","thread_id"],"kind":"direct","line":249,"path":"hermes_progress_tail/runtime/context.py","positional_arity":0},{"callee":"SessionContext","keywords":["routing"],"kind":"direct","line":23,"path":"hermes_progress_tail/runtime/demo.py","positional_arity":7},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":99,"path":"tests/support/rendering.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":44,"path":"tests/test_adapter_rebuild_recovery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":218,"path":"tests/test_announcements_and_status.py","positional_arity":8},{"callee":"SessionContext","keywords":["environment","timestamp"],"kind":"direct","line":27,"path":"tests/test_announcements_and_status.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":100,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":117,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":141,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":159,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":189,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":224,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":353,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":382,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":58,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":89,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":20,"path":"tests/test_background_jobs_and_fencing.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":14,"path":"tests/test_background_jobs_and_fencing_part2.py","positional_arity":8},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":43,"path":"tests/test_c10_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":18,"path":"tests/test_c11_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":21,"path":"tests/test_c7_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":19,"path":"tests/test_c8_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":35,"path":"tests/test_c9_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":38,"path":"tests/test_clarify_checkpoint_hooks.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":15,"path":"tests/test_clarify_checkpoint_rendering.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":104,"path":"tests/test_clarify_checkpoint_telegram.py","positional_arity":8},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":28,"path":"tests/test_clarify_decision_archive.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":130,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":161,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":38,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":75,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":54,"path":"tests/test_compression_lifecycle.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":251,"path":"tests/test_delegate_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":13,"path":"tests/test_delegate_readability.py","positional_arity":8},{"callee":"SessionContext","keywords":["edit_interval","timestamp"],"kind":"direct","line":152,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":207,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":22,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":287,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":88,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":21,"path":"tests/test_event_reducer.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":39,"path":"tests/test_event_reducer_c4c.py","positional_arity":8},{"callee":"SessionContext","keywords":["source_message_id","timestamp"],"kind":"direct","line":44,"path":"tests/test_finalization.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":25,"path":"tests/test_focused_live_markdown.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":25,"path":"tests/test_focused_rendering.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id","timestamp"],"kind":"direct","line":51,"path":"tests/test_gateway_interrupt_monkeypatch.py","positional_arity":0},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":42,"path":"tests/test_hooks.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","chat_type","loop","platform","session_id","session_key","thread_id"],"kind":"direct","line":60,"path":"tests/test_hooks.py","positional_arity":0},{"callee":"SessionContext","keywords":["adapter","chat_id","chat_type","loop","platform","session_id","session_key","thread_id"],"kind":"direct","line":78,"path":"tests/test_hooks.py","positional_arity":0},{"callee":"SessionContext","keywords":[],"kind":"direct","line":189,"path":"tests/test_plugin_warnings.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":60,"path":"tests/test_reasoning.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":88,"path":"tests/test_reasoning.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":183,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":236,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":253,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":312,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":111,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":153,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":218,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":263,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":278,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":310,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":333,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":348,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":63,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":490,"path":"tests/test_renderer_composition.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id"],"kind":"direct","line":491,"path":"tests/test_renderer_composition.py","positional_arity":0},{"callee":"SessionContext","keywords":["timestamp","timestamp_format"],"kind":"direct","line":118,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":277,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":301,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":98,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":188,"path":"tests/test_renderer_part4.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":200,"path":"tests/test_renderer_part4.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":126,"path":"tests/test_rendering_dependency_boundaries.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":92,"path":"tests/test_rendering_dependency_boundaries.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":106,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":133,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":37,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":65,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":96,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":33,"path":"tests/test_runtime_background_process_polling.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":10,"path":"tests/test_runtime_environment_metadata.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":16,"path":"tests/test_runtime_tool_event_results.py","positional_arity":7},{"callee":"SessionContext","keywords":["lines","source_message_id"],"kind":"direct","line":56,"path":"tests/test_session_registry.py","positional_arity":8},{"callee":"state.SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id"],"kind":"qualified","line":16,"path":"tests/test_session_state_compatibility.py","positional_arity":0},{"callee":"state.SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","thread_id"],"kind":"qualified","line":32,"path":"tests/test_session_state_compatibility.py","positional_arity":0},{"callee":"state.SessionContext","keywords":["**"],"kind":"qualified","line":9,"path":"tests/test_session_state_compatibility.py","positional_arity":8},{"callee":"state.SessionContext","keywords":["assistant"],"kind":"qualified","line":391,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"state.SessionContext","keywords":["reasoning"],"kind":"qualified","line":392,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"state.SessionContext","keywords":["background","delegate","delivery","diagnostics","routing"],"kind":"qualified","line":398,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"SessionContext","keywords":["environment","timestamp"],"kind":"direct","line":32,"path":"tests/test_sticky_footer.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":114,"path":"tests/test_telegram_rich_renderer.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","thread_id","timestamp","tools_enabled"],"kind":"direct","line":155,"path":"tests/test_tool_hook_routing.py","positional_arity":0}],"helpers":[{"line":233,"name":"_register_context","path":"hermes_progress_tail/runtime/context.py"},{"line":10,"name":"_demo_command","path":"hermes_progress_tail/runtime/demo.py"},{"line":92,"name":"make_live_context","path":"tests/support/rendering.py"},{"line":43,"name":"make_ctx","path":"tests/test_adapter_rebuild_recovery.py"},{"line":215,"name":"test_runtime_environment_extracts_reasoning_effort_from_agent","path":"tests/test_announcements_and_status.py"},{"line":26,"name":"make_ctx","path":"tests/test_announcements_and_status.py"},{"line":112,"name":"run","path":"tests/test_assistant_progress.py"},{"line":136,"name":"run","path":"tests/test_assistant_progress.py"},{"line":155,"name":"test_assistant_progress_uses_private_gateway_session_key","path":"tests/test_assistant_progress.py"},{"line":185,"name":"test_assistant_progress_renders_already_streamed_but_does_not_suppress_native","path":"tests/test_assistant_progress.py"},{"line":218,"name":"test_monkeypatch_falls_back_to_original_interim_send_when_render_cannot_schedule","path":"tests/test_assistant_progress.py"},{"line":349,"name":"test_assistant_progress_falls_back_to_unique_session_context","path":"tests/test_assistant_progress.py"},{"line":380,"name":"test_pre_tool_call_does_not_emit_fake_assistant_progress","path":"tests/test_assistant_progress.py"},{"line":45,"name":"run","path":"tests/test_assistant_progress.py"},{"line":78,"name":"run","path":"tests/test_assistant_progress.py"},{"line":19,"name":"make_ctx","path":"tests/test_background_jobs_and_fencing.py"},{"line":13,"name":"make_ctx","path":"tests/test_background_jobs_and_fencing_part2.py"},{"line":42,"name":"make_context","path":"tests/test_c10_baseline_compatibility.py"},{"line":17,"name":"_context","path":"tests/test_c11_baseline_compatibility.py"},{"line":20,"name":"context","path":"tests/test_c7_baseline_compatibility.py"},{"line":18,"name":"context","path":"tests/test_c8_baseline_compatibility.py"},{"line":34,"name":"make_context","path":"tests/test_c9_baseline_compatibility.py"},{"line":37,"name":"_ctx","path":"tests/test_clarify_checkpoint_hooks.py"},{"line":14,"name":"context","path":"tests/test_clarify_checkpoint_rendering.py"},{"line":103,"name":"context","path":"tests/test_clarify_checkpoint_telegram.py"},{"line":27,"name":"context","path":"tests/test_clarify_decision_archive.py"},{"line":117,"name":"test_hard_saturation_drops_oldest_context_not_newest","path":"tests/test_clarify_live_context.py"},{"line":160,"name":"test_checkpoint_without_live_context_keeps_legacy_shape","path":"tests/test_clarify_live_context.py"},{"line":37,"name":"test_checkpoint_includes_live_reasoning_and_progress_context","path":"tests/test_clarify_live_context.py"},{"line":61,"name":"test_saturated_checkpoint_keeps_newest_reasoning_progress_and_fits_rich_limit","path":"tests/test_clarify_live_context.py"},{"line":53,"name":"make_ctx","path":"tests/test_compression_lifecycle.py"},{"line":250,"name":"make_ctx","path":"tests/test_delegate_progress.py"},{"line":12,"name":"make_ctx","path":"tests/test_delegate_readability.py"},{"line":10,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":140,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":195,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":271,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":76,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":20,"name":"_context","path":"tests/test_event_reducer.py"},{"line":38,"name":"context","path":"tests/test_event_reducer_c4c.py"},{"line":36,"name":"make_ctx","path":"tests/test_finalization.py"},{"line":24,"name":"make_ctx","path":"tests/test_focused_live_markdown.py"},{"line":24,"name":"make_ctx","path":"tests/test_focused_rendering.py"},{"line":50,"name":"make_ctx","path":"tests/test_gateway_interrupt_monkeypatch.py"},{"line":41,"name":"test_session_context_positional_strategy_compatibility","path":"tests/test_hooks.py"},{"line":59,"name":"test_telegram_dm_metadata_omits_direct_topic_for_general_topic","path":"tests/test_hooks.py"},{"line":77,"name":"test_telegram_dm_metadata_supports_topic_without_reply_anchor","path":"tests/test_hooks.py"},{"line":171,"name":"test_doctor_does_not_warn_when_native_gateway_config_is_normal","path":"tests/test_plugin_warnings.py"},{"line":48,"name":"run","path":"tests/test_reasoning.py"},{"line":76,"name":"run","path":"tests/test_reasoning.py"},{"line":173,"name":"test_gpt55_live_reasoning_buffer_preserves_heading_when_max_chars_truncates","path":"tests/test_reasoning_normalization.py"},{"line":230,"name":"test_very_long_gpt55_live_reasoning_buffer_preserves_latest_heading","path":"tests/test_reasoning_normalization.py"},{"line":247,"name":"test_very_long_colon_heading_live_reasoning_buffer_preserves_latest_heading","path":"tests/test_reasoning_normalization.py"},{"line":300,"name":"run","path":"tests/test_reasoning_normalization.py"},{"line":110,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":150,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":216,"name":"test_gpt56_split_comment_deltas_preserve_heading_boundary","path":"tests/test_reasoning_part2.py"},{"line":255,"name":"test_gpt56_separator_survives_every_two_delta_boundary","path":"tests/test_reasoning_part2.py"},{"line":270,"name":"test_gpt56_separator_survives_buffer_trim_at_every_split_boundary","path":"tests/test_reasoning_part2.py"},{"line":299,"name":"test_gpt56_streamed_fenced_comment_preserves_every_two_delta_boundary","path":"tests/test_reasoning_part2.py"},{"line":319,"name":"test_gpt56_bounded_fenced_comment_preserves_fence_context","path":"tests/test_reasoning_part2.py"},{"line":344,"name":"test_gpt56_bounded_oversized_partial_separator_whitespace_respects_cap","path":"tests/test_reasoning_part2.py"},{"line":62,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":488,"name":"test_characterization_session_context_constructor_repr_equality_and_surfaces","path":"tests/test_renderer_composition.py"},{"line":115,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":266,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":295,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":93,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":181,"name":"run","path":"tests/test_renderer_part4.py"},{"line":113,"name":"test_standalone_renderer_never_uses_runtime_release_lookup","path":"tests/test_rendering_dependency_boundaries.py"},{"line":76,"name":"test_provider_one_call_and_exception_safety_in_both_modes","path":"tests/test_rendering_dependency_boundaries.py"},{"line":122,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":32,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":60,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":84,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":32,"name":"_ctx","path":"tests/test_runtime_background_process_polling.py"},{"line":9,"name":"ctx","path":"tests/test_runtime_environment_metadata.py"},{"line":15,"name":"_ctx","path":"tests/test_runtime_tool_event_results.py"},{"line":55,"name":"context","path":"tests/test_session_registry.py"},{"line":14,"name":"test_legacy_positional_and_keyword_construction","path":"tests/test_session_state_compatibility.py"},{"line":31,"name":"test_all_keyword_required_construction_remains_legal","path":"tests/test_session_state_compatibility.py"},{"line":8,"name":"positional","path":"tests/test_session_state_compatibility.py"},{"line":387,"name":"test_explicit_owner_construction_preserves_identity","path":"tests/test_session_state_ownership.py"},{"line":31,"name":"make_ctx","path":"tests/test_sticky_footer.py"},{"line":113,"name":"make_ctx","path":"tests/test_telegram_rich_renderer.py"},{"line":136,"name":"run","path":"tests/test_tool_hook_routing.py"}]} +{"aliases":[],"calls":[{"callee":"SessionContext","keywords":["adapter","chat_id","loop","owner_thread_id","owner_thread_name","platform","routing","session_id","session_key","thread_id"],"kind":"direct","line":249,"path":"hermes_progress_tail/runtime/context.py","positional_arity":0},{"callee":"SessionContext","keywords":["routing"],"kind":"direct","line":23,"path":"hermes_progress_tail/runtime/demo.py","positional_arity":7},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":99,"path":"tests/support/rendering.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":44,"path":"tests/test_adapter_rebuild_recovery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":218,"path":"tests/test_announcements_and_status.py","positional_arity":8},{"callee":"SessionContext","keywords":["environment","timestamp"],"kind":"direct","line":27,"path":"tests/test_announcements_and_status.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":100,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":117,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":141,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":159,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":189,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":224,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":353,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":382,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":58,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":89,"path":"tests/test_assistant_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":20,"path":"tests/test_background_jobs_and_fencing.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":14,"path":"tests/test_background_jobs_and_fencing_part2.py","positional_arity":8},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":43,"path":"tests/test_c10_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":18,"path":"tests/test_c11_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":21,"path":"tests/test_c7_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":19,"path":"tests/test_c8_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":35,"path":"tests/test_c9_baseline_compatibility.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":38,"path":"tests/test_clarify_checkpoint_hooks.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":15,"path":"tests/test_clarify_checkpoint_rendering.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":104,"path":"tests/test_clarify_checkpoint_telegram.py","positional_arity":8},{"callee":"SessionContext","keywords":["**"],"kind":"direct","line":28,"path":"tests/test_clarify_decision_archive.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":130,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":161,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":38,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["agent_label"],"kind":"direct","line":75,"path":"tests/test_clarify_live_context.py","positional_arity":7},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":54,"path":"tests/test_compression_lifecycle.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":251,"path":"tests/test_delegate_progress.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":13,"path":"tests/test_delegate_readability.py","positional_arity":8},{"callee":"SessionContext","keywords":["edit_interval","timestamp"],"kind":"direct","line":152,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":207,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":22,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":287,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":88,"path":"tests/test_delegate_text_stream.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":21,"path":"tests/test_event_reducer.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":39,"path":"tests/test_event_reducer_c4c.py","positional_arity":8},{"callee":"SessionContext","keywords":["source_message_id","timestamp"],"kind":"direct","line":44,"path":"tests/test_finalization.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":25,"path":"tests/test_focused_live_markdown.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":25,"path":"tests/test_focused_rendering.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id","timestamp"],"kind":"direct","line":51,"path":"tests/test_gateway_interrupt_monkeypatch.py","positional_arity":0},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":42,"path":"tests/test_hooks.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","chat_type","loop","platform","session_id","session_key","thread_id"],"kind":"direct","line":60,"path":"tests/test_hooks.py","positional_arity":0},{"callee":"SessionContext","keywords":["adapter","chat_id","chat_type","loop","platform","session_id","session_key","thread_id"],"kind":"direct","line":78,"path":"tests/test_hooks.py","positional_arity":0},{"callee":"SessionContext","keywords":[],"kind":"direct","line":189,"path":"tests/test_plugin_warnings.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":60,"path":"tests/test_reasoning.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":88,"path":"tests/test_reasoning.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":183,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":236,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":253,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":312,"path":"tests/test_reasoning_normalization.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":111,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":153,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":218,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":263,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":278,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":310,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":333,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":348,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":63,"path":"tests/test_reasoning_part2.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":490,"path":"tests/test_renderer_composition.py","positional_arity":8},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id"],"kind":"direct","line":491,"path":"tests/test_renderer_composition.py","positional_arity":0},{"callee":"SessionContext","keywords":["timestamp","timestamp_format"],"kind":"direct","line":118,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":277,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":301,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":98,"path":"tests/test_renderer_delivery.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":188,"path":"tests/test_renderer_part4.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":200,"path":"tests/test_renderer_part4.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":126,"path":"tests/test_rendering_dependency_boundaries.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":92,"path":"tests/test_rendering_dependency_boundaries.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":106,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":133,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":37,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":65,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":96,"path":"tests/test_reviewer_blockers.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":36,"path":"tests/test_runtime_background_process_polling.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":10,"path":"tests/test_runtime_environment_metadata.py","positional_arity":7},{"callee":"SessionContext","keywords":[],"kind":"direct","line":16,"path":"tests/test_runtime_tool_event_results.py","positional_arity":7},{"callee":"SessionContext","keywords":["lines","source_message_id"],"kind":"direct","line":56,"path":"tests/test_session_registry.py","positional_arity":8},{"callee":"state.SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","strategy","thread_id"],"kind":"qualified","line":16,"path":"tests/test_session_state_compatibility.py","positional_arity":0},{"callee":"state.SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","thread_id"],"kind":"qualified","line":32,"path":"tests/test_session_state_compatibility.py","positional_arity":0},{"callee":"state.SessionContext","keywords":["**"],"kind":"qualified","line":9,"path":"tests/test_session_state_compatibility.py","positional_arity":8},{"callee":"state.SessionContext","keywords":["assistant"],"kind":"qualified","line":391,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"state.SessionContext","keywords":["reasoning"],"kind":"qualified","line":392,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"state.SessionContext","keywords":["background","delegate","delivery","diagnostics","routing"],"kind":"qualified","line":398,"path":"tests/test_session_state_ownership.py","positional_arity":7},{"callee":"SessionContext","keywords":["environment","timestamp"],"kind":"direct","line":32,"path":"tests/test_sticky_footer.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":114,"path":"tests/test_telegram_rich_renderer.py","positional_arity":8},{"callee":"SessionContext","keywords":["timestamp"],"kind":"direct","line":156,"path":"tests/test_thread_safe_scheduling.py","positional_arity":8},{"callee":"SessionContext","keywords":[],"kind":"direct","line":87,"path":"tests/test_thread_safe_scheduling.py","positional_arity":7},{"callee":"SessionContext","keywords":["adapter","chat_id","loop","platform","session_id","session_key","thread_id","timestamp","tools_enabled"],"kind":"direct","line":155,"path":"tests/test_tool_hook_routing.py","positional_arity":0}],"helpers":[{"line":233,"name":"_register_context","path":"hermes_progress_tail/runtime/context.py"},{"line":10,"name":"_demo_command","path":"hermes_progress_tail/runtime/demo.py"},{"line":92,"name":"make_live_context","path":"tests/support/rendering.py"},{"line":43,"name":"make_ctx","path":"tests/test_adapter_rebuild_recovery.py"},{"line":215,"name":"test_runtime_environment_extracts_reasoning_effort_from_agent","path":"tests/test_announcements_and_status.py"},{"line":26,"name":"make_ctx","path":"tests/test_announcements_and_status.py"},{"line":112,"name":"run","path":"tests/test_assistant_progress.py"},{"line":136,"name":"run","path":"tests/test_assistant_progress.py"},{"line":155,"name":"test_assistant_progress_uses_private_gateway_session_key","path":"tests/test_assistant_progress.py"},{"line":185,"name":"test_assistant_progress_renders_already_streamed_but_does_not_suppress_native","path":"tests/test_assistant_progress.py"},{"line":218,"name":"test_monkeypatch_falls_back_to_original_interim_send_when_render_cannot_schedule","path":"tests/test_assistant_progress.py"},{"line":349,"name":"test_assistant_progress_falls_back_to_unique_session_context","path":"tests/test_assistant_progress.py"},{"line":380,"name":"test_pre_tool_call_does_not_emit_fake_assistant_progress","path":"tests/test_assistant_progress.py"},{"line":45,"name":"run","path":"tests/test_assistant_progress.py"},{"line":78,"name":"run","path":"tests/test_assistant_progress.py"},{"line":19,"name":"make_ctx","path":"tests/test_background_jobs_and_fencing.py"},{"line":13,"name":"make_ctx","path":"tests/test_background_jobs_and_fencing_part2.py"},{"line":42,"name":"make_context","path":"tests/test_c10_baseline_compatibility.py"},{"line":17,"name":"_context","path":"tests/test_c11_baseline_compatibility.py"},{"line":20,"name":"context","path":"tests/test_c7_baseline_compatibility.py"},{"line":18,"name":"context","path":"tests/test_c8_baseline_compatibility.py"},{"line":34,"name":"make_context","path":"tests/test_c9_baseline_compatibility.py"},{"line":37,"name":"_ctx","path":"tests/test_clarify_checkpoint_hooks.py"},{"line":14,"name":"context","path":"tests/test_clarify_checkpoint_rendering.py"},{"line":103,"name":"context","path":"tests/test_clarify_checkpoint_telegram.py"},{"line":27,"name":"context","path":"tests/test_clarify_decision_archive.py"},{"line":117,"name":"test_hard_saturation_drops_oldest_context_not_newest","path":"tests/test_clarify_live_context.py"},{"line":160,"name":"test_checkpoint_without_live_context_keeps_legacy_shape","path":"tests/test_clarify_live_context.py"},{"line":37,"name":"test_checkpoint_includes_live_reasoning_and_progress_context","path":"tests/test_clarify_live_context.py"},{"line":61,"name":"test_saturated_checkpoint_keeps_newest_reasoning_progress_and_fits_rich_limit","path":"tests/test_clarify_live_context.py"},{"line":53,"name":"make_ctx","path":"tests/test_compression_lifecycle.py"},{"line":250,"name":"make_ctx","path":"tests/test_delegate_progress.py"},{"line":12,"name":"make_ctx","path":"tests/test_delegate_readability.py"},{"line":10,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":140,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":195,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":271,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":76,"name":"run","path":"tests/test_delegate_text_stream.py"},{"line":20,"name":"_context","path":"tests/test_event_reducer.py"},{"line":38,"name":"context","path":"tests/test_event_reducer_c4c.py"},{"line":36,"name":"make_ctx","path":"tests/test_finalization.py"},{"line":24,"name":"make_ctx","path":"tests/test_focused_live_markdown.py"},{"line":24,"name":"make_ctx","path":"tests/test_focused_rendering.py"},{"line":50,"name":"make_ctx","path":"tests/test_gateway_interrupt_monkeypatch.py"},{"line":41,"name":"test_session_context_positional_strategy_compatibility","path":"tests/test_hooks.py"},{"line":59,"name":"test_telegram_dm_metadata_omits_direct_topic_for_general_topic","path":"tests/test_hooks.py"},{"line":77,"name":"test_telegram_dm_metadata_supports_topic_without_reply_anchor","path":"tests/test_hooks.py"},{"line":171,"name":"test_doctor_does_not_warn_when_native_gateway_config_is_normal","path":"tests/test_plugin_warnings.py"},{"line":48,"name":"run","path":"tests/test_reasoning.py"},{"line":76,"name":"run","path":"tests/test_reasoning.py"},{"line":173,"name":"test_gpt55_live_reasoning_buffer_preserves_heading_when_max_chars_truncates","path":"tests/test_reasoning_normalization.py"},{"line":230,"name":"test_very_long_gpt55_live_reasoning_buffer_preserves_latest_heading","path":"tests/test_reasoning_normalization.py"},{"line":247,"name":"test_very_long_colon_heading_live_reasoning_buffer_preserves_latest_heading","path":"tests/test_reasoning_normalization.py"},{"line":300,"name":"run","path":"tests/test_reasoning_normalization.py"},{"line":110,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":150,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":216,"name":"test_gpt56_split_comment_deltas_preserve_heading_boundary","path":"tests/test_reasoning_part2.py"},{"line":255,"name":"test_gpt56_separator_survives_every_two_delta_boundary","path":"tests/test_reasoning_part2.py"},{"line":270,"name":"test_gpt56_separator_survives_buffer_trim_at_every_split_boundary","path":"tests/test_reasoning_part2.py"},{"line":299,"name":"test_gpt56_streamed_fenced_comment_preserves_every_two_delta_boundary","path":"tests/test_reasoning_part2.py"},{"line":319,"name":"test_gpt56_bounded_fenced_comment_preserves_fence_context","path":"tests/test_reasoning_part2.py"},{"line":344,"name":"test_gpt56_bounded_oversized_partial_separator_whitespace_respects_cap","path":"tests/test_reasoning_part2.py"},{"line":62,"name":"find_context","path":"tests/test_reasoning_part2.py"},{"line":488,"name":"test_characterization_session_context_constructor_repr_equality_and_surfaces","path":"tests/test_renderer_composition.py"},{"line":115,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":266,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":295,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":93,"name":"run","path":"tests/test_renderer_delivery.py"},{"line":181,"name":"run","path":"tests/test_renderer_part4.py"},{"line":113,"name":"test_standalone_renderer_never_uses_runtime_release_lookup","path":"tests/test_rendering_dependency_boundaries.py"},{"line":76,"name":"test_provider_one_call_and_exception_safety_in_both_modes","path":"tests/test_rendering_dependency_boundaries.py"},{"line":122,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":32,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":60,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":84,"name":"run","path":"tests/test_reviewer_blockers.py"},{"line":35,"name":"_ctx","path":"tests/test_runtime_background_process_polling.py"},{"line":9,"name":"ctx","path":"tests/test_runtime_environment_metadata.py"},{"line":15,"name":"_ctx","path":"tests/test_runtime_tool_event_results.py"},{"line":55,"name":"context","path":"tests/test_session_registry.py"},{"line":14,"name":"test_legacy_positional_and_keyword_construction","path":"tests/test_session_state_compatibility.py"},{"line":31,"name":"test_all_keyword_required_construction_remains_legal","path":"tests/test_session_state_compatibility.py"},{"line":8,"name":"positional","path":"tests/test_session_state_compatibility.py"},{"line":387,"name":"test_explicit_owner_construction_preserves_identity","path":"tests/test_session_state_ownership.py"},{"line":31,"name":"make_ctx","path":"tests/test_sticky_footer.py"},{"line":113,"name":"make_ctx","path":"tests/test_telegram_rich_renderer.py"},{"line":140,"name":"test_post_tool_hook_from_a_worker_thread_polls_without_another_loop_event","path":"tests/test_thread_safe_scheduling.py"},{"line":86,"name":"_ctx","path":"tests/test_thread_safe_scheduling.py"},{"line":136,"name":"run","path":"tests/test_tool_hook_routing.py"}]} diff --git a/tests/test_runtime_background_process_polling.py b/tests/test_runtime_background_process_polling.py index 7d80eb1..5c0aef4 100644 --- a/tests/test_runtime_background_process_polling.py +++ b/tests/test_runtime_background_process_polling.py @@ -16,6 +16,9 @@ def create_task(self, coroutine): self.coroutines.append(coroutine) return SimpleNamespace(done=lambda: False) + def call_soon_threadsafe(self, callback): + callback() + class Registry: def __init__(self, values): diff --git a/tests/test_thread_safe_scheduling.py b/tests/test_thread_safe_scheduling.py new file mode 100644 index 0000000..f960d6b --- /dev/null +++ b/tests/test_thread_safe_scheduling.py @@ -0,0 +1,188 @@ +import asyncio +import sys +import threading +import time +from types import ModuleType, SimpleNamespace + +from hermes_progress_tail.models.state import BackgroundJob, SessionContext +from hermes_progress_tail.renderer import ProgressRenderer +from hermes_progress_tail.runtime import plugin, tool_events +from hermes_progress_tail.settings.loading import load_settings +from tests.support.rendering import EditableAdapter + + +class LoopThread: + def __init__(self): + self.loop = asyncio.new_event_loop() + self.loop.set_debug(True) + self.thread = threading.Thread(target=self.loop.run_forever, daemon=True) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_): + async def shutdown(): + pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + asyncio.run_coroutine_threadsafe(shutdown(), self.loop).result(5) + self.loop.call_soon_threadsafe(self.loop.stop) + self.thread.join(5) + self.loop.close() + + +class Registry: + def __init__(self, values): + self.values = iter(values) + self.pending_watchers = [] + + def get(self, process_id): + return next(self.values) + + +class Session: + def __init__(self): + self.output_buffer = "running" + self.exited = False + self.exit_code = None + self.command = "pytest -q" + self.cwd = "/repo" + self.pid = 9 + self.notify_on_complete = True + self.watcher_interval = 10 + self.watch_patterns = ["done"] + + def complete(self): + self.output_buffer = "2 passed" + self.exited = True + self.exit_code = 0 + + +class CompletingRegistry(Registry): + def __init__(self, session, *, after=2): + super().__init__([]) + self.session = session + self.after = after + self.reads = 0 + + def get(self, process_id): + self.reads += 1 + if self.reads >= self.after: + self.session.complete() + return self.session + + +def _install_registry(monkeypatch, registry): + package = ModuleType("tools") + module = ModuleType("tools.process_registry") + module.process_registry = registry + monkeypatch.setitem(sys.modules, "tools", package) + monkeypatch.setitem(sys.modules, "tools.process_registry", module) + + +def _ctx(loop): + return SessionContext("sid", "key", "discord", "chat", None, None, loop) + + +def _renderer(**job_settings): + defaults = {"enabled": True, "update_interval_seconds": 0, "completed_ttl_seconds": 0} + defaults.update(job_settings) + return SimpleNamespace(settings=SimpleNamespace(background_jobs=SimpleNamespace(**defaults))) + + +def test_poll_from_a_foreign_thread_runs_on_an_idle_loop(monkeypatch): + events = [] + delivered = threading.Event() + + def record(context, event, force=False): + events.append(event) + delivered.set() + + monkeypatch.setattr(tool_events, "_schedule_render", record) + monkeypatch.setattr(plugin, "_get_renderer", lambda: _renderer()) + _install_registry(monkeypatch, Registry([None])) + + with LoopThread() as harness: + ctx = _ctx(harness.loop) + ctx.background.jobs["proc"] = BackgroundJob("proc") + tool_events._schedule_background_job_poll(ctx, "proc") + assert delivered.wait(5) + + assert [(event.process_id, event.event_type, event.exited) for event in events] == [ + ("proc", "lost", True) + ] + + +def test_cleanup_from_a_foreign_thread_runs_on_an_idle_loop(monkeypatch): + events = [] + delivered = threading.Event() + + def record(context, event, force=False): + events.append((event, force)) + delivered.set() + + monkeypatch.setattr(tool_events, "_schedule_render", record) + monkeypatch.setattr(plugin, "_get_renderer", lambda: _renderer()) + + with LoopThread() as harness: + ctx = _ctx(harness.loop) + tool_events._schedule_background_job_cleanup(ctx, "proc") + assert delivered.wait(5) + + assert [(event.process_id, event.event_type, force) for event, force in events] == [ + ("proc", "cleanup", True) + ] + + +def test_post_tool_hook_from_a_worker_thread_polls_without_another_loop_event(monkeypatch): + adapter = EditableAdapter() + renderer = ProgressRenderer( + load_settings( + { + "progress_tail": { + "tools": {"timestamp": False}, + "background_jobs": {"update_interval_seconds": 1}, + } + } + ) + ) + _install_registry(monkeypatch, CompletingRegistry(Session())) + monkeypatch.setattr(plugin, "_get_renderer", lambda: renderer) + + with LoopThread() as harness: + ctx = SessionContext( + "s1", + "k1", + "discord", + "chat", + None, + adapter, + harness.loop, + "live_tail", + timestamp=False, + ) + renderer.register_context(ctx) + plugin._on_post_tool_call( + "terminal", + args={"background": True, "command": "pytest -q"}, + result='{"session_id":"proc_live","exited":false}', + task_id="k1", + session_id="s1", + ) + deadline = time.monotonic() + 10 + claimed = False + while time.monotonic() < deadline: + job = ctx.background_jobs.get("proc_live") + if job is not None and job.status == "running" and job.poll_task is not None: + claimed = True + if job is not None and job.status == "completed": + break + time.sleep(0.01) + job = ctx.background_jobs["proc_live"] + + assert claimed + assert (job.status, job.exit_code) == ("completed", 0) + assert "2 passed" in " ".join(job.output_tail)