From c466f390bcf196c3d69c6d9f0b66f43d830c7c0e Mon Sep 17 00:00:00 2001 From: Miaoranmmm Date: Thu, 22 Oct 2026 15:40:23 +0800 Subject: [PATCH 1/2] feat(tui): list every hook run in a sidebar Hooks tab The footer indicator says which hook iCode is waiting on, but nothing showed what hooks ran, what they were handed, or what they returned. The engine the TUI builds now keeps a run log (HookRunLog) of every main-session hook run since iCode started, across sessions and manager replacements: the payload the hook read, its result file, stdout and stderr, and what iCode took from it (blocked, modified args, added reminder or context) or why it took nothing. The manager writes it when a run registers, takes its slot and ends, cancelled runs and runs ending after the manager closed included, and publishes HookRunsChanged. The latest 100 ended runs are kept, their details within 16 MB, payloads cut at 64 KB; nothing is written to disk. Headless and ACP keep no log. The runner keeps the output a timed-out or stopped hook printed, counts the bytes it dropped, tells a missing result file from an invalid one, writes the payload text it is handed, and returns a detached run's log path. Sub-agents pass their origin to tool, approval and pre_compact hooks, so their runs are named. The sidebar's new Hooks tab, after Context, lists the runs numbered in the order they registered, newest at the bottom, following new ones unless scrolled up, with icon, duration and result; Clear ended hides ended runs, numbers carrying on; once more than 100 runs ended, a line at the top says only the latest 100 are kept. While a dialog covers the list, changes wait until it closes. A click opens the run's Input and Output, each part a box that copies itself on a click. Clicking the footer indicator opens that tab, scrolled to the run it shows, and that run's details. The wheel over the sidebar's tab strip slides it to the tabs a narrow sidebar cuts off. --- docs/en/guides/extensions/hooks.md | 8 +- docs/zh-Hans/guides/extensions/hooks.md | 8 +- locales/chrys.pot | 388 +++++++++ locales/zh-Hans/LC_MESSAGES/chrys.po | 390 +++++++++ src/chrys/app/tui/app.py | 1 + .../tui/screens/dialogs/background_task.py | 4 +- src/chrys/app/tui/screens/dialogs/hook_run.py | 628 ++++++++++++++ .../app/tui/screens/dialogs/hook_run.tcss | 66 ++ .../app/tui/screens/dialogs/tool_view.py | 25 +- .../app/tui/screens/main/engine_read_model.py | 20 + .../app/tui/screens/main/event_handlers.py | 7 + .../tui/screens/main/main_screen_presenter.py | 3 + src/chrys/app/tui/screens/main/ports.py | 1 + src/chrys/app/tui/screens/main/screen.py | 60 +- .../app/tui/screens/main/subscriptions.py | 2 + .../app/tui/screens/main/view_adapter.py | 3 + .../app/tui/widgets/chrome/hook_indicator.py | 18 +- src/chrys/app/tui/widgets/sidebar/hooks.py | 512 ++++++++++++ src/chrys/app/tui/widgets/sidebar/panel.py | 43 +- src/chrys/foundation/events/types.py | 5 + .../_catalogs/zh-Hans/LC_MESSAGES/chrys.mo | Bin 199310 -> 203600 bytes .../foundation/models/hook_executions.py | 2 + src/chrys/foundation/models/hook_runs.py | 114 +++ src/chrys/orchestration/engine/assembly.py | 7 + src/chrys/orchestration/engine/engine.py | 8 + .../orchestration/engine/hook_reporting.py | 22 +- src/chrys/orchestration/engine/loader.py | 5 +- src/chrys/orchestration/sub_agents/tools.py | 10 +- .../agent_middleware/control/approval.py | 2 + .../control/approval_hooks.py | 10 +- .../agent_middleware/events/hook_dispatch.py | 19 +- .../events/sub_agent_events.py | 2 + src/chrys/service/hooks/manager.py | 418 ++++++++-- src/chrys/service/hooks/run_log.py | 260 ++++++ src/chrys/service/hooks/runner.py | 83 +- src/chrys/service/trajectory/hooks.py | 14 +- .../behaviors/test_main_screen_interaction.py | 4 +- .../screens/main/test_hook_indicator_flow.py | 51 +- .../tui/screens/main/test_hooks_panel_flow.py | 767 ++++++++++++++++++ .../tui/screens/test_background_task_modal.py | 12 +- tests/app/tui/screens/test_hook_run_modal.py | 531 ++++++++++++ tests/app/tui/widgets/test_hook_run_list.py | 553 +++++++++++++ tests/app/tui/widgets/test_sidebar_panel.py | 66 +- tests/app/tui/widgets/test_tasks_panel.py | 1 + .../trajectory_wait_manifest.json | 58 +- tests/foundation/i18n/_catalog_oracle_ids.py | 2 + .../i18n/_hook_panel_catalog_oracle_ids.py | 74 ++ tests/foundation/i18n/test_catalog_oracle.py | 2 +- .../engine/test_trajectory_events_log.py | 6 +- .../engine/build/test_lifecycle_hooks.py | 1 + .../engine/build/test_retired_hook_traces.py | 2 +- .../engine/run/test_lifecycle_hooks.py | 4 +- .../engine/test_hook_run_engine.py | 357 ++++++++ .../engine/test_hook_run_reporting.py | 275 +++++++ .../engine/test_shutdown_close.py | 4 +- .../service/agent_middleware/test_approval.py | 7 +- .../agent_middleware/test_approval_hooks.py | 13 +- .../test_approval_reuse_review_regressions.py | 2 +- tests/service/hooks/test_manager.py | 29 +- .../service/hooks/test_manager_executions.py | 3 +- tests/service/hooks/test_run_log.py | 511 ++++++++++++ tests/service/hooks/test_run_log_retention.py | 494 +++++++++++ tests/service/hooks/test_runner_capture.py | 237 ++++++ 63 files changed, 7017 insertions(+), 217 deletions(-) create mode 100644 src/chrys/app/tui/screens/dialogs/hook_run.py create mode 100644 src/chrys/app/tui/screens/dialogs/hook_run.tcss create mode 100644 src/chrys/app/tui/widgets/sidebar/hooks.py create mode 100644 src/chrys/foundation/models/hook_runs.py create mode 100644 src/chrys/service/hooks/run_log.py create mode 100644 tests/app/tui/screens/main/test_hooks_panel_flow.py create mode 100644 tests/app/tui/screens/test_hook_run_modal.py create mode 100644 tests/app/tui/widgets/test_hook_run_list.py create mode 100644 tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py create mode 100644 tests/orchestration/engine/test_hook_run_engine.py create mode 100644 tests/orchestration/engine/test_hook_run_reporting.py create mode 100644 tests/service/hooks/test_run_log.py create mode 100644 tests/service/hooks/test_run_log_retention.py create mode 100644 tests/service/hooks/test_runner_capture.py diff --git a/docs/en/guides/extensions/hooks.md b/docs/en/guides/extensions/hooks.md index de1563300..afbd9c9cd 100644 --- a/docs/en/guides/extensions/hooks.md +++ b/docs/en/guides/extensions/hooks.md @@ -169,7 +169,13 @@ For most hooks, choose an execution mode based on whether the current operation An asynchronous hook (`async`) does not delay the event that triggered it or the current operation. When the current turn or session ends, if an applicable asynchronous hook is still running, iCode waits for it to finish before ending the turn or session. iCode does not wait for asynchronous hooks triggered by `user_interrupt`. The wait limit at session end is configurable in [Global settings](../../reference/hooks.md#global-settings). To retry unfinished tasks after iCode exits, also configure `delivery: durable`. -In a chat, the bottom-right corner shows the hook that is running; select it to see the hooks iCode loaded. +## See which hooks ran + +In a chat, the bottom-right corner shows the hook iCode is waiting for. Select it to open the **Hooks** tab of the sidebar (**Ctrl+G** shows or hides the sidebar) and, when a hook is shown there, that hook's details over it; the list scrolls to that hook's row and marks it. + +The **Hooks** tab lists the hooks that ran since iCode started, in every session, numbered in the order they started, the newest at the bottom: those still running or waiting for their turn, and the 100 that ended last. Each row shows its number, the hook, the tool or event that triggered it (and the sub-agent, when one did), how long it ran, and its result, such as `exit 1`, `timed out`, `blocked`, `modified args` or `added context`. A hook that failed is marked `✗`. **Clear ended** removes the runs that ended from the list; running ones stay. Cleared runs can't be shown again, and new runs keep counting from the last number. Once more than 100 runs have ended, the top of the list says that only the latest 100 are kept. + +Click a run to see its details. **Input** shows the payload iCode gave the hook and how it was run; **Output** shows its result file, what iCode took from it (or why it took nothing), and what it printed to stdout and stderr. Click a box to copy what it shows, or press **c** to copy the whole tab. The list starts empty each time iCode starts; to see the hooks iCode loaded, enter `/runtime` and open the **Hooks** tab. ## Use global hooks diff --git a/docs/zh-Hans/guides/extensions/hooks.md b/docs/zh-Hans/guides/extensions/hooks.md index f4f360ea9..93859ccb0 100644 --- a/docs/zh-Hans/guides/extensions/hooks.md +++ b/docs/zh-Hans/guides/extensions/hooks.md @@ -169,7 +169,13 @@ with open(os.environ["CHRYS_HOOK_RESULT"], "w", encoding="utf-8") as result_file 异步钩子(`async`)不会延迟触发它的事件或当前操作。当前轮或会话结束时,如果相应的异步钩子仍在运行,iCode 会等待它完成后再结束当前轮或会话。`user_interrupt` 触发的异步钩子不会被等待。会话结束时的等待上限可在[全局设置](../../reference/hooks.md#全局设置)中配置。需要在 iCode 退出后重试未完成的任务时,还应配置 `delivery: durable`。 -在对话中,右下角会显示正在运行的钩子;点击它可以查看 iCode 加载的钩子。 +## 查看运行过的钩子 + +在对话中,右下角会显示 iCode 正在等待的钩子。点击它会打开侧栏的“钩子”标签页(按 **Ctrl+G** 显示或隐藏侧栏);右下角显示着某个钩子时,还会在上面打开这个钩子的详情,并且列表会滚动到这一行并标出它。 + +“钩子”标签页列出 iCode 启动以来所有会话中运行过的钩子,按开始顺序编号,最新的在最下面:包括正在运行和排队等待的,以及最近结束的 100 次。每行显示编号、钩子、触发它的工具或事件(由子智能体触发时还显示子智能体)、运行时长和结果,例如 `exit 1`、`timed out`、`blocked`、`modified args` 或 `added context`。运行失败的钩子标为 `✗`。点击“清除已结束”可从列表中移除已结束的运行,正在运行的会保留。清除后的运行无法再显示,新的运行接着原来的编号往下数。结束的运行超过 100 次后,列表顶部会提示只保留最近 100 次。 + +点击一行可查看详情。“输入”显示 iCode 交给钩子的 payload 以及钩子的运行方式;“输出”显示结果文件、iCode 采纳了哪些内容(或为什么没有采纳),以及钩子输出到 stdout 和 stderr 的内容。点击某个方框即可复制其中的内容;按 **c** 复制整个标签页。每次启动 iCode 时列表为空;要查看 iCode 加载了哪些钩子,输入 `/runtime` 并打开“钩子”标签页。 ## 使用全局钩子 diff --git a/locales/chrys.pot b/locales/chrys.pot index dc0e9214a..d5f7ca10a 100644 --- a/locales/chrys.pot +++ b/locales/chrys.pot @@ -6243,6 +6243,277 @@ msgstr "" msgid "tui.hook_indicator.idle" msgstr "" +#. English: Changed the tool arguments to: +#. chrys-meta={"fingerprint":"5e457bb14b4b7bc5180cc1b6a1788e62af22df9ac2fc673bb54129d93844fe22","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.args" +msgstr "" + +#. English: Blocked the action: {reason} +#. chrys-meta={"fingerprint":"869c0c47dfbff769b0747bd613dde9f3c6343c4c2b134ff33701c4dbe8768091","multiline":false,"placeholders":["reason"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.blocked" +msgstr "" + +#. English: Added to the tool result: +#. chrys-meta={"fingerprint":"21c92d105ef15620ffa4504e8f0b7423c5ee3ebe60d066ea8ed900626db30859","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.context" +msgstr "" + +#. English: Nothing +#. chrys-meta={"fingerprint":"90c459b0ad5ab03069594987cf32d173a3d32a413bf4a4c3cdd5fd66ac37a3fd","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.nothing" +msgstr "" + +#. English: Added a reminder for the model: +#. chrys-meta={"fingerprint":"33cdfccec07ac69efef0321c4c4bc8bfbe64943e6c977c117a311e37287162f1","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.reminder" +msgstr "" + +#. English: Copied log path +#. chrys-meta={"fingerprint":"87ede8461a4168c6439cb0814da6124fe28d8193222a81286dfe86a8836bccfb","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.log_path" +msgstr "" + +#. English: Copied log path (terminal clipboard unavailable) +#. chrys-meta={"fingerprint":"bc849068d0b31f404bdfa13b9d4337afa1f8ec555b9d2396b95c9986f152f7af","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.log_path_unavailable" +msgstr "" + +#. English: Copied {section} +#. chrys-meta={"fingerprint":"09bdbc46796fdaa9f4f9aa6fd67484a64a8bcd2a52e683e460adb1a679db193b","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section" +msgstr "" + +#. English: Copied {section} (terminal clipboard skipped: payload too large) +#. chrys-meta={"fingerprint":"e0333a1cc47287e6802aeac1ebe2f84d328c8994bd67df6cbe63bb822c00d5e4","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section_too_large" +msgstr "" + +#. English: Copied {section} (terminal clipboard unavailable) +#. chrys-meta={"fingerprint":"38d1e8f0dfd21950fc35a3a9c4effd55ea7999deb2a372660d4cbe98fb5703fd","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section_unavailable" +msgstr "" + +#. English: Details are no longer kept for this run +#. chrys-meta={"fingerprint":"7775c2cf0ef5fdf2cb346bc003d9211c2cf37fe3b2cd0357fbe1c98ea0ea0446","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.details_gone" +msgstr "" + +#. English: exit code {code} +#. chrys-meta={"fingerprint":"e009512d43587c00bacb654487590314a4a7e6a3c9542b2dc0ba3c2d10279ce2","multiline":false,"placeholders":["code"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.exit_code" +msgstr "" + +#. English: Applied +#. chrys-meta={"fingerprint":"c73971eb92293f6575210d852c251b2923e1956b5268764e43395cade725298b","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.applied" +msgstr "" + +#. English: Decision +#. chrys-meta={"fingerprint":"2d1c54b8f9715fe18dc6fd6935897b93af938024de4b3b19e63a5c4f2f4a25d8","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.decision" +msgstr "" + +#. English: Event +#. chrys-meta={"fingerprint":"8fc5a0087cec0dffa23a9ad155b5c2c8ccc9586bc9dfeed8e858f099c51a2b2a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.event" +msgstr "" + +#. English: Log +#. chrys-meta={"fingerprint":"33b400523094269b6e91c373570d3cf8cdc52c0149345e6e0dd08569f775276c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.log" +msgstr "" + +#. English: Log file +#. chrys-meta={"fingerprint":"6e2010eb3be3d95b89af1b0aa277718581a2f099e818368123a2d64e1534f915","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.log_file" +msgstr "" + +#. English: Mode +#. chrys-meta={"fingerprint":"65e38fbafd4185e83d69fdec8cdf370bb53243d56276f066f9150df1888e125b","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.mode" +msgstr "" + +#. English: Overview +#. chrys-meta={"fingerprint":"b9ad84991d7daa4ad9f8a22fdf25f00dd38773173eb33646e011e7646ed19e29","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.overview" +msgstr "" + +#. English: Payload +#. chrys-meta={"fingerprint":"cd530b348473e964964ed0eeb7090558280c7a347d3541787e50b27a1b3e3818","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.payload" +msgstr "" + +#. English: Queued +#. chrys-meta={"fingerprint":"c4442bc21ce24ef79d0ee677283811ada44d78aafca431b2a52d36beefb768eb","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.queued" +msgstr "" + +#. English: Result +#. chrys-meta={"fingerprint":"26e6b71d0b6f7f7005eaeef64cfb341a12d773a71146d47e74b8fd377399b911","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.result" +msgstr "" + +#. English: Run +#. chrys-meta={"fingerprint":"0b0e1acb1eb53197a4a6fb0739fe1ee34bfe598858edac41d63eee6bc9148c7c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.run" +msgstr "" + +#. English: Session +#. chrys-meta={"fingerprint":"ff5347f05cc1e039d9010c2905058f253aa7ab5e0fb4e243daf5d0787404db29","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.session" +msgstr "" + +#. English: Source +#. chrys-meta={"fingerprint":"8a23381b73255be7becb386ff5bf8b4846f3fcba4f6d5404413d16d72d5ed687","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.source" +msgstr "" + +#. English: Started +#. chrys-meta={"fingerprint":"826982e6ab2fb3751c2f9c2d5d536b77262189c6a8a4e6bc67dfa5f4fd30d5f2","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.started" +msgstr "" + +#. English: stderr +#. chrys-meta={"fingerprint":"a3d13bf12753a05efbb0dfed9972765064dbb5fc33d31a2399a7951e8c8e4f75","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.stderr" +msgstr "" + +#. English: stdout +#. chrys-meta={"fingerprint":"ee75972b99d9c354c23287cc6cfe77e2cc9fe335813500d16c5b4b53db306cbf","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.stdout" +msgstr "" + +#. English: Triggered by +#. chrys-meta={"fingerprint":"5cd22b0cf77e16370b37ac79a34cd00c327d796573870f049826aff9da5054a4","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.triggered_by" +msgstr "" + +#. English: The result file was invalid and was ignored +#. chrys-meta={"fingerprint":"18480780d9864e4cf8c1178e8f5f856f3ab98d8302e2b993b7b45f21ac8e3603","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.invalid_result_file" +msgstr "" + +#. English: Main agent +#. chrys-meta={"fingerprint":"2a6d7b7905e27f4347f243fecd0fd6331e8bfa169a9cc24685d5b5b14d3a91cc","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.main_agent" +msgstr "" + +#. English: No result file +#. chrys-meta={"fingerprint":"04c2e7eb5cd03a9e5ef3ef72ad7b833463eb2301237ab59239ebe0f7b4716be6","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.no_result_file" +msgstr "" + +#. English: The hook failed, so its result file was ignored +#. chrys-meta={"fingerprint":"5941f7b7eacc65678eb54266c7cd1183f55380f8fdbed7e7158a640316ea180c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.failed" +msgstr "" + +#. English: The hook failed and on_error is block, so the action was blocked +#. chrys-meta={"fingerprint":"25514934918920bf7c4f487119c01f3a47f175b825bf9f1a408d3d729387b9c8","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.failed_blocked" +msgstr "" + +#. English: Async and fire_and_forget hooks can't change the action +#. chrys-meta={"fingerprint":"d1a52020e2498e1b7988c02d2dfaf97dc177f66471e5df38d0b2bc56da895090","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.nonblocking" +msgstr "" + +#. English: {event} doesn't support this action +#. chrys-meta={"fingerprint":"ffb21cb152099cd58c6ade26cf07bd47ed8c49302b9556f4f59a3d5b876cefa8","multiline":false,"placeholders":["event"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.unsupported" +msgstr "" + +#. English: Payload cut at 64 KB of {size} +#. chrys-meta={"fingerprint":"95bc340815d859aa5908b17774f108a24218dd554b5853dc114b2c9ee0d2745e","multiline":false,"placeholders":["size"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.payload_truncated" +msgstr "" + +#. English: Retry of an earlier run +#. chrys-meta={"fingerprint":"f9ac568b9f02169a0ead2504c0ff7317e973cef24f9f8404ebe12255aac3a9b0","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.retry" +msgstr "" + +#. English: Running… output shows when the hook ends +#. chrys-meta={"fingerprint":"35230ed1ed71285e58b95a23ecc45da5b076e124f83c2797286edb44e7dec7f5","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.running" +msgstr "" + +#. English: Another run of this hook started in the same second and writes to +#. this log too +#. chrys-meta={"fingerprint":"c81fd0c547460ed1c031017f8424f5bec3995355d8d000041a098ab07f908f9e","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.shared_log" +msgstr "" + +#. English: succeeded +#. chrys-meta={"fingerprint":"d20404943dcd141e2e88a8b0299a224899fc4d8746b9283f1cc6b76f83f50a54","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.succeeded" +msgstr "" + +#. English: Input +#. chrys-meta={"fingerprint":"aa801f5aa47a9644ff6d258e4f0dc34a47b07d38685b021f96fe2a5f3c4e0ee6","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.tab.input" +msgstr "" + +#. English: Output +#. chrys-meta={"fingerprint":"05859c2cb178f6b52e68ea789358b4f6d9958c27ad628104ccee05f67586426d","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.tab.output" +msgstr "" + +#. English: Hook #{number} · {hook_id} +#. chrys-meta={"fingerprint":"8e82c147d2837aca116516d443f774394ca47647ed6bbebd7007b2fbf84655bf","multiline":false,"placeholders":["hook_id","number"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.title" +msgstr "" + +#. English: {bytes} bytes from the middle were not kept +#. chrys-meta={"fingerprint":"f46f77b798cdf4ad57c33b49b4dc605b60334c7b9e098be9f482c6bc6fec4305","multiline":false,"placeholders":["bytes"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.truncated" +msgstr "" + #. English: Arguments: #. chrys-meta={"fingerprint":"abee83246fc63d7f641b344313ab75e31d459c0f4c99231da217a066e73be558","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/chat/renderers/hosted_generic.py @@ -10606,6 +10877,117 @@ msgstr "" msgid "tui.sidebar.debug.event_stream" msgstr "" +#. English: Clear ended +#. chrys-meta={"fingerprint":"dcb6a0d30ef78d850aaba51180142bacbdaa9a48b7a3679ec9f128ff97c7a0ca","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.clear_ended" +msgstr "" + +#. English: Ended runs cleared +#. chrys-meta={"fingerprint":"ba6607222905de97189e941a750c15087d87caec10e868e928859598c72485de","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.cleared" +msgstr "" + +#. English: No hook has run since {app} started +#. chrys-meta={"fingerprint":"2478bf8fe82a678e47b454d6e27e963aee8cfdfdb23f0bbba9cfe1dccb3eefe0","multiline":false,"placeholders":["app"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.empty" +msgstr "" + +#. English: No hooks configured +#. chrys-meta={"fingerprint":"322a4dcf13cf9491d6d5cd0110f494c7fa8c6471abc32932ed0075e1d1e34b10","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.empty_unconfigured" +msgstr "" + +#. English: Only the latest {limit} runs are kept +#. chrys-meta={"fingerprint":"31444529819e96841ea27028af2ac4822b9296144dfb12ad85ecb3527975a294","multiline":false,"placeholders":["limit"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.only_latest_kept" +msgstr "" + +#. English: added context +#. chrys-meta={"fingerprint":"3fc7dccf24c12cc1fe775bb143e1897ef236d958265032a4799523254fb12ae0","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.added_context" +msgstr "" + +#. English: added reminder +#. chrys-meta={"fingerprint":"b3645d6786c7cd80ef5bbfd979f084e10375a347f8819f1e2a2224659f8adf8a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.added_reminder" +msgstr "" + +#. English: blocked +#. chrys-meta={"fingerprint":"23bcab7ae9ef97e9666043447f5b6968da692cbfffdf79d3a1fb059da04e7d7e","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.blocked" +msgstr "" + +#. English: cancelled +#. chrys-meta={"fingerprint":"a5fb27a22343ae45dffbf8e925df54667260b67e79f86aabb0cfa7dd81b207f2","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.cancelled" +msgstr "" + +#. English: couldn't start +#. chrys-meta={"fingerprint":"75f6bf66f713d1eda2acb6a5ca8cf69e72af47ca156013576b17393908583a3f","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.couldnt_start" +msgstr "" + +#. English: detached +#. chrys-meta={"fingerprint":"f6f4dc1067c3131bf2f47ad10b96c2d8d8d4e164e21b31e15b96bbffbf0e5247","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.detached" +msgstr "" + +#. English: exit {code} +#. chrys-meta={"fingerprint":"19d84c7da6f00031ba6efcaf7ea40ad59766c004b4654ac15b6e4c82aa079b64","multiline":false,"placeholders":["code"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.exit" +msgstr "" + +#. English: modified args +#. chrys-meta={"fingerprint":"29d632bd5cb8ff86dc4aa9e9b65518077c1723aeebde9a531f31471c18788ae3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.modified_args" +msgstr "" + +#. English: queued +#. chrys-meta={"fingerprint":"292031914838efc235b6a1d841d6c5a9ba8e1d5dfeea522e30b6ac43c8b8f9f1","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.queued" +msgstr "" + +#. English: running +#. chrys-meta={"fingerprint":"30a70ec2e1543266fdf8c3d3507853a36b54e6a70feed23c5ae22fd78476fa4a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.running" +msgstr "" + +#. English: timed out +#. chrys-meta={"fingerprint":"3ca20c7b1a56196dd6efffe72762fb8519065f05ea75a7578129f84f758ec0a5","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.timed_out" +msgstr "" + +#. English: Hooks +#. chrys-meta={"fingerprint":"bc590033958e925397df73037927d0966b3d6b872ec389277b25224deed0ebd3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.title" +msgstr "" + +#. English: Hooks ({count} active) +#. English-plural: Hooks ({count} active) +#. chrys-meta={"fingerprint":"3717b8678ac91680244380334d4243cf19a6e6973d36abe39a7f7ce6128761ee","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.title_active" +msgid_plural "tui.sidebar.hooks.title_active#plural" +msgstr[0] "" +msgstr[1] "" + #. English: Buddy #. chrys-meta={"fingerprint":"b24977515f388242f6162756260851ee0929d9063bb635c662fc21f76399f966","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/sidebar/panel.py @@ -10624,6 +11006,12 @@ msgstr "" msgid "tui.sidebar.tab.debug" msgstr "" +#. English: Hooks +#. chrys-meta={"fingerprint":"bc590033958e925397df73037927d0966b3d6b872ec389277b25224deed0ebd3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/panel.py +msgid "tui.sidebar.tab.hooks" +msgstr "" + #. English: Messages #. chrys-meta={"fingerprint":"dc539a1862021f7a116d2443dde9458f890e3f149671bfc0021d3b6300e6147f","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/sidebar/panel.py diff --git a/locales/zh-Hans/LC_MESSAGES/chrys.po b/locales/zh-Hans/LC_MESSAGES/chrys.po index c932d0d25..8a7b43495 100644 --- a/locales/zh-Hans/LC_MESSAGES/chrys.po +++ b/locales/zh-Hans/LC_MESSAGES/chrys.po @@ -6286,6 +6286,277 @@ msgstr "{app} 用户指南" msgid "tui.hook_indicator.idle" msgstr "钩子" +#. English: Changed the tool arguments to: +#. chrys-meta={"fingerprint":"5e457bb14b4b7bc5180cc1b6a1788e62af22df9ac2fc673bb54129d93844fe22","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.args" +msgstr "将工具参数改为:" + +#. English: Blocked the action: {reason} +#. chrys-meta={"fingerprint":"869c0c47dfbff769b0747bd613dde9f3c6343c4c2b134ff33701c4dbe8768091","multiline":false,"placeholders":["reason"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.blocked" +msgstr "阻止了此操作:{reason}" + +#. English: Added to the tool result: +#. chrys-meta={"fingerprint":"21c92d105ef15620ffa4504e8f0b7423c5ee3ebe60d066ea8ed900626db30859","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.context" +msgstr "追加到工具结果:" + +#. English: Nothing +#. chrys-meta={"fingerprint":"90c459b0ad5ab03069594987cf32d173a3d32a413bf4a4c3cdd5fd66ac37a3fd","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.nothing" +msgstr "无" + +#. English: Added a reminder for the model: +#. chrys-meta={"fingerprint":"33cdfccec07ac69efef0321c4c4bc8bfbe64943e6c977c117a311e37287162f1","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.applied.reminder" +msgstr "为模型添加了提醒:" + +#. English: Copied log path +#. chrys-meta={"fingerprint":"87ede8461a4168c6439cb0814da6124fe28d8193222a81286dfe86a8836bccfb","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.log_path" +msgstr "已复制日志路径" + +#. English: Copied log path (terminal clipboard unavailable) +#. chrys-meta={"fingerprint":"bc849068d0b31f404bdfa13b9d4337afa1f8ec555b9d2396b95c9986f152f7af","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.log_path_unavailable" +msgstr "已复制日志路径(终端剪贴板不可用)" + +#. English: Copied {section} +#. chrys-meta={"fingerprint":"09bdbc46796fdaa9f4f9aa6fd67484a64a8bcd2a52e683e460adb1a679db193b","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section" +msgstr "已复制 {section}" + +#. English: Copied {section} (terminal clipboard skipped: payload too large) +#. chrys-meta={"fingerprint":"e0333a1cc47287e6802aeac1ebe2f84d328c8994bd67df6cbe63bb822c00d5e4","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section_too_large" +msgstr "已复制 {section}(内容过大,已跳过终端剪贴板)" + +#. English: Copied {section} (terminal clipboard unavailable) +#. chrys-meta={"fingerprint":"38d1e8f0dfd21950fc35a3a9c4effd55ea7999deb2a372660d4cbe98fb5703fd","multiline":false,"placeholders":["section"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.copy.section_unavailable" +msgstr "已复制 {section}(终端剪贴板不可用)" + +#. English: Details are no longer kept for this run +#. chrys-meta={"fingerprint":"7775c2cf0ef5fdf2cb346bc003d9211c2cf37fe3b2cd0357fbe1c98ea0ea0446","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.details_gone" +msgstr "此次运行的详情已不再保留" + +#. English: exit code {code} +#. chrys-meta={"fingerprint":"e009512d43587c00bacb654487590314a4a7e6a3c9542b2dc0ba3c2d10279ce2","multiline":false,"placeholders":["code"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.exit_code" +msgstr "退出码 {code}" + +#. English: Applied +#. chrys-meta={"fingerprint":"c73971eb92293f6575210d852c251b2923e1956b5268764e43395cade725298b","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.applied" +msgstr "采纳" + +#. English: Decision +#. chrys-meta={"fingerprint":"2d1c54b8f9715fe18dc6fd6935897b93af938024de4b3b19e63a5c4f2f4a25d8","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.decision" +msgstr "决定" + +#. English: Event +#. chrys-meta={"fingerprint":"8fc5a0087cec0dffa23a9ad155b5c2c8ccc9586bc9dfeed8e858f099c51a2b2a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.event" +msgstr "事件" + +#. English: Log +#. chrys-meta={"fingerprint":"33b400523094269b6e91c373570d3cf8cdc52c0149345e6e0dd08569f775276c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.log" +msgstr "日志" + +#. English: Log file +#. chrys-meta={"fingerprint":"6e2010eb3be3d95b89af1b0aa277718581a2f099e818368123a2d64e1534f915","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.log_file" +msgstr "日志文件" + +#. English: Mode +#. chrys-meta={"fingerprint":"65e38fbafd4185e83d69fdec8cdf370bb53243d56276f066f9150df1888e125b","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.mode" +msgstr "方式" + +#. English: Overview +#. chrys-meta={"fingerprint":"b9ad84991d7daa4ad9f8a22fdf25f00dd38773173eb33646e011e7646ed19e29","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.overview" +msgstr "概览" + +#. English: Payload +#. chrys-meta={"fingerprint":"cd530b348473e964964ed0eeb7090558280c7a347d3541787e50b27a1b3e3818","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.payload" +msgstr "Payload" + +#. English: Queued +#. chrys-meta={"fingerprint":"c4442bc21ce24ef79d0ee677283811ada44d78aafca431b2a52d36beefb768eb","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.queued" +msgstr "排队" + +#. English: Result +#. chrys-meta={"fingerprint":"26e6b71d0b6f7f7005eaeef64cfb341a12d773a71146d47e74b8fd377399b911","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.result" +msgstr "结果" + +#. English: Run +#. chrys-meta={"fingerprint":"0b0e1acb1eb53197a4a6fb0739fe1ee34bfe598858edac41d63eee6bc9148c7c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.run" +msgstr "运行" + +#. English: Session +#. chrys-meta={"fingerprint":"ff5347f05cc1e039d9010c2905058f253aa7ab5e0fb4e243daf5d0787404db29","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.session" +msgstr "会话" + +#. English: Source +#. chrys-meta={"fingerprint":"8a23381b73255be7becb386ff5bf8b4846f3fcba4f6d5404413d16d72d5ed687","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.source" +msgstr "来源" + +#. English: Started +#. chrys-meta={"fingerprint":"826982e6ab2fb3751c2f9c2d5d536b77262189c6a8a4e6bc67dfa5f4fd30d5f2","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.started" +msgstr "开始时间" + +#. English: stderr +#. chrys-meta={"fingerprint":"a3d13bf12753a05efbb0dfed9972765064dbb5fc33d31a2399a7951e8c8e4f75","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.stderr" +msgstr "stderr" + +#. English: stdout +#. chrys-meta={"fingerprint":"ee75972b99d9c354c23287cc6cfe77e2cc9fe335813500d16c5b4b53db306cbf","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.stdout" +msgstr "stdout" + +#. English: Triggered by +#. chrys-meta={"fingerprint":"5cd22b0cf77e16370b37ac79a34cd00c327d796573870f049826aff9da5054a4","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.field.triggered_by" +msgstr "触发者" + +#. English: The result file was invalid and was ignored +#. chrys-meta={"fingerprint":"18480780d9864e4cf8c1178e8f5f856f3ab98d8302e2b993b7b45f21ac8e3603","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.invalid_result_file" +msgstr "结果文件无效,已忽略" + +#. English: Main agent +#. chrys-meta={"fingerprint":"2a6d7b7905e27f4347f243fecd0fd6331e8bfa169a9cc24685d5b5b14d3a91cc","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.main_agent" +msgstr "主智能体" + +#. English: No result file +#. chrys-meta={"fingerprint":"04c2e7eb5cd03a9e5ef3ef72ad7b833463eb2301237ab59239ebe0f7b4716be6","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.no_result_file" +msgstr "没有结果文件" + +#. English: The hook failed, so its result file was ignored +#. chrys-meta={"fingerprint":"5941f7b7eacc65678eb54266c7cd1183f55380f8fdbed7e7158a640316ea180c","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.failed" +msgstr "钩子运行失败,其结果文件已忽略" + +#. English: The hook failed and on_error is block, so the action was blocked +#. chrys-meta={"fingerprint":"25514934918920bf7c4f487119c01f3a47f175b825bf9f1a408d3d729387b9c8","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.failed_blocked" +msgstr "钩子运行失败且 on_error 为 block,因此阻止了此操作" + +#. English: Async and fire_and_forget hooks can't change the action +#. chrys-meta={"fingerprint":"d1a52020e2498e1b7988c02d2dfaf97dc177f66471e5df38d0b2bc56da895090","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.nonblocking" +msgstr "async 和 fire_and_forget 钩子不能改变操作" + +#. English: {event} doesn't support this action +#. chrys-meta={"fingerprint":"ffb21cb152099cd58c6ade26cf07bd47ed8c49302b9556f4f59a3d5b876cefa8","multiline":false,"placeholders":["event"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_applied.unsupported" +msgstr "{event} 不支持此操作" + +#. English: Payload cut at 64 KB of {size} +#. chrys-meta={"fingerprint":"95bc340815d859aa5908b17774f108a24218dd554b5853dc114b2c9ee0d2745e","multiline":false,"placeholders":["size"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.payload_truncated" +msgstr "Payload 共 {size},只保留前 64 KB" + +#. English: Retry of an earlier run +#. chrys-meta={"fingerprint":"f9ac568b9f02169a0ead2504c0ff7317e973cef24f9f8404ebe12255aac3a9b0","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.retry" +msgstr "重试之前的一次运行" + +#. English: Running… output shows when the hook ends +#. chrys-meta={"fingerprint":"35230ed1ed71285e58b95a23ecc45da5b076e124f83c2797286edb44e7dec7f5","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.running" +msgstr "运行中… 钩子结束后显示输出" + +#. English: Another run of this hook started in the same second and writes to +#. this log too +#. chrys-meta={"fingerprint":"c81fd0c547460ed1c031017f8424f5bec3995355d8d000041a098ab07f908f9e","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.shared_log" +msgstr "此钩子的另一次运行在同一秒开始,也写入这个日志" + +#. English: succeeded +#. chrys-meta={"fingerprint":"d20404943dcd141e2e88a8b0299a224899fc4d8746b9283f1cc6b76f83f50a54","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.succeeded" +msgstr "成功" + +#. English: Input +#. chrys-meta={"fingerprint":"aa801f5aa47a9644ff6d258e4f0dc34a47b07d38685b021f96fe2a5f3c4e0ee6","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.tab.input" +msgstr "输入" + +#. English: Output +#. chrys-meta={"fingerprint":"05859c2cb178f6b52e68ea789358b4f6d9958c27ad628104ccee05f67586426d","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.tab.output" +msgstr "输出" + +#. English: Hook #{number} · {hook_id} +#. chrys-meta={"fingerprint":"8e82c147d2837aca116516d443f774394ca47647ed6bbebd7007b2fbf84655bf","multiline":false,"placeholders":["hook_id","number"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.title" +msgstr "钩子 #{number} · {hook_id}" + +#. English: {bytes} bytes from the middle were not kept +#. chrys-meta={"fingerprint":"f46f77b798cdf4ad57c33b49b4dc605b60334c7b9e098be9f482c6bc6fec4305","multiline":false,"placeholders":["bytes"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.truncated" +msgstr "中间的 {bytes} 字节未保留" + #. English: Arguments: #. chrys-meta={"fingerprint":"abee83246fc63d7f641b344313ab75e31d459c0f4c99231da217a066e73be558","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/chat/renderers/hosted_generic.py @@ -10788,6 +11059,116 @@ msgstr "上下文用量" msgid "tui.sidebar.debug.event_stream" msgstr "事件流" +#. English: Clear ended +#. chrys-meta={"fingerprint":"dcb6a0d30ef78d850aaba51180142bacbdaa9a48b7a3679ec9f128ff97c7a0ca","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.clear_ended" +msgstr "清除已结束" + +#. English: Ended runs cleared +#. chrys-meta={"fingerprint":"ba6607222905de97189e941a750c15087d87caec10e868e928859598c72485de","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.cleared" +msgstr "已清除结束的运行" + +#. English: No hook has run since {app} started +#. chrys-meta={"fingerprint":"2478bf8fe82a678e47b454d6e27e963aee8cfdfdb23f0bbba9cfe1dccb3eefe0","multiline":false,"placeholders":["app"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.empty" +msgstr "{app} 启动以来还没有运行过钩子" + +#. English: No hooks configured +#. chrys-meta={"fingerprint":"322a4dcf13cf9491d6d5cd0110f494c7fa8c6471abc32932ed0075e1d1e34b10","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.empty_unconfigured" +msgstr "没有配置钩子" + +#. English: Only the latest {limit} runs are kept +#. chrys-meta={"fingerprint":"31444529819e96841ea27028af2ac4822b9296144dfb12ad85ecb3527975a294","multiline":false,"placeholders":["limit"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.only_latest_kept" +msgstr "只保留最近 {limit} 次运行" + +#. English: added context +#. chrys-meta={"fingerprint":"3fc7dccf24c12cc1fe775bb143e1897ef236d958265032a4799523254fb12ae0","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.added_context" +msgstr "追加了上下文" + +#. English: added reminder +#. chrys-meta={"fingerprint":"b3645d6786c7cd80ef5bbfd979f084e10375a347f8819f1e2a2224659f8adf8a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.added_reminder" +msgstr "添加了提醒" + +#. English: blocked +#. chrys-meta={"fingerprint":"23bcab7ae9ef97e9666043447f5b6968da692cbfffdf79d3a1fb059da04e7d7e","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.blocked" +msgstr "已阻止" + +#. English: cancelled +#. chrys-meta={"fingerprint":"a5fb27a22343ae45dffbf8e925df54667260b67e79f86aabb0cfa7dd81b207f2","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.cancelled" +msgstr "已取消" + +#. English: couldn't start +#. chrys-meta={"fingerprint":"75f6bf66f713d1eda2acb6a5ca8cf69e72af47ca156013576b17393908583a3f","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.couldnt_start" +msgstr "无法启动" + +#. English: detached +#. chrys-meta={"fingerprint":"f6f4dc1067c3131bf2f47ad10b96c2d8d8d4e164e21b31e15b96bbffbf0e5247","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.detached" +msgstr "已分离" + +#. English: exit {code} +#. chrys-meta={"fingerprint":"19d84c7da6f00031ba6efcaf7ea40ad59766c004b4654ac15b6e4c82aa079b64","multiline":false,"placeholders":["code"]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.exit" +msgstr "退出码 {code}" + +#. English: modified args +#. chrys-meta={"fingerprint":"29d632bd5cb8ff86dc4aa9e9b65518077c1723aeebde9a531f31471c18788ae3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.modified_args" +msgstr "修改了参数" + +#. English: queued +#. chrys-meta={"fingerprint":"292031914838efc235b6a1d841d6c5a9ba8e1d5dfeea522e30b6ac43c8b8f9f1","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.queued" +msgstr "排队中" + +#. English: running +#. chrys-meta={"fingerprint":"30a70ec2e1543266fdf8c3d3507853a36b54e6a70feed23c5ae22fd78476fa4a","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.running" +msgstr "运行中" + +#. English: timed out +#. chrys-meta={"fingerprint":"3ca20c7b1a56196dd6efffe72762fb8519065f05ea75a7578129f84f758ec0a5","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.outcome.timed_out" +msgstr "已超时" + +#. English: Hooks +#. chrys-meta={"fingerprint":"bc590033958e925397df73037927d0966b3d6b872ec389277b25224deed0ebd3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.title" +msgstr "钩子" + +#. English: Hooks ({count} active) +#. English-plural: Hooks ({count} active) +#. chrys-meta={"fingerprint":"3717b8678ac91680244380334d4243cf19a6e6973d36abe39a7f7ce6128761ee","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/hooks.py +msgid "tui.sidebar.hooks.title_active" +msgid_plural "tui.sidebar.hooks.title_active#plural" +msgstr[0] "钩子({count} 个进行中)" + #. English: Buddy #. chrys-meta={"fingerprint":"b24977515f388242f6162756260851ee0929d9063bb635c662fc21f76399f966","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/sidebar/panel.py @@ -10806,6 +11187,12 @@ msgstr "上下文" msgid "tui.sidebar.tab.debug" msgstr "调试" +#. English: Hooks +#. chrys-meta={"fingerprint":"bc590033958e925397df73037927d0966b3d6b872ec389277b25224deed0ebd3","multiline":false,"placeholders":[]} +#: src/chrys/app/tui/widgets/sidebar/panel.py +msgid "tui.sidebar.tab.hooks" +msgstr "钩子" + #. English: Messages #. chrys-meta={"fingerprint":"dc539a1862021f7a116d2443dde9458f890e3f149671bfc0021d3b6300e6147f","multiline":false,"placeholders":[]} #: src/chrys/app/tui/widgets/sidebar/panel.py @@ -14987,6 +15374,9 @@ msgstr "提示已被钩子阻止。" #~ msgid "tui.sidebar.buddy.personality" #~ msgstr "性格:{personality}" +#~ msgid "tui.sidebar.hooks.not_all_listed" +#~ msgstr "部分运行已不在列表中" + #~ msgid "tui.status.tool_call_plural" #~ msgid_plural "tui.status.tool_call_plural#plural" #~ msgstr[0] "{count} 次工具调用" diff --git a/src/chrys/app/tui/app.py b/src/chrys/app/tui/app.py index 4d17fc67c..18c21bbd6 100644 --- a/src/chrys/app/tui/app.py +++ b/src/chrys/app/tui/app.py @@ -1434,6 +1434,7 @@ def _on_successful_turn() -> None: on_successful_turn=_on_successful_turn, on_turn_started=session_title_updater.on_turn_started, surface=SessionSurface.TUI, + record_hook_runs=True, ) app = app_cls( diff --git a/src/chrys/app/tui/screens/dialogs/background_task.py b/src/chrys/app/tui/screens/dialogs/background_task.py index 85907e649..739d0739f 100644 --- a/src/chrys/app/tui/screens/dialogs/background_task.py +++ b/src/chrys/app/tui/screens/dialogs/background_task.py @@ -155,7 +155,7 @@ def confirm_stopping_left_behind(commands: Sequence[LeftCommand], owner: Widget) ) -class _LogTail: +class LogTail: """Reads what a log gained since the last read, at most its last ``_TAIL_BYTES``.""" def __init__(self, path: str) -> None: @@ -214,7 +214,7 @@ def __init__( self._latest = latest """The task as the main screen knows it now; None once it left the list.""" self._stop = stop - self._tail = _LogTail(task.log_path) + self._tail = LogTail(task.log_path) self._reading = False def compose(self) -> ComposeResult: diff --git a/src/chrys/app/tui/screens/dialogs/hook_run.py b/src/chrys/app/tui/screens/dialogs/hook_run.py new file mode 100644 index 000000000..9f9a21b9f --- /dev/null +++ b/src/chrys/app/tui/screens/dialogs/hook_run.py @@ -0,0 +1,628 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""HookRunModal — one hook run's input (what it was handed) and output (what it returned). + +The run's details come from the engine's run log, which keeps them in memory +only. While the run is queued or running, the modal reads it again once a +second and fills in the output when it ends; the output is collected when the +hook's process exits, so nothing streams before that. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import shlex +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, ClassVar + +from rich.style import Style +from rich.table import Table +from rich.text import Text +from textual import on +from textual.containers import ScrollableContainer, VerticalGroup +from textual.css.query import NoMatches +from textual.message import Message +from textual.widgets import Log, Static, TabbedContent, TabPane + +from chrys.app.tui.binding_display import CLOSE_BINDING, COPY_BINDING, localized_binding +from chrys.app.tui.i18n import render_str, widget_localizer +from chrys.app.tui.screens.dialogs.background_task import LogTail +from chrys.app.tui.screens.dialogs.base import BaseDialog +from chrys.app.tui.screens.dialogs.tool_view import ( + COPIED_INPUT, + COPIED_INPUT_TOO_LARGE, + COPIED_INPUT_UNAVAILABLE, + COPIED_OUTPUT, + COPIED_OUTPUT_TOO_LARGE, + COPIED_OUTPUT_UNAVAILABLE, + copy_and_notify, +) +from chrys.app.tui.util.removal import remove_children_shielded +from chrys.app.tui.widgets.chat.tool_call import fmt_duration +from chrys.app.tui.widgets.chat.tool_view_builders import build_code_view +from chrys.app.tui.widgets.sidebar.hooks import run_duration, run_icon, run_outcome +from chrys.foundation.i18n import MessageRef, msg +from chrys.foundation.i18n.formatting import sanitize_legacy_scalar +from chrys.foundation.platform.files import surrogate_safe_text + +if TYPE_CHECKING: + from collections.abc import Callable + + from textual.app import ComposeResult + from textual.events import Click + from textual.widget import Widget + + from chrys.foundation.models.hook_runs import HookRunDetail + +_POLL_SECONDS = 1.0 +_MAX_LOG_LINES = 10_000 +_QUEUED_SHOWN_AFTER_SECONDS = 0.01 +"""A shorter wait for a slot is no queueing worth naming.""" + +_TITLE = msg("tui.hook_run.title", fallback="Hook #{number} · {hook_id}") +_INPUT = msg("tui.hook_run.tab.input", fallback="Input") +_OUTPUT = msg("tui.hook_run.tab.output", fallback="Output") +_EVENT = msg("tui.hook_run.field.event", fallback="Event") +_MODE = msg("tui.hook_run.field.mode", fallback="Mode") +_SESSION = msg("tui.hook_run.field.session", fallback="Session") +_TRIGGERED_BY = msg("tui.hook_run.field.triggered_by", fallback="Triggered by") +_STARTED = msg("tui.hook_run.field.started", fallback="Started") +_QUEUED = msg("tui.hook_run.field.queued", fallback="Queued") +_SOURCE = msg("tui.hook_run.field.source", fallback="Source") +_RUN = msg("tui.hook_run.field.run", fallback="Run") +_OVERVIEW = msg("tui.hook_run.field.overview", fallback="Overview") +_PAYLOAD = msg("tui.hook_run.field.payload", fallback="Payload") +_RESULT = msg("tui.hook_run.field.result", fallback="Result") +_DECISION = msg("tui.hook_run.field.decision", fallback="Decision") +_APPLIED = msg("tui.hook_run.field.applied", fallback="Applied") +_STDOUT = msg("tui.hook_run.field.stdout", fallback="stdout") +_STDERR = msg("tui.hook_run.field.stderr", fallback="stderr") +_LOG = msg("tui.hook_run.field.log", fallback="Log") +_LOG_FILE = msg("tui.hook_run.field.log_file", fallback="Log file") +_MAIN_AGENT = msg("tui.hook_run.main_agent", fallback="Main agent") +_RETRY = msg("tui.hook_run.retry", fallback="Retry of an earlier run") +_SUCCEEDED = msg("tui.hook_run.succeeded", fallback="succeeded") +_EXIT_CODE = msg("tui.hook_run.exit_code", fallback="exit code {code}") +_RUNNING = msg("tui.hook_run.running", fallback="Running… output shows when the hook ends") +_DETAILS_GONE = msg("tui.hook_run.details_gone", fallback="Details are no longer kept for this run") +_NO_RESULT_FILE = msg("tui.hook_run.no_result_file", fallback="No result file") +_INVALID_RESULT_FILE = msg("tui.hook_run.invalid_result_file", fallback="The result file was invalid and was ignored") +_APPLIED_NOTHING = msg("tui.hook_run.applied.nothing", fallback="Nothing") +_APPLIED_BLOCKED = msg("tui.hook_run.applied.blocked", fallback="Blocked the action: {reason}") +_APPLIED_ARGS = msg("tui.hook_run.applied.args", fallback="Changed the tool arguments to:") +_APPLIED_REMINDER = msg("tui.hook_run.applied.reminder", fallback="Added a reminder for the model:") +_APPLIED_CONTEXT = msg("tui.hook_run.applied.context", fallback="Added to the tool result:") +_NOT_APPLIED_NONBLOCKING = msg( + "tui.hook_run.not_applied.nonblocking", fallback="Async and fire_and_forget hooks can't change the action" +) +_NOT_APPLIED_UNSUPPORTED = msg("tui.hook_run.not_applied.unsupported", fallback="{event} doesn't support this action") +_NOT_APPLIED_FAILED = msg("tui.hook_run.not_applied.failed", fallback="The hook failed, so its result file was ignored") +_NOT_APPLIED_FAILED_BLOCKED = msg( + "tui.hook_run.not_applied.failed_blocked", + fallback="The hook failed and on_error is block, so the action was blocked", +) +_TRUNCATED = msg("tui.hook_run.truncated", fallback="{bytes} bytes from the middle were not kept") +_PAYLOAD_TRUNCATED = msg("tui.hook_run.payload_truncated", fallback="Payload cut at 64 KB of {size}") +_SHARED_LOG = msg( + "tui.hook_run.shared_log", + fallback="Another run of this hook started in the same second and writes to this log too", +) +_COPIED_SECTION = msg("tui.hook_run.copy.section", fallback="Copied {section}") +_COPIED_SECTION_TOO_LARGE = msg( + "tui.hook_run.copy.section_too_large", + fallback="Copied {section} (terminal clipboard skipped: payload too large)", +) +_COPIED_SECTION_UNAVAILABLE = msg( + "tui.hook_run.copy.section_unavailable", fallback="Copied {section} (terminal clipboard unavailable)" +) +_COPIED_LOG_PATH = msg("tui.hook_run.copy.log_path", fallback="Copied log path") +_COPIED_LOG_PATH_UNAVAILABLE = msg( + "tui.hook_run.copy.log_path_unavailable", fallback="Copied log path (terminal clipboard unavailable)" +) + + +def _wall_time(epoch: float) -> str: + return datetime.fromtimestamp(epoch, tz=UTC).astimezone().strftime("%Y-%m-%d %H:%M:%S") + + +def _size(count: int) -> str: + if count < 1024: + return f"{count} B" + if count < 1024 * 1024: + return f"{count / 1024:.1f} KB" + return f"{count / (1024 * 1024):.1f} MB" + + +def _pretty_json(text: str) -> str: + try: + return json.dumps(json.loads(text), indent=2, ensure_ascii=False) + except ValueError: + return text + + +@dataclass(frozen=True, slots=True) +class _Fields: + rows: tuple[tuple[str, Text], ...] + id: str | None = None + + +@dataclass(frozen=True, slots=True) +class _Note: + text: str + copied: bool = True + """False for a remark about the section, left out of what it copies.""" + + +@dataclass(frozen=True, slots=True) +class _Code: + language: str + text: str + + +@dataclass(frozen=True, slots=True) +class _Line: + text: Text + + +@dataclass(frozen=True, slots=True) +class _LogPath: + path: str + + +@dataclass(frozen=True, slots=True) +class _LogTail: + pass + + +_Block = _Fields | _Line | _Note | _Code | _LogPath | _LogTail + + +@dataclass(frozen=True, slots=True) +class _Section: + """One part of a tab, copied whole by a click: its widgets and its copied text come from its blocks.""" + + name: str + """Its box's title, and what the copied notice names.""" + blocks: tuple[_Block, ...] + + +class _CopySection(VerticalGroup): + """A section of a tab: a click anywhere in it copies the section, unless it ends a text selection.""" + + class Clicked(Message): + def __init__(self, key: str) -> None: + super().__init__() + self.key = key + + def __init__(self, *children: Widget, title: str, key: str) -> None: + super().__init__(*children, classes="hook-run-section") + self.border_title = Text(title) + self.key = key + + def on_click(self, event: Click) -> None: + event.stop() + if self.screen.get_selected_text(): + return + self.post_message(self.Clicked(self.key)) + + # Textual marks only the innermost widget hovered: the whole section lights up as one. + def on_enter(self) -> None: + self.set_class(True, "-hover") + + def on_leave(self) -> None: + if not (self.screen.is_current and self.region.contains_point(self.app.mouse_position)): + self.set_class(False, "-hover") + + +def _fields_plain(fields: _Fields) -> str: + lines = [] + for label, value in fields.rows: + first, *rest = value.plain.split("\n") + lines.append(f"{label}: {first}") + lines.extend(" " * (len(label) + 2) + line for line in rest) + return "\n".join(lines) + + +class HookRunModal(BaseDialog[None]): + """A hook run's payload and run settings, then its result file, output and what iCode took from it.""" + + CSS_PATH = "hook_run.tcss" + + BINDINGS: ClassVar[list] = [ + localized_binding("escape", "close", CLOSE_BINDING, show=False, priority=True), + localized_binding("q", "close", CLOSE_BINDING, show=False), + localized_binding("c", "copy", COPY_BINDING, show=False), + ] + + def __init__(self, detail: HookRunDetail, *, latest: Callable[[str], HookRunDetail | None]) -> None: + super().__init__() + self._shown = detail + self._latest = latest + """The run as the run log has it now; None once the log no longer keeps it.""" + self._tail = LogTail(detail.log_path) if detail.log_path else None + self._reading = False + self._rebuilding = False + + # ── layout ─────────────────────────────────────────────────────── + + def compose(self) -> ComposeResult: + detail = self._shown + with VerticalGroup(id="hook-run-container") as container: + container.border_title = Text( + self._text( + _TITLE.bind(number=str(detail.record.number), hook_id=sanitize_legacy_scalar(detail.record.hook_id)) + ) + ) + with TabbedContent(id="hook-run-tabs"): + with ( + TabPane(self._text(_INPUT.bind()), id="hook-run-input"), + ScrollableContainer(classes="hook-run-scroll"), + ): + yield from self._widgets("input", self._input_sections()) + with TabPane(self._text(_OUTPUT.bind()), id="hook-run-output"): + yield ScrollableContainer( + *self._widgets("output", self._output_sections()), + id="hook-run-output-body", + classes="hook-run-scroll", + ) + + def on_mount(self) -> None: + self._show_state() + # Like ToolDetailModal: switch tabs once they mounted, never through ``initial=``. + # An ended run opens on what it returned; one still in flight has only its input yet. + if self._shown.record.state == "ended": + self.call_after_refresh(self._open_output) + if self._shown.record.state != "ended" or self._tail is not None: + self.set_interval(_POLL_SECONDS, self._poll) + if self._tail is not None: + self._read_log() + + def _open_output(self) -> None: + with contextlib.suppress(NoMatches): + self.query_one(TabbedContent).active = "hook-run-output" + + def _text(self, reference: MessageRef) -> str: + return render_str(widget_localizer(self), reference) + + def _fields(self, rows: list[tuple[MessageRef, Text | str]], *, id: str | None = None) -> _Fields: + return _Fields( + tuple( + (self._text(label), value if isinstance(value, Text) else Text(value)) for label, value in rows if value + ), + id=id, + ) + + def _note(self, label: MessageRef, *, copied: bool = True) -> _Note: + return _Note(self._text(label), copied=copied) + + @staticmethod + def _grid(fields: _Fields) -> Table: + grid = Table.grid(padding=(0, 2)) + grid.add_column(style="dim", no_wrap=True) + grid.add_column(overflow="fold") + for label, value in fields.rows: + grid.add_row(label, value) + return grid + + def _widgets(self, tab: str, sections: list[_Section]) -> list[Widget]: + widgets: list[Widget] = [] + for index, section in enumerate(sections): + children: list[Widget] = [] + for block in section.blocks: + match block: + case _Fields(): + children.append(Static(self._grid(block), id=block.id)) + case _Line(): + children.append(Static(block.text)) + case _Note(): + children.append(Static(Text(block.text), classes="hook-run-note")) + case _Code(): + children.append( + build_code_view( + block.language, block.text, dark=self.app.current_theme.dark, render_message=self._text + ) + ) + case _LogPath(): + children.append(Static(Text(surrogate_safe_text(block.path)), classes="hook-run-note")) + case _LogTail(): + children.append(Log(max_lines=_MAX_LOG_LINES, id="hook-run-log")) + widgets.append(_CopySection(*children, title=section.name, key=f"{tab}:{index}")) + return widgets + + def _section_text(self, section: _Section) -> str: + """What the section shows, in plain text, but its title.""" + parts: list[str] = [] + for block in section.blocks: + match block: + case _Fields(): + parts.append(_fields_plain(block)) + case _Line(): + parts.append(block.text.plain) + case _Note() if block.copied: + parts.append(block.text) + case _Code(): + parts.append(block.text) + case _LogPath(): + parts.append(surrogate_safe_text(block.path)) + case _LogTail(): + with contextlib.suppress(NoMatches): + # The log keeps the empty line its last newline opens. + parts.append("\n".join(self.query_one("#hook-run-log", Log).lines).rstrip("\n")) + return "\n".join(part for part in parts if part) + + def _plain(self, sections: list[_Section]) -> str: + """The whole tab in plain text: each section starts ``## title``.""" + texts = [] + for section in sections: + text = self._section_text(section) + texts.append(f"## {section.name}\n{text}" if text else f"## {section.name}") + return "\n\n".join(texts) + + def _input_sections(self) -> list[_Section]: + return [_Section(self._text(_OVERVIEW.bind()), (self._input_fields(),)), self._payload_section()] + + def _input_fields(self) -> _Fields: + detail = self._shown + record = detail.record + mode = [record.execution_mode] + if record.detach: + mode.append("detach") + mode.append(f"timeout {detail.timeout_seconds:g}s") + mode.append(f"on_error: {detail.on_error}") + if detail.delivery == "durable": + mode.append("delivery: durable") + triggered = [sanitize_legacy_scalar(record.sub_agent) if record.sub_agent else self._text(_MAIN_AGENT.bind())] + if record.tool_name: + tool = sanitize_legacy_scalar(record.tool_name) + triggered.append(f"{tool} ({sanitize_legacy_scalar(detail.tool_call_id)})" if detail.tool_call_id else tool) + if record.retry: + triggered.append(self._text(_RETRY.bind())) + started = queued = "" + now = time.monotonic() + if record.started_at is not None: + started = _wall_time(record.registered_wall + (record.started_at - record.registered_at)) + waited = record.started_at - record.registered_at + else: + waited = (now if record.state == "queued" else record.registered_at) - record.registered_at + if waited >= _QUEUED_SHOWN_AFTER_SECONDS: + queued = fmt_duration(int(waited * 1000)) + source = " · ".join(part for part in (detail.source, surrogate_safe_text(detail.source_path)) if part) + if detail.run_type == "shell": + command = detail.run_argv[0] if detail.run_argv else "" + else: + command = shlex.join(detail.run_argv) + run_lines = [f"{detail.run_type}: {sanitize_legacy_scalar(command)}"] + if detail.run_cwd: + run_lines.append(f"cwd: {surrogate_safe_text(detail.run_cwd)}") + if detail.run_env_names: + run_lines.append(f"env: {', '.join(sanitize_legacy_scalar(name) for name in detail.run_env_names)}") + return self._fields( + [ + (_EVENT.bind(), record.hook_event), + (_MODE.bind(), " · ".join(mode)), + (_SESSION.bind(), record.session_id), + (_TRIGGERED_BY.bind(), " · ".join(triggered)), + (_STARTED.bind(), started), + (_QUEUED.bind(), queued), + (_SOURCE.bind(), source), + (_RUN.bind(), "\n".join(run_lines)), + ], + id="hook-run-input-fields", + ) + + def _payload_section(self) -> _Section: + name = self._text(_PAYLOAD.bind()) + io = self._shown.io + if io is None: + return _Section(name, (self._note(_DETAILS_GONE.bind()),)) + if io.payload_size > len(io.payload.encode("utf-8")): + return _Section( + name, + ( + self._note(_PAYLOAD_TRUNCATED.bind(size=_size(io.payload_size)), copied=False), + _Code("text", io.payload), + ), + ) + return _Section(name, (_Code("json", _pretty_json(io.payload)),)) + + # ── output ─────────────────────────────────────────────────────── + + def _result_text(self) -> Text: + record = self._shown.record + parts = [self._text(part) for part in run_outcome(record) or (_SUCCEEDED.bind(),)] + if record.exit_code is not None and record.outcome != "failed": + parts.append(self._text(_EXIT_CODE.bind(code=str(record.exit_code)))) + duration = run_duration(record, time.monotonic()) + if duration: + parts.append(duration) + return Text.assemble(run_icon(record, running_style=Style(color="yellow")), " ", " · ".join(parts)) + + def _output_sections(self) -> list[_Section]: + detail = self._shown + result = self._text(_RESULT.bind()) + if detail.record.state != "ended": + return [_Section(result, (self._note(_RUNNING.bind()),))] + io = detail.io + outcome: tuple[_Block, ...] = (_Line(self._result_text()),) + if io is None: + outcome = (*outcome, self._note(_DETAILS_GONE.bind(), copied=False)) + sections = [_Section(result, outcome)] + if io is not None and io.result_file is not None: + if io.result_file == "ok": + decision: _Block = _Code("json", io.decision) + else: + decision = self._note((_NO_RESULT_FILE if io.result_file == "missing" else _INVALID_RESULT_FILE).bind()) + sections.append(_Section(self._text(_DECISION.bind()), (decision,))) + sections.append(_Section(self._text(_APPLIED.bind()), tuple(self._applied_blocks()))) + if io is not None and not detail.record.detach: + sections.append(self._stream_section(_STDOUT.bind(), io.stdout, io.stdout_dropped)) + sections.append(self._stream_section(_STDERR.bind(), io.stderr, io.stderr_dropped)) + elif io is not None and io.stderr: + # A detached hand-off that failed says why. + sections.append(self._stream_section(_STDERR.bind(), io.stderr, 0)) + if detail.log_path: + path: tuple[_Block, ...] = (_LogPath(detail.log_path),) + if detail.shared_log: + path = (*path, self._note(_SHARED_LOG.bind(), copied=False)) + sections.append(_Section(self._text(_LOG_FILE.bind()), path)) + sections.append(_Section(self._text(_LOG.bind()), (_LogTail(),))) + return sections + + def _applied_blocks(self) -> list[_Block]: + detail = self._shown + applied = detail.applied + effect = detail.record.effect + if applied is None: + return [self._note(_APPLIED_NOTHING.bind())] + widgets: list[_Block] = [] + if effect == "blocked": + widgets.append(self._note(_APPLIED_BLOCKED.bind(reason=sanitize_legacy_scalar(applied.block_reason)))) + elif effect == "modified_args": + widgets.append(self._note(_APPLIED_ARGS.bind())) + widgets.append(_Code("json", applied.args)) + elif effect == "added_reminder": + widgets.append(self._note(_APPLIED_REMINDER.bind())) + widgets.append(_Code("text", applied.reminder)) + elif effect == "added_context": + widgets.append(self._note(_APPLIED_CONTEXT.bind())) + widgets.append(_Code("text", applied.extra_context)) + reason = { + "nonblocking": _NOT_APPLIED_NONBLOCKING.bind(), + "unsupported": _NOT_APPLIED_UNSUPPORTED.bind(event=detail.record.hook_event), + "failed": _NOT_APPLIED_FAILED.bind(), + "failed_blocked": _NOT_APPLIED_FAILED_BLOCKED.bind(), + None: None, + }[applied.not_applied] + if reason is not None: + widgets.append(self._note(reason)) + return widgets or [self._note(_APPLIED_NOTHING.bind())] + + def _stream_section(self, label: MessageRef, text: str, dropped: int) -> _Section: + blocks: tuple[_Block, ...] = (_Code("text", text),) + if dropped: + blocks = (self._note(_TRUNCATED.bind(bytes=str(dropped)), copied=False), *blocks) + return _Section(self._text(label), blocks) + + # ── updates ────────────────────────────────────────────────────── + + def _show_state(self) -> None: + with contextlib.suppress(NoMatches): + self.query_one("#hook-run-container").border_subtitle = self._result_text() + + async def _poll(self) -> None: + if self._tail is not None and not self._reading: + self._read_log() + if self._shown.record.state == "ended": + return + latest = self._latest(self._shown.record.run_id) + if latest is None: + return + changed = latest.record != self._shown.record + self._shown = latest + self._show_state() + if not changed or self._rebuilding: + return + with contextlib.suppress(NoMatches): + self.query_one("#hook-run-input-fields", Static).update(self._grid(self._input_fields())) + if latest.record.state != "ended": + return + # Ended: its output arrived. The tab stays where the user is. + self._rebuilding = True + try: + body = self.query_one("#hook-run-output-body", ScrollableContainer) + await remove_children_shielded(body) + await body.mount(*self._widgets("output", self._output_sections())) + except NoMatches: + return + finally: + self._rebuilding = False + if latest.log_path and self._tail is None: + self._tail = LogTail(latest.log_path) + self._read_log() + + def _read_log(self) -> None: + self._reading = True + self.run_worker(self._read_more(), exit_on_error=False) + + async def _read_more(self) -> None: + tail = self._tail + if tail is None: + self._reading = False + return + try: + text, restart = await asyncio.to_thread(tail.read) + except OSError: + return + finally: + self._reading = False + with contextlib.suppress(NoMatches): + log = self.query_one("#hook-run-log", Log) + if restart: + log.clear() + if text: + log.write(text, scroll_end=log.is_vertical_scroll_end) + + # ── actions ────────────────────────────────────────────────────── + + def _input_text(self) -> str: + """Everything the Input tab shows.""" + return self._plain(self._input_sections()) + + def _output_text(self) -> str: + """Everything the Output tab shows, the log read so far included.""" + return self._plain(self._output_sections()) + + @on(_CopySection.Clicked) + def _on_copy_section(self, event: _CopySection.Clicked) -> None: + event.stop() + tab, _, index = event.key.partition(":") + sections = self._input_sections() if tab == "input" else self._output_sections() + if not index.isdigit() or int(index) >= len(sections): + return + section = sections[int(index)] + if any(isinstance(block, _LogPath) for block in section.blocks): + copied, too_large, unavailable = ( + _COPIED_LOG_PATH, + _COPIED_LOG_PATH_UNAVAILABLE, + _COPIED_LOG_PATH_UNAVAILABLE, + ) + copy_and_notify(self, self._shown.log_path, copied=copied, too_large=too_large, unavailable=unavailable) + return + name = section.name + copy_and_notify( + self, + self._section_text(section), + copied=_COPIED_SECTION.bind(section=name), + too_large=_COPIED_SECTION_TOO_LARGE.bind(section=name), + unavailable=_COPIED_SECTION_UNAVAILABLE.bind(section=name), + ) + + def action_copy(self) -> None: + """Copy everything the open tab shows.""" + on_output = False + with contextlib.suppress(NoMatches): + on_output = self.query_one(TabbedContent).active == "hook-run-output" + if on_output: + copy_and_notify( + self, + self._output_text(), + copied=COPIED_OUTPUT, + too_large=COPIED_OUTPUT_TOO_LARGE, + unavailable=COPIED_OUTPUT_UNAVAILABLE, + ) + else: + copy_and_notify( + self, + self._input_text(), + copied=COPIED_INPUT, + too_large=COPIED_INPUT_TOO_LARGE, + unavailable=COPIED_INPUT_UNAVAILABLE, + ) + + def action_close(self) -> None: + """Close the dialog.""" + self.dismiss(None) diff --git a/src/chrys/app/tui/screens/dialogs/hook_run.tcss b/src/chrys/app/tui/screens/dialogs/hook_run.tcss new file mode 100644 index 000000000..6bc409416 --- /dev/null +++ b/src/chrys/app/tui/screens/dialogs/hook_run.tcss @@ -0,0 +1,66 @@ +HookRunModal { + align: center middle; + background: $background 40%; +} + +#hook-run-container { + width: 92%; + max-width: 120; + height: 85%; + max-height: 45; + border: round $tui-border-primary $border-opacity; + border-title-color: $primary; + border-title-style: bold; + border-subtitle-color: $text-muted; + background: $surface; + padding: 0; +} + +#hook-run-tabs { + height: 1fr; +} + +#hook-run-tabs ContentSwitcher { + height: 1fr; +} + +#hook-run-tabs TabPane { + height: 1fr; + padding: 0; +} + +.hook-run-scroll { + width: 100%; + height: 1fr; + padding: 0 0 0 1; + scrollbar-size: 1 1; +} + +/* Boxed as Runtime Details' sections; a box copies itself on a click, and the mouse over it shows which. */ +.hook-run-section { + width: 100%; + height: auto; + border: round $tui-border-primary 45%; + border-title-color: $primary; + border-title-style: bold; + margin: 0 1 1 0; + padding: 0 1; + color: $text; + pointer: pointer; +} + +.hook-run-section.-hover { + background: $boost; + border: round $tui-border-primary; +} + +.hook-run-note { + color: $text-muted; +} + +#hook-run-log { + height: auto; + max-height: 12; + scrollbar-size: 1 1; + background: transparent; +} diff --git a/src/chrys/app/tui/screens/dialogs/tool_view.py b/src/chrys/app/tui/screens/dialogs/tool_view.py index ed0206de3..fb871cf41 100644 --- a/src/chrys/app/tui/screens/dialogs/tool_view.py +++ b/src/chrys/app/tui/screens/dialogs/tool_view.py @@ -30,7 +30,7 @@ from chrys.app.tui.widgets.chat.agent_transcript_surface import AgentTranscriptSurface from chrys.app.tui.widgets.chat.tool_call import ToolViewRequested from chrys.app.tui.widgets.chat.tool_view_builders import ToolViewContent -from chrys.foundation.i18n import MessageDef, msg +from chrys.foundation.i18n import MessageDef, MessageRef, msg if TYPE_CHECKING: from textual.app import ComposeResult @@ -39,9 +39,9 @@ _TITLE_MAX = 72 -_COPIED_INPUT = msg("tui.tool_view.copy.input", fallback="Copied input") +COPIED_INPUT = msg("tui.tool_view.copy.input", fallback="Copied input") COPIED_OUTPUT = msg("tui.tool_view.copy.output", fallback="Copied output") -_COPIED_INPUT_TOO_LARGE = msg( +COPIED_INPUT_TOO_LARGE = msg( "tui.tool_view.copy.input_too_large", fallback="Copied input (terminal clipboard skipped: payload too large)", ) @@ -49,7 +49,7 @@ "tui.tool_view.copy.output_too_large", fallback="Copied output (terminal clipboard skipped: payload too large)", ) -_COPIED_INPUT_UNAVAILABLE = msg( +COPIED_INPUT_UNAVAILABLE = msg( "tui.tool_view.copy.input_unavailable", fallback="Copied input (terminal clipboard unavailable)", ) @@ -65,9 +65,14 @@ def copy_and_notify( - screen: Screen[object], payload: str, *, copied: MessageDef, too_large: MessageDef, unavailable: MessageDef + screen: Screen[object], + payload: str, + *, + copied: MessageDef | MessageRef, + too_large: MessageDef | MessageRef, + unavailable: MessageDef | MessageRef, ) -> None: - """Copy *payload* to the clipboards and tell how that went.""" + """Copy *payload* to the clipboards and tell how that went; a message with placeholders comes bound.""" terminal_payload_too_large = terminal_clipboard_size(payload) > OSC52_COPY_MAX_BYTES if copy_text_to_clipboards(screen.app, payload, max_terminal_bytes=OSC52_COPY_MAX_BYTES): message, timeout = copied, 2 @@ -77,7 +82,7 @@ def copy_and_notify( message, timeout = unavailable, 3 localizer = widget_localizer(screen) screen.notify( - render_str(localizer, message.bind()), + render_str(localizer, message.bind() if isinstance(message, MessageDef) else message), title=render_str(localizer, COPIED_TITLE.bind()), timeout=timeout, markup=False, @@ -203,9 +208,9 @@ def action_copy(self) -> None: copy_and_notify( self, self._raw_input, - copied=_COPIED_INPUT, - too_large=_COPIED_INPUT_TOO_LARGE, - unavailable=_COPIED_INPUT_UNAVAILABLE, + copied=COPIED_INPUT, + too_large=COPIED_INPUT_TOO_LARGE, + unavailable=COPIED_INPUT_UNAVAILABLE, ) def action_close(self) -> None: diff --git a/src/chrys/app/tui/screens/main/engine_read_model.py b/src/chrys/app/tui/screens/main/engine_read_model.py index 8980f0642..ace46faaf 100644 --- a/src/chrys/app/tui/screens/main/engine_read_model.py +++ b/src/chrys/app/tui/screens/main/engine_read_model.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from collections.abc import Awaitable, Callable + from chrys.foundation.models.hook_runs import HookRunDetail, HookRunRecord + from chrys.service.hooks.run_log import HookRunLog from chrys.service.mutations.coordination import MutationCoordinator from chrys.service.mutations.types import RollbackPlan @@ -38,6 +40,9 @@ def build_generation(self) -> int: ... @property def workspace_primary_cwd(self) -> str: ... + @property + def hook_runs(self) -> HookRunLog | None: ... + def available_rollback_turns(self) -> list[int]: """Return rollback turn numbers available for display.""" @@ -117,6 +122,21 @@ def workspace_cwd(self) -> str: """Return the normalized primary cwd owned by the current runtime build.""" return self._engine.workspace_primary_cwd + def hook_runs(self) -> tuple[HookRunRecord, ...]: + """Every session's hook runs since iCode started, in the Hooks panel's order; in memory, cheap.""" + log = self._engine.hook_runs + return log.records() if log is not None else () + + def hook_runs_dropped(self) -> int: + """Ended runs the run log no longer keeps, past its cap.""" + log = self._engine.hook_runs + return log.dropped if log is not None else 0 + + def hook_run_detail(self, run_id: str) -> HookRunDetail | None: + """One run's details; None once the run log no longer keeps the run.""" + log = self._engine.hook_runs + return log.detail(run_id) if log is not None else None + async def begin_rollback_projection( self, *, diff --git a/src/chrys/app/tui/screens/main/event_handlers.py b/src/chrys/app/tui/screens/main/event_handlers.py index 95022e37d..464c07f76 100644 --- a/src/chrys/app/tui/screens/main/event_handlers.py +++ b/src/chrys/app/tui/screens/main/event_handlers.py @@ -77,6 +77,7 @@ ContextCompressed, Error, HookExecutionsChanged, + HookRunsChanged, ImageAttachmentCompressionFinished, ImageAttachmentCompressionStarted, InvocationAborted, @@ -1704,6 +1705,12 @@ async def on_hook_executions_changed(self, event: HookExecutionsChanged) -> None if event.session_id == self.main_usage_source_id: self.refresh_hook_indicator() + async def on_hook_runs_changed(self, _event: HookRunsChanged) -> None: + # Every session's runs show, so no session filter. Only marks the panel + # stale: it rereads the run log on its own pump, once per pump at most, + # and only while it shows. + self._ui().hook_runs_changed() + def refresh_hook_indicator(self) -> None: """Queue one render of the last hook snapshot; snapshots arriving before it runs share it.""" runtime = self._state.runtime diff --git a/src/chrys/app/tui/screens/main/main_screen_presenter.py b/src/chrys/app/tui/screens/main/main_screen_presenter.py index a6ffbc7ba..6bef6c913 100644 --- a/src/chrys/app/tui/screens/main/main_screen_presenter.py +++ b/src/chrys/app/tui/screens/main/main_screen_presenter.py @@ -105,6 +105,9 @@ def set_background_tasks(self, tasks: tuple[BackgroundTaskView, ...]) -> None: def set_hook_executions(self, executions: tuple[HookExecutionView, ...]) -> None: self._view.set_hook_executions(executions) + def hook_runs_changed(self) -> None: + self._view.hook_runs_changed() + def set_header_approval_mode(self, mode: ApprovalMode) -> None: self._view.set_header_approval_mode(mode) diff --git a/src/chrys/app/tui/screens/main/ports.py b/src/chrys/app/tui/screens/main/ports.py index af7f62526..05ccafe12 100644 --- a/src/chrys/app/tui/screens/main/ports.py +++ b/src/chrys/app/tui/screens/main/ports.py @@ -468,6 +468,7 @@ def set_todo_state(self, items: tuple[TodoItem, ...]) -> None: ... def clear_todos(self) -> None: ... def set_background_tasks(self, tasks: tuple[BackgroundTaskView, ...]) -> None: ... def set_hook_executions(self, executions: tuple[HookExecutionView, ...]) -> None: ... + def hook_runs_changed(self) -> None: ... def set_header_approval_mode(self, mode: ApprovalMode) -> None: ... def set_terminal_title_for_cwd(self, cwd: str | None = None) -> None: ... def set_terminal_title_for_user_message(self, text: str) -> None: ... diff --git a/src/chrys/app/tui/screens/main/screen.py b/src/chrys/app/tui/screens/main/screen.py index 434e00f66..acd765919 100644 --- a/src/chrys/app/tui/screens/main/screen.py +++ b/src/chrys/app/tui/screens/main/screen.py @@ -40,6 +40,7 @@ ) from chrys.app.tui.screens.main.copy_actions import CopyActionController from chrys.app.tui.screens.main.diff_controller import DiffController, LiveDiffOwner, LiveDiffTracker +from chrys.app.tui.screens.main.engine_read_model import EngineReadModel from chrys.app.tui.screens.main.event_handlers import BackendEventCallbacks, BackendEventHandler from chrys.app.tui.screens.main.input_flow import InputFlowController from chrys.app.tui.screens.main.model_indicator import ( @@ -102,7 +103,8 @@ from chrys.app.tui.widgets.editor import MESSAGE_EDITOR_MAX_CHARACTERS, EditorBufferSnapshot, EditorMode from chrys.app.tui.widgets.sidebar.background_tasks import BackgroundTaskList from chrys.app.tui.widgets.sidebar.context import ContextUsageState -from chrys.app.tui.widgets.sidebar.panel import SidebarPanel +from chrys.app.tui.widgets.sidebar.hooks import HookRunList +from chrys.app.tui.widgets.sidebar.panel import SIDEBAR_HOOKS_TAB, SidebarPanel from chrys.app.tui.widgets.sidebar.tasks import TodoListState from chrys.app.tui.widgets.sidebar.toc import ConversationToc from chrys.app.tui.widgets.trajectory import TrajectoryDashboard @@ -149,6 +151,7 @@ from chrys.foundation.config.settings_store import PersistResult, SettingsHandle from chrys.foundation.events.bus import EventBus from chrys.foundation.models.background_tasks import BackgroundTaskView + from chrys.foundation.models.hook_runs import HookRunDetail, HookRunRecord from chrys.orchestration.engine.engine import AgentEngine from chrys.service.profiles.agents.registry import AgentProfileRegistry from chrys.service.profiles.models.registry import ModelProfileRegistry @@ -1096,6 +1099,7 @@ def on_screen_resume(self, _event: ScreenResume) -> None: self.query_one(InputBar).sync_deferred_button_geometry() self.sync_footer_bindings() self.query_one(HookIndicator).screen_resumed() + self.query_one(HookRunList).screen_resumed() self.query_one(SuggestionList).resume_marquee() if self.theme_editor is not None: self.theme_editor.resume() @@ -1284,6 +1288,9 @@ async def on_mount(self) -> None: ) self._suggestions.build_slash_commands() + self.query_one(SidebarPanel).hooks_panel.bind_source( + self._hook_runs, self._configured_hook_count, self._hook_runs_dropped + ) await self._subscriptions.subscribe_all() await self._workflow.subscribe() self._sync_execution_chrome() @@ -1786,11 +1793,54 @@ def action_runtime_details(self) -> None: """Open the active runtime details modal.""" self._config_actions.open_runtime_details() - def on_hook_indicator_clicked(self, _event: HookIndicator.Clicked) -> None: - """Show the session's hooks: the runtime details' Hooks tab.""" - from chrys.app.tui.screens.dialogs.runtime_details import RUNTIME_DETAILS_HOOKS_TAB + def on_hook_indicator_clicked(self, event: HookIndicator.Clicked) -> None: + """Show the sidebar's Hooks tab, and over it the run the indicator shows, if any. + + The tab opens the sidebar if it is closed, so closing the run's details + lands on the list, scrolled to that run's row and marking it. A workflow hides that tab: there, with no run shown, + the configured hooks show instead. + """ + detail = self._hook_run_detail(event.run_id) if event.run_id else None + if not self._workflow.workflow_mode: + sidebar = self.query_one(SidebarPanel) + if not sidebar.is_visible: + self._navigation.toggle_sidebar() + sidebar.focus_tab(SIDEBAR_HOOKS_TAB) + if detail is not None: + sidebar.hooks_panel.select(event.run_id) + if detail is not None: + from chrys.app.tui.screens.dialogs.hook_run import HookRunModal + + self.app.push_screen(HookRunModal(detail, latest=self._hook_run_detail)) + elif self._workflow.workflow_mode: + from chrys.app.tui.screens.dialogs.runtime_details import RUNTIME_DETAILS_HOOKS_TAB + + self._config_actions.open_runtime_details(initial_tab=RUNTIME_DETAILS_HOOKS_TAB) + + def _hook_runs(self) -> tuple[HookRunRecord, ...]: + provider = self._services.engine_provider + return EngineReadModel(provider()).hook_runs() if provider is not None else () + + def _hook_runs_dropped(self) -> int: + provider = self._services.engine_provider + return EngineReadModel(provider()).hook_runs_dropped() if provider is not None else 0 + + def _hook_run_detail(self, run_id: str) -> HookRunDetail | None: + provider = self._services.engine_provider + return EngineReadModel(provider()).hook_run_detail(run_id) if provider is not None else None + + def _configured_hook_count(self) -> int: + details = self._state.runtime.details + return sum(hook.enabled for source in details.hook_sources for hook in source.hooks) + + @on(HookRunList.Opened) + def _open_hook_run(self, event: HookRunList.Opened) -> None: + event.stop() + detail = self._hook_run_detail(event.run_id) + if detail is not None: + from chrys.app.tui.screens.dialogs.hook_run import HookRunModal - self._config_actions.open_runtime_details(initial_tab=RUNTIME_DETAILS_HOOKS_TAB) + self.app.push_screen(HookRunModal(detail, latest=self._hook_run_detail)) def _open_settings(self, initial_tab: str = GENERAL_TAB_ID) -> None: """Open the Settings dialog at *initial_tab*.""" diff --git a/src/chrys/app/tui/screens/main/subscriptions.py b/src/chrys/app/tui/screens/main/subscriptions.py index ca7f5ada2..8a0850561 100644 --- a/src/chrys/app/tui/screens/main/subscriptions.py +++ b/src/chrys/app/tui/screens/main/subscriptions.py @@ -28,6 +28,7 @@ Event, ExecutionChanged, HookExecutionsChanged, + HookRunsChanged, ImageAttachmentCompressionFinished, ImageAttachmentCompressionStarted, InvocationAborted, @@ -141,6 +142,7 @@ def __init__( _sub(TodoListUpdated, events.on_todo_list_updated), _sub(BackgroundTasksChanged, events.on_background_tasks_changed), _sub(HookExecutionsChanged, events.on_hook_executions_changed), + _sub(HookRunsChanged, events.on_hook_runs_changed), _sub(ToolCompacted, events.on_tool_compacted), _sub(CompactionStarted, events.on_compaction_started), _sub(CompactionFinished, events.on_compaction_finished), diff --git a/src/chrys/app/tui/screens/main/view_adapter.py b/src/chrys/app/tui/screens/main/view_adapter.py index 655e2dfa7..079724447 100644 --- a/src/chrys/app/tui/screens/main/view_adapter.py +++ b/src/chrys/app/tui/screens/main/view_adapter.py @@ -437,6 +437,9 @@ def clear_todos(self) -> None: def set_hook_executions(self, executions: tuple[HookExecutionView, ...]) -> None: self._screen.query_one(HookIndicator).set_executions(executions) + def hook_runs_changed(self) -> None: + self._screen.query_one(SidebarPanel).hooks_panel.runs_changed() + def set_background_tasks(self, tasks: tuple[BackgroundTaskView, ...]) -> None: self._screen.background_tasks_state = tasks running = sum(task.running for task in tasks) diff --git a/src/chrys/app/tui/widgets/chrome/hook_indicator.py b/src/chrys/app/tui/widgets/chrome/hook_indicator.py index 45a59493d..53dc92a36 100644 --- a/src/chrys/app/tui/widgets/chrome/hook_indicator.py +++ b/src/chrys/app/tui/widgets/chrome/hook_indicator.py @@ -82,7 +82,12 @@ class HookIndicator(NonSelectableStatic): """ class Clicked(Message): - """The indicator was clicked: show the session's hooks.""" + """The indicator was clicked: show the run it shows, or the hooks when it shows none.""" + + def __init__(self, run_id: str) -> None: + super().__init__() + self.run_id = run_id + """The run shown when clicked; empty when idle.""" def __init__(self, *, locale_controller: LocaleController | None = None, **kwargs: Any) -> None: super().__init__("", markup=False, **kwargs) @@ -91,6 +96,8 @@ def __init__(self, *, locale_controller: LocaleController | None = None, **kwarg self._timer: Timer | None = None self._frame = 0 self._shown: Text | None = None + self._activity: HookExecutionView | None = None + """The run the label shows; None when it reads ``Hooks``.""" # The mouse's screen column while it is over the box, for the hover. self._mouse_x: int | None = None self._hot = False @@ -163,13 +170,19 @@ def on_mouse_move(self, event: MouseMove) -> None: self._paint() def on_leave(self, _event: Leave) -> None: + # A Leave sent as an overlay covered the label can arrive after the + # screen resumed and read the hover again: a mouse still resting on + # the label keeps it. + if self.screen.is_current and self.region.contains_point(self.app.mouse_position): + return self._mouse_x = None self._paint() def on_click(self, event: Click) -> None: event.prevent_default() event.stop() - self.post_message(self.Clicked()) + # The run the user saw, not one picked again at the click. + self.post_message(self.Clicked(self._activity.run_id if self._activity is not None else "")) def _sync_timer(self) -> None: if self._timer is None: @@ -206,6 +219,7 @@ def _room(self) -> int: def _paint(self) -> None: activity = indicator_activity(self._executions, now=time.monotonic()) + self._activity = activity if activity is None: component = "hook-indicator--idle" label = f" {_REST_GLYPH} {self._render_message(HOOK_INDICATOR_IDLE.bind())} " diff --git a/src/chrys/app/tui/widgets/sidebar/hooks.py b/src/chrys/app/tui/widgets/sidebar/hooks.py new file mode 100644 index 000000000..1a02cb66f --- /dev/null +++ b/src/chrys/app/tui/widgets/sidebar/hooks.py @@ -0,0 +1,512 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""HookRunList — the sidebar's Hooks tab: every hook run since iCode started, across sessions.""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, ClassVar + +from rich.style import Style +from rich.table import Table +from rich.text import Text +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.geometry import Region +from textual.message import Message +from textual.widgets import Static + +from chrys.app.tui.i18n import render_str +from chrys.app.tui.util.spinner import SPINNER_FRAMES +from chrys.app.tui.util.visibility import is_widget_shown_on_active_screen, set_widget_visibility_without_layout +from chrys.app.tui.widgets.chat.tool_call import fmt_duration +from chrys.app.tui.widgets.selection import NonSelectableStatic +from chrys.app.tui.widgets.sidebar.empty_state import SidebarEmptyStateLabel +from chrys.foundation.branding import APP_DISPLAY_NAME +from chrys.foundation.i18n import MessageRef, msg +from chrys.foundation.i18n.formatting import format_message, sanitize_legacy_scalar +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.service.hooks.run_log import HOOK_RUN_LOG_MAX_ENDED + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.events import Click, Leave, MouseEvent, MouseMove + from textual.timer import Timer + + from chrys.app.tui.i18n import LocaleController + +_TITLE = msg("tui.sidebar.hooks.title", fallback="Hooks") +_TITLE_ACTIVE = msg( + "tui.sidebar.hooks.title_active", + fallback="Hooks ({count} active)", + plural_fallback="Hooks ({count} active)", +) +_CLEAR_ENDED = msg("tui.sidebar.hooks.clear_ended", fallback="Clear ended") +_EMPTY_UNCONFIGURED = msg("tui.sidebar.hooks.empty_unconfigured", fallback="No hooks configured") +_EMPTY = msg("tui.sidebar.hooks.empty", fallback="No hook has run since {app} started") +_ONLY_LATEST_KEPT = msg("tui.sidebar.hooks.only_latest_kept", fallback="Only the latest {limit} runs are kept") +_CLEARED = msg("tui.sidebar.hooks.cleared", fallback="Ended runs cleared") +_RUNNING = msg("tui.sidebar.hooks.outcome.running", fallback="running") +_QUEUED = msg("tui.sidebar.hooks.outcome.queued", fallback="queued") +_BLOCKED = msg("tui.sidebar.hooks.outcome.blocked", fallback="blocked") +_MODIFIED_ARGS = msg("tui.sidebar.hooks.outcome.modified_args", fallback="modified args") +_ADDED_REMINDER = msg("tui.sidebar.hooks.outcome.added_reminder", fallback="added reminder") +_ADDED_CONTEXT = msg("tui.sidebar.hooks.outcome.added_context", fallback="added context") +_EXIT = msg("tui.sidebar.hooks.outcome.exit", fallback="exit {code}") +_TIMED_OUT = msg("tui.sidebar.hooks.outcome.timed_out", fallback="timed out") +_COULDNT_START = msg("tui.sidebar.hooks.outcome.couldnt_start", fallback="couldn't start") +_CANCELLED = msg("tui.sidebar.hooks.outcome.cancelled", fallback="cancelled") +_DETACHED = msg("tui.sidebar.hooks.outcome.detached", fallback="detached") + +_EFFECTS = { + "blocked": _BLOCKED, + "modified_args": _MODIFIED_ARGS, + "added_reminder": _ADDED_REMINDER, + "added_context": _ADDED_CONTEXT, +} + +# The spinner's full dot grid: a running row's glyph when animations are off. +REST_GLYPH = "⠿" +_ICONS = { + "queued": "◌", + "success": "✓", + "blocked": "⊘", + "failure": "✗", + "cancelled": "■", + "detached": "→", +} +_ICON_STYLES = { + "queued": "dim", + "success": "green", + "blocked": "yellow", + "failure": "red", + "cancelled": "dim", + "detached": "dim", +} + + +def run_state(record: HookRunRecord) -> str: + """One of "running", "queued", "success", "blocked", "failure", "cancelled" or "detached" (handed to its own process).""" + if record.state != "ended": + return record.state + outcome = record.outcome + if outcome == "cancelled": + return "cancelled" + if outcome == "detached": + return "detached" + if outcome == "success": + return "blocked" if record.effect == "blocked" else "success" + return "failure" + + +def run_icon(record: HookRunRecord, *, running_style: Style, glyph: str = REST_GLYPH) -> Text: + """The state glyph of *record*, colored; a running run shows *glyph* (a spinner frame).""" + state = run_state(record) + if state == "running": + return Text(glyph, style=running_style) + return Text(_ICONS[state], style=_ICON_STYLES[state]) + + +def run_outcome(record: HookRunRecord) -> tuple[MessageRef, ...]: + """How the run ended, then what iCode took from it; empty for a success that changed nothing. + + A failure that ``on_error: block`` turned into a block says both. + """ + if record.state == "running": + return (_RUNNING.bind(),) + if record.state == "queued": + return (_QUEUED.bind(),) + outcome = record.outcome + if outcome == "success": + effect = _EFFECTS.get(record.effect or "") + return (effect.bind(),) if effect is not None else () + if outcome == "cancelled": + return (_CANCELLED.bind(),) + if outcome == "detached": + return (_DETACHED.bind(),) + if outcome == "failed": + failure = _EXIT.bind(code=str(record.exit_code)) + elif outcome == "timed_out": + failure = _TIMED_OUT.bind() + else: + failure = _COULDNT_START.bind() + return (failure, _BLOCKED.bind()) if record.effect == "blocked" else (failure,) + + +def run_duration(record: HookRunRecord, now: float) -> str: + """How long it queued (still queued) or ran; empty when it never took a slot or was detached.""" + if record.state == "queued": + return fmt_duration(max(0, int((now - record.registered_at) * 1000))) + # Handed off, it runs on its own: iCode never sees how long. + if record.started_at is None or record.outcome == "detached": + return "" + end = record.ended_at if record.ended_at is not None else now + return fmt_duration(max(0, int((end - record.started_at) * 1000))) + + +def run_label(record: HookRunRecord) -> str: + """``{hook_id} · {tool or event}``, and the sub-agent that fired it; safe to show.""" + parts = [record.hook_id, record.tool_name or record.hook_event] + if record.sub_agent: + parts.append(record.sub_agent) + return sanitize_legacy_scalar(" · ".join(" ".join(part.split()) for part in parts)) + + +class HookRunList(Vertical, can_focus=False): + """The hook runs, oldest first and numbered, one row each; a click on a row opens its details. + + The runs are read from the engine's run log through *reader* only while + the list is shown: a change while it is hidden only marks it stale. Durations + and the spinner tick once a second, only while a run is in flight and the + list is composited. Same-sized repaints skip layout; a row added or removed + costs one. + """ + + class Opened(Message): + """The user clicked the row of a hook run.""" + + def __init__(self, run_id: str) -> None: + super().__init__() + self.run_id = run_id + + COMPONENT_CLASSES: ClassVar[set[str]] = {"hook-runs--running", "hook-runs--hover", "hook-runs--selected"} + + DEFAULT_CSS = """ + HookRunList { + height: 100%; + width: 100%; + padding: 0 1; + } + HookRunList > .hook-runs--running { + color: $warning; + } + HookRunList > .hook-runs--hover { + background: $primary 30%; + } + HookRunList > .hook-runs--selected { + background: $primary 15%; + } + HookRunList > Horizontal { + height: 1; + } + HookRunList #hook-runs-title { + width: 1fr; + text-style: bold; + color: $text-muted; + } + HookRunList #hook-runs-clear { + width: auto; + color: $text-muted; + text-style: underline; + pointer: pointer; + } + /* No box, as the Messages tab: the rows take the whole width. */ + HookRunList > #hook-runs-box { + height: 1fr; + scrollbar-size: 1 1; + overflow-x: hidden; + display: none; + } + HookRunList #hook-runs-unlisted { + color: $text-muted; + display: none; + } + HookRunList #hook-runs-rows { + width: 100%; + height: auto; + pointer: pointer; + } + HookRunList > #hook-runs-empty { + height: 1fr; + color: $text-muted; + content-align: center middle; + } + """ + + def __init__(self, *, locale_controller: LocaleController | None = None) -> None: + super().__init__() + self._locale_controller = locale_controller + self._reader: Callable[[], tuple[HookRunRecord, ...]] | None = None + self._configured: Callable[[], int] | None = None + self._records: tuple[HookRunRecord, ...] = () + """What the rows show: the log as last read, without the cleared runs.""" + self._cleared: set[str] = set() + """Ended runs the user cleared from the list: nothing shows them again.""" + self._dropped: Callable[[], int] | None = None + self._kept_all = True + """No ended run was dropped past the log's cap.""" + self._logged = 0 + """Runs the log keeps, the cleared ones included.""" + self._stale = True + self._read_pending = False + self._composited = False + self._timer: Timer | None = None + self._frame = 0 + self._painted_rows = -1 + self._hovered: int | None = None + """The row under the mouse.""" + self._selected = "" + """The run whose details were opened last: its row stays marked.""" + self._reveal = False + """Scroll the selected run's row into view at the next paint that lists it.""" + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Static(id="hook-runs-title") + clear = Static(id="hook-runs-clear") + # Hidden, not undisplayed: it comes and goes as runs end, and must not lay out the screen. + clear.styles.visibility = "hidden" + yield clear + # Not focusable: a click on a row must leave the keyboard where it was. + with VerticalScroll(id="hook-runs-box", can_focus=False): + # Above the oldest row: the list starts later than #1, or has gaps. + yield NonSelectableStatic(id="hook-runs-unlisted") + yield Static(id="hook-runs-rows") + yield SidebarEmptyStateLabel(id="hook-runs-empty") + + def on_mount(self) -> None: + self._timer = self.set_interval(1, self._tick, pause=True) + # Scrolled, another row is under the mouse: the next move finds it. + self.watch(self.query_one("#hook-runs-box", VerticalScroll), "scroll_y", self.unhover, init=False) + self._localize() + self._paint() + + def on_show(self) -> None: + self._composited = True + if self._stale: + # Shown by the user (the tab, or the indicator opening a run's + # details over it): read now, even behind that dialog. + self._read(covered_too=True) + else: + self._paint() + + def on_hide(self) -> None: + self._composited = False + self._sync_timer() + + def on_resize(self) -> None: + self._hovered = None + # The result column's cap follows the width. + self.query_one("#hook-runs-rows", Static).update(self._rows(self._records), layout=False) + + def bind_source( + self, + reader: Callable[[], tuple[HookRunRecord, ...]], + configured: Callable[[], int], + dropped: Callable[[], int], + ) -> None: + """Read the runs from *reader*, the number of enabled hooks from *configured*, + and how many ended runs the log dropped past its cap from *dropped*.""" + self._reader = reader + self._configured = configured + self._dropped = dropped + self.runs_changed() + + def runs_changed(self) -> None: + """The run log changed: read it once, soon, if the list shows; else when it shows next. + + Covered by an overlay, it waits for the screen's resume (``screen_resumed``). + """ + self._stale = True + if not self.is_mounted or not self._composited or self._read_pending: + return + if not is_widget_shown_on_active_screen(self): + return + # Coalesced: every change in one pump of messages costs one read and one paint. + self._read_pending = self.call_later(self._read) + + def _read(self, *, covered_too: bool = False) -> None: + self._read_pending = False + if not self.is_mounted: + return + # Covered since a change queued this read: still stale, read at the screen's resume. + if not covered_too and not is_widget_shown_on_active_screen(self): + return + self._stale = False + records = self._reader() if self._reader is not None else () + self._kept_all = (self._dropped() if self._dropped is not None else 0) == 0 + self._logged = len(records) + self._cleared.intersection_update(record.run_id for record in records) + self._records = tuple(record for record in records if record.run_id not in self._cleared) + self._paint() + + def refresh_localization(self) -> None: + """Retranslate the list.""" + if self.is_mounted: + self._localize() + self._paint() + + def _localize(self) -> None: + # Sized by its text, so it lays out again: only here, never on a tick. + self.query_one("#hook-runs-clear", Static).update(Text(self._text(_CLEAR_ENDED.bind()))) + self.query_one("#hook-runs-unlisted", Static).update( + Text(self._text(_ONLY_LATEST_KEPT.bind(limit=HOOK_RUN_LOG_MAX_ENDED))) + ) + + def screen_resumed(self) -> None: + """Catch up once an overlay over the screen closes. + + The overlay parked the timer, and closing it resumes the screen without + showing this widget again. + """ + if not self.is_mounted or not self._composited: + return + if self._stale: + self._read() + else: + self._paint() + + def _tick(self) -> None: + # An overlay opened since the timer started: nothing sends this widget + # a Hide then, so the first tick stops the timer until the screen resumes. + if not is_widget_shown_on_active_screen(self): + if self._timer is not None: + self._timer.pause() + return + self._frame += 1 + self._paint() + + def _sync_timer(self) -> None: + if self._timer is None: + return + live = any(record.state != "ended" for record in self._records) + if self._composited and live and is_widget_shown_on_active_screen(self): + self._timer.resume() + else: + self._timer.pause() + + def _paint(self) -> None: + records = self._records + active = sum(record.state != "ended" for record in records) + title = _TITLE_ACTIVE.bind(count=active) if active else _TITLE.bind() + self.query_one("#hook-runs-title", Static).update(Text(self._text(title)), layout=False) + set_widget_visibility_without_layout(self.query_one("#hook-runs-clear", Static), active < len(records)) + rows = self.query_one("#hook-runs-rows", Static) + box = self.query_one("#hook-runs-box", VerticalScroll) + grown = len(records) > self._painted_rows + reveal = self._reveal and any(record.run_id == self._selected for record in records) + # Newest at the bottom: a list read to its end follows the runs that join it, as the chat + # does; one scrolled up to an older run stays there. + follow = grown and not reveal and (self._painted_rows <= 0 or box.scroll_y >= box.max_scroll_y) + rows.update(self._rows(records), layout=len(records) != self._painted_rows) + self._painted_rows = len(records) + if follow: + box.scroll_end(animate=False) + # Only runs dropped past the log's cap: clearing is the user's own doing. + unlisted = not self._kept_all + notice = self.query_one("#hook-runs-unlisted", Static) + if unlisted != notice.display: + notice.display = unlisted + empty = self.query_one("#hook-runs-empty", Static) + if not records: + configured = self._configured() if self._configured is not None else 0 + if self._logged: + reference = _CLEARED.bind() + elif configured: + reference = _EMPTY.bind(app=APP_DISPLAY_NAME) + else: + reference = _EMPTY_UNCONFIGURED.bind() + empty.update(Text(self._text(reference))) + if box.display != bool(records): + box.display = bool(records) + empty.display = not records + if reveal: + self._reveal = False + # Once laid out: the rows may have just grown, or the tab just shown. + self.call_after_refresh(self._scroll_to_selected) + self._sync_timer() + + def select(self, run_id: str) -> None: + """Mark the row of *run_id*, whose details are open, and scroll it into view.""" + self._selected = run_id + self._reveal = True + if self.is_mounted and self._composited: + # Read again first: the run may be newer than the rows. Read now + # even under the details dialog this opens with: the user asked + # for this row, and the list shows dimmed behind the dialog. + self._read(covered_too=True) + + def _scroll_to_selected(self) -> None: + if not self.is_mounted: + return + row = next((row for row, record in enumerate(self._records) if record.run_id == self._selected), None) + if row is None: + return + box = self.query_one("#hook-runs-box", VerticalScroll) + top = 1 if self.query_one("#hook-runs-unlisted", Static).display else 0 + box.scroll_to_region(Region(0, top + row, box.size.width, 1), center=True, animate=False, immediate=True) + + def _rows(self, records: tuple[HookRunRecord, ...]) -> Table: + resolved = self.get_component_rich_style("hook-runs--running", default=Style(color="yellow")) + running_style = Style(color=resolved.color) + hover = Style(bgcolor=self.get_component_rich_style("hook-runs--hover").bgcolor) + selected = Style(bgcolor=self.get_component_rich_style("hook-runs--selected").bgcolor) + now = time.monotonic() + still = self.app.animation_level == "none" + grid = Table.grid(padding=(0, 1), expand=True) + # Numbered as the Messages tab numbers turns: the run's place since iCode started. + grid.add_column(justify="right", no_wrap=True, style="dim") + grid.add_column(width=1, no_wrap=True) + grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis") + grid.add_column(justify="right", no_wrap=True) + # The name matters more: a long result gives way to it past a third of the row. + width = self.content_size.width + grid.add_column( + justify="right", no_wrap=True, overflow="ellipsis", max_width=max(8, width // 3) if width else None + ) + for row, record in enumerate(records): + glyph = REST_GLYPH if still else SPINNER_FRAMES[self._frame % len(SPINNER_FRAMES)] + icon = run_icon(record, running_style=running_style, glyph=glyph) + detail = running_style if record.state == "running" else "dim" + outcome = " · ".join(self._text(part) for part in run_outcome(record)) + grid.add_row( + str(record.number), + icon, + Text(run_label(record), no_wrap=True, overflow="ellipsis"), + Text(run_duration(record, now), style=detail), + Text(outcome, style=detail, no_wrap=True, overflow="ellipsis"), + style=hover if row == self._hovered else selected if record.run_id == self._selected else None, + ) + return grid + + def _text(self, reference: MessageRef) -> str: + controller = self._locale_controller + return format_message(reference) if controller is None else render_str(controller.localizer, reference) + + def on_click(self, event: Click) -> None: + clear = self.query_one("#hook-runs-clear", Static) + if clear.visible and event.get_content_offset(clear) is not None: + event.stop() + self._cleared.update(record.run_id for record in self._records if record.state == "ended") + self._records = tuple(record for record in self._records if record.run_id not in self._cleared) + self._paint() + return + row = self._row_at(event) + if row is not None: + event.stop() + run_id = self._records[row].run_id + self._selected = run_id + self._paint() + self.post_message(self.Opened(run_id)) + + def on_mouse_move(self, event: MouseMove) -> None: + self._hover(self._row_at(event)) + + def _row_at(self, event: MouseEvent) -> int | None: + rows = self.query_one("#hook-runs-rows", Static) + # Scrolled, the rows reach up under the title: only the mouse on them counts. + offset = event.get_content_offset(rows) if event.widget is rows else None + return offset.y if offset is not None and 0 <= offset.y < len(self._records) else None + + def on_leave(self, event: Leave) -> None: + if event.node is self.query_one("#hook-runs-rows", Static): + self._hover(None) + + def unhover(self) -> None: + """Drop the highlight, as the rows moved under the mouse: its next move finds the row there.""" + self._hover(None) + + def _hover(self, row: int | None) -> None: + if row != self._hovered: + self._hovered = row + self.query_one("#hook-runs-rows", Static).update(self._rows(self._records), layout=False) diff --git a/src/chrys/app/tui/widgets/sidebar/panel.py b/src/chrys/app/tui/widgets/sidebar/panel.py index 751298ab2..296a50e30 100644 --- a/src/chrys/app/tui/widgets/sidebar/panel.py +++ b/src/chrys/app/tui/widgets/sidebar/panel.py @@ -15,6 +15,7 @@ from chrys.app.tui.widgets.sidebar.buddy import BuddyPanel from chrys.app.tui.widgets.sidebar.context import ContextPanel, ContextUsageState from chrys.app.tui.widgets.sidebar.debug import DebugPanel +from chrys.app.tui.widgets.sidebar.hooks import HookRunList from chrys.app.tui.widgets.sidebar.tasks import TasksPanel, TodoListState from chrys.app.tui.widgets.sidebar.toc import ConversationToc from chrys.foundation.i18n import MessageDef, MessageRef, msg @@ -22,6 +23,7 @@ _SIDEBAR_MESSAGES = msg("tui.sidebar.tab.messages", fallback="Messages") _SIDEBAR_TASKS = msg("tui.sidebar.tab.tasks", fallback="Tasks") +_SIDEBAR_HOOKS = msg("tui.sidebar.tab.hooks", fallback="Hooks") _SIDEBAR_CONTEXT = msg("tui.sidebar.tab.context", fallback="Context") _SIDEBAR_DEBUG = msg("tui.sidebar.tab.debug", fallback="Debug") _SIDEBAR_BUDDY = msg("tui.sidebar.tab.buddy", fallback="Buddy") @@ -29,13 +31,19 @@ _SIDEBAR_MESSAGES, _SIDEBAR_TASKS, _SIDEBAR_CONTEXT, + _SIDEBAR_HOOKS, _SIDEBAR_DEBUG, _SIDEBAR_BUDDY, ) -_WORKFLOW_HIDDEN_TABS = ("tab-toc", "tab-tasks", "tab-context") +SIDEBAR_HOOKS_TAB = "tab-hooks" +# Workflow runs build their own hook managers, which the run log does not record. +_WORKFLOW_HIDDEN_TABS = ("tab-toc", "tab-tasks", SIDEBAR_HOOKS_TAB, "tab-context") +_TAB_STRIP_SCROLL_CELLS = 4 +"""How far one wheel step moves the tab strip.""" if TYPE_CHECKING: from textual.app import ComposeResult + from textual.events import MouseScrollDown, MouseScrollLeft, MouseScrollRight, MouseScrollUp from chrys.app.tui.i18n import LocaleController from chrys.foundation.models.background_tasks import BackgroundTaskView @@ -90,6 +98,8 @@ def compose(self) -> ComposeResult: yield ContextPanel(locale_controller=self._locale_controller).data_bind( usage_state=SidebarPanel.context_usage_state ) + with TabPane(self._localized_content(_SIDEBAR_HOOKS.bind()), id=SIDEBAR_HOOKS_TAB): + yield HookRunList(locale_controller=self._locale_controller) with TabPane(self._localized_content(_SIDEBAR_DEBUG.bind()), id="tab-debug"): yield DebugPanel(locale_controller=self._locale_controller) with TabPane(self._localized_content(_SIDEBAR_BUDDY.bind()), id="tab-buddy"): @@ -125,6 +135,7 @@ def refresh_localization(self) -> None: tab.label = self._localized_content(definition.bind()) self.context_panel.refresh_localization() self.tasks_panel.refresh_localization() + self.hooks_panel.refresh_localization() self.toc_panel.refresh_localization() self.buddy_panel.refresh_localization() self.debug_panel.refresh_localization() @@ -135,6 +146,32 @@ def _localized_content(self, reference: MessageRef) -> Content: return Content.from_text(format_message(reference), markup=False) return render_content(controller.localizer, reference) + # The tab strip is wider than a narrow sidebar: the wheel over it (up and + # down too, as most mice have no sideways wheel) slides it left and right. + # Its own scroll box hides overflow and ignores the wheel, so these bubble here. + def on_mouse_scroll_down(self, event: MouseScrollDown) -> None: + self._slide_tab_strip(event, _TAB_STRIP_SCROLL_CELLS) + + def on_mouse_scroll_up(self, event: MouseScrollUp) -> None: + self._slide_tab_strip(event, -_TAB_STRIP_SCROLL_CELLS) + + def on_mouse_scroll_right(self, event: MouseScrollRight) -> None: + self._slide_tab_strip(event, _TAB_STRIP_SCROLL_CELLS) + + def on_mouse_scroll_left(self, event: MouseScrollLeft) -> None: + self._slide_tab_strip(event, -_TAB_STRIP_SCROLL_CELLS) + + def _slide_tab_strip( + self, event: MouseScrollDown | MouseScrollUp | MouseScrollLeft | MouseScrollRight, cells: int + ) -> None: + tabs = self.query_one(TabbedContent).query_one(Tabs) + # Where the mouse is, not ``event.widget``: the terminal's wheel events name no widget. + if not tabs.region.contains_point(event.screen_offset): + return + event.stop() + # Forced: the strip hides its overflow, so it takes no scroll of its own. + tabs.query_one("#tabs-scroll").scroll_relative(x=cells, animate=False, force=True) + def toggle(self) -> None: if not self._visibility_suppressors: self.display = not self.display @@ -186,6 +223,10 @@ def context_panel(self) -> ContextPanel: def tasks_panel(self) -> TasksPanel: return self.query_one(TasksPanel) + @property + def hooks_panel(self) -> HookRunList: + return self.query_one(HookRunList) + @property def toc_panel(self) -> ConversationToc: return self.query_one(ConversationToc) diff --git a/src/chrys/foundation/events/types.py b/src/chrys/foundation/events/types.py index 076e26829..cdc24a90d 100644 --- a/src/chrys/foundation/events/types.py +++ b/src/chrys/foundation/events/types.py @@ -987,6 +987,11 @@ class HookExecutionsChanged(Event): executions: tuple[HookExecutionView, ...] = () +@dataclass +class HookRunsChanged(Event): + """The engine's hook run log changed; reread it (no data; every session's runs; for the TUI only).""" + + @dataclass class UserInjectResult(Event): """Reports whether a mid-run user message was delivered to the model. diff --git a/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo b/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo index 91bb6b0358816b4aa3f573cf2e3f417778522eb6..cd1c48aeead48aca5501d981654417fd64f8c673 100644 GIT binary patch delta 41878 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 diff --git a/src/chrys/foundation/models/hook_executions.py b/src/chrys/foundation/models/hook_executions.py index 5a7ebbd83..53311f185 100644 --- a/src/chrys/foundation/models/hook_executions.py +++ b/src/chrys/foundation/models/hook_executions.py @@ -23,3 +23,5 @@ class HookExecutionView: """Still waiting for a ``max_parallel_hooks`` slot.""" started_at: float """When the run was registered, in this process's ``time.monotonic()``.""" + run_id: str = "" + """The run's id in the hook run log, where its details are kept.""" diff --git a/src/chrys/foundation/models/hook_runs.py b/src/chrys/foundation/models/hook_runs.py new file mode 100644 index 000000000..f22fb7300 --- /dev/null +++ b/src/chrys/foundation/models/hook_runs.py @@ -0,0 +1,114 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""What the TUI's Hooks panel shows of the hook runs since iCode started.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +HookRunState = Literal["queued", "running", "ended"] + +HookRunEffect = Literal["blocked", "modified_args", "added_reminder", "added_context"] +"""What iCode took from a run; a run has at most one (the event groups that take each are disjoint).""" + +HookNotApplied = Literal["nonblocking", "unsupported", "failed", "failed_blocked"] +"""Why a run's decision was not taken: an ``async``/``fire_and_forget`` run's never is, the event +does not take that action, or the run failed (its result file ignored; with ``on_error: block`` on a +gated event the action was blocked instead).""" + +HookResultFileStatus = Literal["ok", "missing", "invalid"] +"""``missing`` covers an absent or empty result file; ``invalid`` one too large, not JSON or not an object.""" + + +@dataclass(frozen=True, slots=True) +class HookRunRecord: + """One hook run, as it is at one moment.""" + + run_id: str + """Also the run's ``hook.operation.*`` ``operation_id`` when the trajectory records it.""" + session_id: str + hook_id: str + hook_event: str + execution_mode: str + detach: bool + tool_name: str + """The tool of a tool event; empty for other events.""" + sub_agent: str + """The sub-agent that fired it; empty for the main agent.""" + retry: bool + """An outbox retry of an earlier run.""" + registered_at: float + """``time.monotonic()`` when registered, queued or not.""" + started_at: float | None + """``time.monotonic()`` when it took a ``max_parallel_hooks`` slot; None when it never did.""" + ended_at: float | None + registered_wall: float + """``time.time()`` when registered.""" + state: HookRunState + outcome: str | None + """A ``hook.operation.finished`` outcome (``HookOutcome``) once ended.""" + exit_code: int | None + effect: HookRunEffect | None + number: int = 0 + """The run's place among every run since the process started, from 1; kept as older runs are dropped.""" + + +@dataclass(frozen=True, slots=True) +class HookRunIO: + """What a run read and wrote: kept for the runs the panel lists, within a memory cap.""" + + payload: str + """The payload file's text as the hook read it, cut at ``payload_size`` when longer.""" + payload_size: int + """The payload's full length in UTF-8 bytes.""" + result_file: HookResultFileStatus | None + """None until the run ends, and for runs whose result is never read (detached).""" + decision: str + """The result file's JSON object, as read.""" + stdout: str + stderr: str + stdout_dropped: int + stderr_dropped: int + """Bytes cut from the middle of each stream.""" + + +@dataclass(frozen=True, slots=True) +class HookRunApplied: + """What iCode took from a run, or why it took nothing.""" + + block_reason: str = "" + args: str = "" + """The tool arguments after the run's override, as JSON; empty when unchanged.""" + reminder: str = "" + extra_context: str = "" + not_applied: HookNotApplied | None = None + + +@dataclass(frozen=True, slots=True) +class HookRunDetail: + """A run's record with what its details dialog shows.""" + + record: HookRunRecord + tool_call_id: str + timeout_seconds: float + on_error: str + delivery: str + source: str + """``project`` or ``global``; empty when unknown.""" + source_path: str + run_type: str + """``script``, ``command`` or ``shell``.""" + run_argv: tuple[str, ...] + """The script path and its args, the command's argv, or the shell command line.""" + run_cwd: str + run_env_names: tuple[str, ...] + """The names ``run.env`` sets; never their values, which may be expanded secrets.""" + log_path: str + """A detached run's log file.""" + shared_log: bool + """Another run of the hook writes to the same log (they started in the same second).""" + io: HookRunIO | None + """None once the details are no longer kept.""" + applied: HookRunApplied | None + """None until the run ends.""" diff --git a/src/chrys/orchestration/engine/assembly.py b/src/chrys/orchestration/engine/assembly.py index 719d8887e..b9bde640b 100644 --- a/src/chrys/orchestration/engine/assembly.py +++ b/src/chrys/orchestration/engine/assembly.py @@ -31,6 +31,7 @@ from chrys.orchestration.workflows.coordinator import WorkflowCoordinator from chrys.service.approval.policy import ApprovalMode from chrys.service.approval.turn_context import TurnContextHolder +from chrys.service.hooks.run_log import HookRunLog from chrys.service.mcp.cache import MCPConnectionCache from chrys.service.mutations.workspace_changes import WorkspaceChangeTracker from chrys.service.session.history import SessionHistoryManager @@ -67,11 +68,14 @@ def assemble_agent_engine( on_turn_started: Callable[[], None] | None = None, allow_user_interaction: bool = True, surface: SessionSurface | None = None, + record_hook_runs: bool = False, ) -> AgentEngine: """Build the owners and inject the engine's event-routing facade. *surface* is the frontend this engine serves; turns and workflow runs record it on their session. Production entry points always pass it. + *record_hook_runs* keeps every main-session hook run since the engine was + built, with its input and output, for the TUI's Hooks panel. """ bus = event_bus if loaded_settings is not None and settings is not None and settings is not loaded_settings.settings: @@ -115,6 +119,7 @@ def unregister_current_engine() -> None: _unset_current_engine(engine) mcp_cache = MCPConnectionCache() + hook_runs = HookRunLog() if record_hook_runs else None loader = AgentLoader( bus=bus, persistence=persistence, @@ -138,6 +143,7 @@ def unregister_current_engine() -> None: allow_user_interaction=allow_user_interaction, build_agent_fn=build_agent, register_current_engine=register_current_engine, + hook_runs=hook_runs, ) lifecycle = SessionLifecycle( @@ -232,5 +238,6 @@ def unregister_current_engine() -> None: history=history, trajectory_recorder=recorder, workflows=workflows, + hook_runs=hook_runs, ) return engine diff --git a/src/chrys/orchestration/engine/engine.py b/src/chrys/orchestration/engine/engine.py index efbdb5e4e..8a92cfbdf 100644 --- a/src/chrys/orchestration/engine/engine.py +++ b/src/chrys/orchestration/engine/engine.py @@ -82,6 +82,7 @@ if TYPE_CHECKING: from chrys.foundation.events.bus import EventBus from chrys.foundation.models.execution import ExecutionSnapshot + from chrys.service.hooks.run_log import HookRunLog from chrys.service.profiles.agents.schema import AgentProfile from chrys.service.profiles.models.registry import ModelProfileRegistry from chrys.service.profiles.models.schema import ModelProfile @@ -162,6 +163,7 @@ def __init__( history: SessionHistoryManager, trajectory_recorder: trajectory_recorder.TrajectoryRecorder, workflows: WorkflowCoordinator, + hook_runs: HookRunLog | None = None, ) -> None: self._bus = bus self._session = session @@ -180,6 +182,7 @@ def __init__( self._history = history self._trajectory_recorder = trajectory_recorder self._workflows = workflows + self._hook_runs = hook_runs self._subscribed = False self._release_task: asyncio.Task[None] | None = None @@ -188,6 +191,11 @@ def turns(self) -> TurnCoordinator: """Turn orchestration and runtime state.""" return self._turns + @property + def hook_runs(self) -> HookRunLog | None: + """Every main-session hook run since the engine was built; None unless it records them.""" + return self._hook_runs + @property def workflows(self) -> WorkflowCoordinator: """Workflow run admission and routing.""" diff --git a/src/chrys/orchestration/engine/hook_reporting.py b/src/chrys/orchestration/engine/hook_reporting.py index 2d795bae8..24bf611e1 100644 --- a/src/chrys/orchestration/engine/hook_reporting.py +++ b/src/chrys/orchestration/engine/hook_reporting.py @@ -1,22 +1,36 @@ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. -"""Publish the hook runs a session's hook manager has in flight.""" +"""Publish the hook runs a session's hook manager has in flight, and record its runs.""" from __future__ import annotations from typing import TYPE_CHECKING -from chrys.foundation.events.types import HookExecutionsChanged +from chrys.foundation.events.types import HookExecutionsChanged, HookRunsChanged if TYPE_CHECKING: from chrys.foundation.events.bus import EventBus from chrys.service.hooks.manager import HookManager + from chrys.service.hooks.run_log import HookRunLog -def wire_hook_reporting(manager: HookManager, bus: EventBus, *, session_id: str) -> None: - """Publish *manager*'s runs in flight on *bus* for *session_id*, after each change.""" +def wire_hook_reporting( + manager: HookManager, bus: EventBus, *, session_id: str, run_log: HookRunLog | None = None +) -> None: + """Publish *manager*'s runs in flight on *bus* for *session_id*, after each change. + + With *run_log*, every run is recorded there too, and ``HookRunsChanged`` + published after each write. + """ async def _publish_executions(source: HookManager) -> None: await bus.publish(HookExecutionsChanged(session_id=session_id, executions=source.executions())) manager.on_change = _publish_executions + if run_log is None: + return + + async def _publish_runs() -> None: + await bus.publish(HookRunsChanged(session_id=session_id)) + + manager.record_runs(run_log, session_id=session_id, on_change=_publish_runs) diff --git a/src/chrys/orchestration/engine/loader.py b/src/chrys/orchestration/engine/loader.py index 53a39f882..bdaef9bdc 100644 --- a/src/chrys/orchestration/engine/loader.py +++ b/src/chrys/orchestration/engine/loader.py @@ -59,6 +59,7 @@ from chrys.orchestration.engine.usage import UsagePublisher from chrys.service.approval.turn_context import TurnContextHolder from chrys.service.hooks.manager import HookManager + from chrys.service.hooks.run_log import HookRunLog from chrys.service.profiles.agents.registry import AgentProfileRegistry from chrys.service.profiles.agents.schema import AgentProfile, MCPServerConfig from chrys.service.profiles.models.registry import ModelProfileRegistry @@ -188,8 +189,10 @@ def __init__( allow_user_interaction: bool, build_agent_fn: BuildAgentFn, register_current_engine: Callable[[], None], + hook_runs: HookRunLog | None = None, ) -> None: self._bus = bus + self._hook_runs = hook_runs self._persistence = persistence self._agent_registry = agent_registry self._model_registry = model_registry @@ -310,7 +313,7 @@ async def build_hook_manager( # Every main-session manager reports to the TUI; workflow managers are # built by their own factory and stay unreported. if manager is not None: - wire_hook_reporting(manager, self._bus, session_id=resolved_session_id or "") + wire_hook_reporting(manager, self._bus, session_id=resolved_session_id or "", run_log=self._hook_runs) return manager def start_outbox_recovery(self) -> None: diff --git a/src/chrys/orchestration/sub_agents/tools.py b/src/chrys/orchestration/sub_agents/tools.py index 3b76cf876..1ca8648a1 100644 --- a/src/chrys/orchestration/sub_agents/tools.py +++ b/src/chrys/orchestration/sub_agents/tools.py @@ -503,8 +503,9 @@ async def _acquire(conversation: Conversation) -> None: # tools (compress_context, recall_context, list_compressed_contexts) # are disabled — sub-agents are single-turn and don't need cross-turn # context management. + # The registered template runs no invocation of its own, so no origin to name. async def _on_pre_compact(info: PreCompactInfo) -> None: - await self._fire_pre_compact_hook(tool_name, info) + await self._fire_pre_compact_hook(tool_name, info, origin=None) def _publish_context_pressure( reason: str, @@ -690,7 +691,7 @@ def _make_invocation_runtime( ) async def _on_pre_compact(info: PreCompactInfo, *, _tool_name: str = tool_name) -> None: - await self._fire_pre_compact_hook(_tool_name, info) + await self._fire_pre_compact_hook(_tool_name, info, origin=emitter.origin) def _publish_invocation_context_pressure( reason: str, @@ -2012,7 +2013,9 @@ async def _fire_sub_agent_hook( payload["result_summary"] = result_summary await self._hook_manager.fire(event, payload, target_operation_id=target_operation_id) - async def _fire_pre_compact_hook(self, tool_name: str, info: PreCompactInfo) -> None: + async def _fire_pre_compact_hook( + self, tool_name: str, info: PreCompactInfo, *, origin: InvocationOrigin | None + ) -> None: if self._hook_manager is None: return if not self._hook_manager.has_hooks_for(HookEvent.PRE_COMPACT): @@ -2032,6 +2035,7 @@ async def _fire_pre_compact_hook(self, tool_name: str, info: PreCompactInfo) -> }, }, target_operation_id=info.trajectory_operation_id, + origin=origin, ) async def _publish_sub_agent_compaction_status( diff --git a/src/chrys/service/agent_middleware/control/approval.py b/src/chrys/service/agent_middleware/control/approval.py index ae94983d5..efc78af28 100644 --- a/src/chrys/service/agent_middleware/control/approval.py +++ b/src/chrys/service/agent_middleware/control/approval.py @@ -604,6 +604,7 @@ def _record_judge_audit(verdict: JudgeVerdict) -> None: call_id=call_id, args=parsed_args, target_operation_id=approval_trace.request_id if approval_trace is not None else None, + origin=publisher.origin if publisher is not None else None, ) finally: # Cancel the judge if the user decided first (or reason is in — the @@ -668,6 +669,7 @@ def _record_judge_audit(verdict: JudgeVerdict) -> None: # edited arguments belongs to the tool it can still block, # not to the exchange the call came in on. target_operation_id=tool_operation_id(context.metadata), + origin=publisher.origin if publisher is not None else None, ) # Hooks may have rewritten args further — capture the final form. final_args = context.arguments if isinstance(context.arguments, dict) else modified_args diff --git a/src/chrys/service/agent_middleware/control/approval_hooks.py b/src/chrys/service/agent_middleware/control/approval_hooks.py index 2b6a97227..376bb31b3 100644 --- a/src/chrys/service/agent_middleware/control/approval_hooks.py +++ b/src/chrys/service/agent_middleware/control/approval_hooks.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from chrys.foundation.events.types import ApprovalResponse + from chrys.foundation.models.invocations import InvocationOrigin from chrys.service.hooks.manager import HookManager @@ -29,8 +30,12 @@ async def await_approval_with_hooks( call_id: str, args: dict[str, Any], target_operation_id: str | None, + origin: InvocationOrigin | None = None, ) -> ApprovalResponse: - """Wait for the existing decision future, notifying only actual human waits.""" + """Wait for the existing decision future, notifying only actual human waits. + + *origin* is the invocation whose tool waits; it names a sub-agent in the hook run log. + """ if manager is None or not ( manager.has_hooks_for(HookEvent.APPROVAL_REQUESTED) or manager.has_hooks_for(HookEvent.APPROVAL_RESOLVED) ): @@ -52,11 +57,12 @@ async def await_approval_with_hooks( "tool": {"name": tool_name, "kind": tool_kind, "call_id": call_id, "args": args}, } # These are observer events. Ignore every decision returned by the manager. - await manager.fire(HookEvent.APPROVAL_REQUESTED, payload, target_operation_id=target_operation_id) + await manager.fire(HookEvent.APPROVAL_REQUESTED, payload, target_operation_id=target_operation_id, origin=origin) result = await future await manager.fire( HookEvent.APPROVAL_RESOLVED, {**payload, "approved": result.approved}, target_operation_id=target_operation_id, + origin=origin, ) return result diff --git a/src/chrys/service/agent_middleware/events/hook_dispatch.py b/src/chrys/service/agent_middleware/events/hook_dispatch.py index 0f1c4e742..fc300d174 100644 --- a/src/chrys/service/agent_middleware/events/hook_dispatch.py +++ b/src/chrys/service/agent_middleware/events/hook_dispatch.py @@ -30,6 +30,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: + from chrys.foundation.models.invocations import InvocationOrigin from chrys.kernel.middleware import FunctionInvocationContext from chrys.service.hooks.manager import HookManager @@ -51,12 +52,14 @@ async def apply_before_tool_hooks( args: dict[str, Any], workspace_cwd: str, target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> bool: """Run ``before_tool_call`` hooks and apply their blocking/modification decision. Returns ``True`` when a hook blocked the tool. The caller should still publish the normal tool start/result events with the current arguments so - the UI and persisted history reflect the denied call. + the UI and persisted history reflect the denied call. *origin* names the + sub-agent that calls the tool; None for the main agent. """ if manager is None or not manager.has_hooks_for(HookEvent.BEFORE_TOOL_CALL): return False @@ -73,6 +76,7 @@ async def apply_before_tool_hooks( workspace_cwd=workspace_cwd, ), target_operation_id=target_operation_id, + origin=origin, ) if decision.blocked: message = decision.block_reason or "Tool execution was blocked by hook." @@ -107,8 +111,12 @@ async def fire_after_tool_hooks( rejection_source: str, workspace_cwd: str, target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> HookDecision: - """Fire ``after_tool_call`` / ``tool_error`` hooks and return their decision.""" + """Fire ``after_tool_call`` / ``tool_error`` hooks and return their decision. + + *origin* names the sub-agent that called the tool; None for the main agent. + """ if manager is None: return HookDecision() result = { @@ -136,11 +144,14 @@ async def fire_after_tool_hooks( if manager.has_hooks_for(HookEvent.AFTER_TOOL_CALL): _merge_decision( decision, - await manager.fire(HookEvent.AFTER_TOOL_CALL, payload, target_operation_id=target_operation_id), + await manager.fire( + HookEvent.AFTER_TOOL_CALL, payload, target_operation_id=target_operation_id, origin=origin + ), ) if errored and manager.has_hooks_for(HookEvent.TOOL_ERROR): _merge_decision( - decision, await manager.fire(HookEvent.TOOL_ERROR, payload, target_operation_id=target_operation_id) + decision, + await manager.fire(HookEvent.TOOL_ERROR, payload, target_operation_id=target_operation_id, origin=origin), ) return decision diff --git a/src/chrys/service/agent_middleware/events/sub_agent_events.py b/src/chrys/service/agent_middleware/events/sub_agent_events.py index ede894812..ac220d65b 100644 --- a/src/chrys/service/agent_middleware/events/sub_agent_events.py +++ b/src/chrys/service/agent_middleware/events/sub_agent_events.py @@ -507,6 +507,7 @@ async def _process( args=args, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None, + origin=origin, ) args = context.arguments if is_mutable_tool_args(context.arguments) else {} @@ -798,6 +799,7 @@ async def _process_tool_call( rejection_source=source, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None, + origin=origin, ) context.result, result_text = append_extra_context_to_result( context.result, diff --git a/src/chrys/service/hooks/manager.py b/src/chrys/service/hooks/manager.py index da21c5242..59b23b1b3 100644 --- a/src/chrys/service/hooks/manager.py +++ b/src/chrys/service/hooks/manager.py @@ -21,16 +21,19 @@ import contextlib import copy import itertools +import json import logging import time from collections.abc import Awaitable, Callable from dataclasses import replace from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from chrys.foundation.models.hook_executions import HookExecutionView, HookWaitPoint +from chrys.foundation.models.hook_runs import HookRunApplied, HookRunEffect from chrys.foundation.trajectory.context import TrajectoryContext +from chrys.foundation.trajectory.ids import new_analytics_id from chrys.service.hooks.events import GATED_EVENTS, HookEvent from chrys.service.hooks.matcher import matches from chrys.service.hooks.outbox import Outbox, OutboxJob @@ -38,6 +41,10 @@ from chrys.service.hooks.schema import HookConfig, HookDecision, HooksFile, MergedHooksFile from chrys.service.trajectory.hooks import HookOperationTrace, HookOutcome, TrajectoryContextProvider +if TYPE_CHECKING: + from chrys.foundation.models.invocations import InvocationOrigin + from chrys.service.hooks.run_log import HookRunLog + logger = logging.getLogger(__name__) _ARGS_OVERRIDE_EVENTS = frozenset({HookEvent.BEFORE_TOOL_CALL}) _SYSTEM_REMINDER_EVENTS = frozenset({HookEvent.USER_PROMPT_SUBMIT}) @@ -55,12 +62,13 @@ class _Tracked: ``scope`` lets :meth:`HookManager.drain_turn` filter to just turn- scoped tasks without touching background or session-scoped ones. ``execution_key`` is the run's snapshot entry, registered before the task - exists, and ``trace`` its span, opened before: for a task cancelled before - its first step, whose own handlers never ran, the drain or the reap - callback drops the one and closes the other. + exists, ``trace`` its span, opened before, and ``run`` its run log entry: + for a task cancelled before its first step, whose own handlers never ran, + the drain or the reap callback drops the entry, closes the span and ends + the run. """ - __slots__ = ("execution_key", "hook", "job", "operation_id", "scope", "task", "trace") + __slots__ = ("execution_key", "hook", "job", "operation_id", "run", "scope", "task", "trace") def __init__( self, @@ -72,6 +80,7 @@ def __init__( operation_id: str | None, execution_key: int | None, trace: HookOperationTrace | None = None, + run: _Run | None = None, ) -> None: self.hook = hook self.task = task @@ -80,6 +89,31 @@ def __init__( self.operation_id = operation_id self.execution_key = execution_key self.trace = trace + self.run = run + + +class _Run: + """One run's identity and what the run log keeps of how it was started.""" + + __slots__ = ("payload_text", "retry", "run_id", "sub_agent", "tool_call_id", "tool_name") + + def __init__( + self, + run_id: str, + *, + payload_text: str | None = None, + tool_name: str = "", + tool_call_id: str = "", + sub_agent: str = "", + retry: bool = False, + ) -> None: + self.run_id = run_id + self.payload_text = payload_text + """The payload file's text, serialized once when the run is registered; None when unrecorded.""" + self.tool_name = tool_name + self.tool_call_id = tool_call_id + self.sub_agent = sub_agent + self.retry = retry # --------------------------------------------------------------------------- @@ -141,6 +175,12 @@ def __init__( # The single observer, installed by the session that owns this manager # (``wire_hook_reporting``); a second assignment replaces the first. self.on_change: Callable[[HookManager], Awaitable[None]] | None = None + # Where every run is recorded with its input and output (the TUI's + # Hooks panel), and who is told after each write; none unless the + # engine records runs (``record_runs``). + self._run_log: HookRunLog | None = None + self._run_log_session_id = "" + self._on_runs_change: Callable[[], Awaitable[None]] | None = None @property def hooks_dir(self) -> Path: @@ -174,6 +214,17 @@ def executions(self) -> tuple[HookExecutionView, ...]: """The runs in flight now (queued or running, waited on or not), oldest first.""" return tuple(self._executions.values()) + def record_runs( + self, log: HookRunLog, *, session_id: str, on_change: Callable[[], Awaitable[None]] | None = None + ) -> None: + """Record every run of this manager in *log* under *session_id*, calling *on_change* after each write. + + A run that ends after the manager closed is recorded too. + """ + self._run_log = log + self._run_log_session_id = session_id + self._on_runs_change = on_change + # ── dispatch ───────────────────────────────────────────────────── async def fire( @@ -184,6 +235,7 @@ async def fire( scope: str = "turn", target_operation_id: str | None = None, trajectory_context: TrajectoryContext | None = None, + origin: InvocationOrigin | None = None, ) -> HookDecision: """Fire every matching hook for *event*. @@ -201,6 +253,8 @@ async def fire( trajectory_context: Explicit recording owner for blocking hooks. Non-blocking turn-scoped hooks deliberately retain the normal provider context so response-fence membership remains resolvable. + origin: The invocation that fired the event, when a sub-agent + did; the run log names that sub-agent. None is the main agent. Returns: Aggregated :class:`HookDecision`. For events with no @@ -235,7 +289,8 @@ async def fire( return decision if not matches(hook, envelope): continue - trace = self._open_trace(target_operation_id, context=trajectory_context) + run = self._new_run(envelope, origin=origin) + trace = self._open_trace(target_operation_id, context=trajectory_context, operation_id=run.run_id) try: # Inside the block that closes the span: the start marker # awaits its write ack, and an interrupt landing there would @@ -259,30 +314,54 @@ async def fire( envelope, # Interrupt observers run on a task nobody waits on. waited_at="none" if scope == "detached" else "call", + run=run, ) except asyncio.CancelledError: if trace is not None: trace.finished_soon(outcome=HookOutcome.CANCELLED) + if self._end_run(run, outcome=HookOutcome.CANCELLED): + await self._notify_change(executions=False) raise except Exception as exc: logger.exception("Blocking hook %r failed before producing a result", hook.id) result = HookResult(hook_id=hook.id, exit_code=None, stderr=str(exc)) blocked_before = decision.blocked override_before = decision.args_override + reminders_before = len(decision.system_reminders) + contexts_before = len(decision.extra_context) _merge_blocking_result(decision, hook, result, event=event) - if trace is not None: - await trace.finished( - outcome=_hook_outcome(result, blocked=decision.blocked and not blocked_before), - arguments_modified=decision.args_override != override_before, - exit_code=result.exit_code, - timed_out=result.timed_out, - ) + # Compared by value: a hook returning the arguments as they were changed nothing. + args_before: Any = None + if not decision.blocked and decision.args_override is not None: + if self._run_log is not None and decision.args_override != override_before: + args_before = copy.deepcopy(_tool_args(envelope)) + _apply_args_override_to_envelope(envelope, decision.args_override) + effect, applied = _blocking_effect( + event, + result, + decision, + blocked=decision.blocked and not blocked_before, + args_before=args_before, + args_after=_tool_args(envelope) if args_before is not None else None, + reminders_before=reminders_before, + contexts_before=contexts_before, + ) + recorded = self._end_run(run, result=result, effect=effect, applied=applied) + try: + if trace is not None: + await trace.finished( + outcome=_hook_outcome(result, blocked=decision.blocked and not blocked_before), + arguments_modified=decision.args_override != override_before, + exit_code=result.exit_code, + timed_out=result.timed_out, + ) + finally: + if recorded: + await self._notify_change(executions=False) if decision.blocked: # Cancel any planned non-blocking hooks for this event — # nothing else will execute on this dispatch. return decision - if decision.args_override is not None: - _apply_args_override_to_envelope(envelope, decision.args_override) if self._closed or self._session_drain_generation != session_drain_generation: return decision @@ -296,6 +375,7 @@ async def fire( scope=scope, session_drain_generation=session_drain_generation, target_operation_id=target_operation_id, + origin=origin, ) except Exception as exc: _log_nonblocking_spawn_failure(hook, exc) @@ -304,10 +384,10 @@ async def fire( # ── runs in flight ─────────────────────────────────────────────── - def _add_execution(self, hook: HookConfig, *, waited_at: HookWaitPoint) -> int: + def _add_execution(self, hook: HookConfig, *, waited_at: HookWaitPoint, run_id: str) -> int: key = next(self._execution_keys) self._executions[key] = HookExecutionView( - hook_id=hook.id, waited_at=waited_at, queued=True, started_at=time.monotonic() + hook_id=hook.id, waited_at=waited_at, queued=True, started_at=time.monotonic(), run_id=run_id ) return key @@ -319,26 +399,115 @@ def _mark_running(self, key: int) -> bool: self._executions[key] = replace(view, queued=False) return True - async def _notify_change(self) -> None: - """Report the current runs; a failing subscriber never touches the hook.""" - if self.on_change is None: - return - self._reported_runs = bool(self._executions) - try: - await self.on_change(self) - except Exception: - logger.exception("Hook execution change notification failed") + async def _notify_change(self, *, executions: bool = True, runs: bool = True) -> None: + """Report the runs in flight (*executions*), then that the run log changed (*runs*). + + Separate conditions: a run ending after the manager closed changes the + log but publishes no snapshot. A failing subscriber never touches the hook. + """ + if executions and self.on_change is not None: + self._reported_runs = bool(self._executions) + try: + await self.on_change(self) + except Exception: + logger.exception("Hook execution change notification failed") + if runs and self._run_log is not None and self._on_runs_change is not None: + try: + await self._on_runs_change() + except Exception: + logger.exception("Hook run log change notification failed") def _open_trace( self, target_operation_id: str | None, *, context: TrajectoryContext | None = None, + operation_id: str | None = None, ) -> HookOperationTrace | None: return HookOperationTrace.open( target_operation_id=target_operation_id, context=context, provider=self.trajectory_context_provider, + operation_id=operation_id, + ) + + # ── run log ────────────────────────────────────────────────────── + + def _new_run(self, payload: dict[str, Any], *, origin: InvocationOrigin | None = None, retry: bool = False) -> _Run: + """A run's id, and, when runs are recorded, the payload it will be handed as it is now. + + Serialized here, not kept by reference: a later blocking hook's + argument override rewrites the same dict in place. + """ + run_id = new_analytics_id() + if self._run_log is None: + return _Run(run_id) + tool = payload.get("tool") + tool_name = tool_call_id = "" + if isinstance(tool, dict): + tool_name = str(tool.get("name") or "") + tool_call_id = str(tool.get("call_id") or "") + sub_agent = "" + if origin is not None and origin.kind == "sub_agent": + named = payload.get("sub_agent") + name = named.get("name") if isinstance(named, dict) else None + sub_agent = str(name or payload.get("profile") or "") + return _Run( + run_id, + payload_text=json.dumps(payload, default=str), + tool_name=tool_name, + tool_call_id=tool_call_id, + sub_agent=sub_agent, + retry=retry, + ) + + def _register_run(self, hook: HookConfig, run: _Run) -> None: + if self._run_log is None or run.payload_text is None: + return + self._run_log.register( + run_id=run.run_id, + session_id=self._run_log_session_id, + hook=hook, + source=self._hook_sources.get(id(hook), ""), + source_path=self._hook_source_paths.get(id(hook), ""), + payload_text=run.payload_text, + tool_name=run.tool_name, + tool_call_id=run.tool_call_id, + sub_agent=run.sub_agent, + retry=run.retry, + ) + + def _start_run(self, run: _Run) -> bool: + return self._run_log is not None and self._run_log.mark_running(run.run_id) + + def _end_run( + self, + run: _Run, + *, + outcome: str = HookOutcome.CANCELLED, + result: HookResult | None = None, + effect: HookRunEffect | None = None, + applied: HookRunApplied | None = None, + log_path: str = "", + error: str = "", + ) -> bool: + """End *run* in the run log; whether it was open there.""" + if self._run_log is None: + return False + if result is None: + return self._run_log.finish(run.run_id, outcome=outcome, stderr=error, log_path=log_path) + return self._run_log.finish( + run.run_id, + outcome=_hook_outcome(result, blocked=False), + exit_code=result.exit_code, + effect=effect, + applied=applied, + result_file=result.result_file, + decision=json.dumps(result.decision, indent=2, ensure_ascii=False, default=str) if result.decision else "", + stdout=result.stdout, + stderr=result.stderr, + stdout_dropped=result.stdout_dropped, + stderr_dropped=result.stderr_dropped, ) # ── drains ─────────────────────────────────────────────────────── @@ -392,7 +561,7 @@ async def drain_session(self, *, close: bool = True) -> None: # session-switch event that would clear what it reported. self._executions.clear() if self._reported_runs: - await self._notify_change() + await self._notify_change(runs=False) async def _drain(self, *, scopes: set[str], timeout: float | None) -> None: tracked = [t for t in self._inflight if t.scope in scopes] @@ -424,11 +593,13 @@ async def _drain(self, *, scopes: set[str], timeout: float | None) -> None: # Also when this drain is cancelled (an interrupt during # ``drain_turn``): ``gather`` passes the cancel on to the runs and # raises only once every one of them is done. - dropped = False + dropped = ended = False for tracked_task in tracked: - dropped |= self._settle_unstarted(tracked_task) - if dropped: - await self._notify_change() + was_dropped, was_ended = self._settle_unstarted(tracked_task) + dropped |= was_dropped + ended |= was_ended + if dropped or ended: + await self._notify_change(executions=dropped, runs=ended) for tracked_task in tracked: if not tracked_task.task.done(): @@ -547,20 +718,23 @@ async def _run_blocking( payload: dict[str, Any], *, waited_at: HookWaitPoint, + run: _Run, ) -> HookResult: # Registered as queued before the slot is taken: probing the semaphore # first would race other runs taking or releasing it. - key = self._add_execution(hook, waited_at=waited_at) + key = self._add_execution(hook, waited_at=waited_at, run_id=run.run_id) + self._register_run(hook, run) try: await self._notify_change() async with self._sem: # Published while holding the slot: ``on_change`` must stay a # quick store (the TUI defers its render), or it delays every # run queued behind this one. A run that queued past the - # manager's close publishes nothing, as at its end. - if self._mark_running(key): - await self._notify_change() - return await self._runner.run_and_wait(hook, payload) + # manager's close publishes no snapshot, as at its end, but + # still changes the run log. + running = self._mark_running(key) + await self._notify_change(executions=running, runs=self._start_run(run)) + return await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text) finally: # Every removal is reported, a cancelled run's too: the report # runs on the run's own task and reads the set when it is stored, @@ -568,8 +742,9 @@ async def _run_blocking( # after the manager closed (an undrained ``user_interrupt`` # observer) was already cleared and reported: publishing again # would overwrite the snapshot of the manager that replaced this one. - if self._executions.pop(key, None) is not None: - await self._notify_change() + # The caller ends the run in the run log, with what it applied. + removed = self._executions.pop(key, None) is not None + await self._notify_change(executions=removed, runs=False) async def _spawn_nonblocking( self, @@ -580,9 +755,11 @@ async def _spawn_nonblocking( session_drain_generation: int, existing_job: OutboxJob | None = None, target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> None: if self._closed or self._session_drain_generation != session_drain_generation: return + run = self._new_run(payload, origin=origin, retry=existing_job is not None) job = existing_job if hook.execution.delivery == "durable" and job is None: job = self._outbox.write_pending( @@ -602,7 +779,7 @@ async def _spawn_nonblocking( # Async turn-scope hooks must stay in the active turn: drain_turn() # publishes their ids in the response fence, whose analyzer lookup is # intentionally confined to that turn's node membership. - trace = self._open_trace(target_operation_id) + trace = self._open_trace(target_operation_id, operation_id=run.run_id) if trace is not None: try: await trace.started( @@ -631,7 +808,7 @@ async def _spawn_nonblocking( if hook.execution.mode == "async": # The drain of the run's scope waits on it; nobody waits on an interrupt observer. waited_at: HookWaitPoint = "turn" if scope == "turn" else "session" if scope == "session" else "none" - self._start_tracked(hook, payload, job, trace, scope=scope, waited_at=waited_at) + self._start_tracked(hook, payload, job, trace, run, scope=scope, waited_at=waited_at) await self._notify_change() return @@ -640,7 +817,7 @@ async def _spawn_nonblocking( try: if job is not None: self._outbox.mark_started(job) - await self._runner.spawn_detached(hook, payload, job=job) + log_path = await self._runner.spawn_detached(hook, payload, job=job, payload_text=run.payload_text) except asyncio.CancelledError: if trace is not None: trace.finished_soon(outcome=HookOutcome.CANCELLED) @@ -650,17 +827,30 @@ async def _spawn_nonblocking( if job is not None: with contextlib.suppress(OSError): self._outbox.mark_failed(job, exit_code=None, error=str(exc)) - if trace is not None: - await trace.finished(outcome=HookOutcome.SPAWN_FAILED) + # Recorded ended at once: nobody observes a detached run. + self._register_run(hook, run) + recorded = self._end_run(run, outcome=HookOutcome.SPAWN_FAILED, error=str(exc)) + try: + if trace is not None: + await trace.finished(outcome=HookOutcome.SPAWN_FAILED) + finally: + if recorded: + await self._notify_change(executions=False) return - if trace is not None: - # Handed to the detached worker: the parent never observes - # its exit, so the recorded span ends at the hand-off. - await trace.finished(outcome=HookOutcome.DETACHED) + self._register_run(hook, run) + recorded = self._end_run(run, outcome=HookOutcome.DETACHED, log_path=str(log_path)) + try: + if trace is not None: + # Handed to the detached worker: the parent never observes + # its exit, so the recorded span ends at the hand-off. + await trace.finished(outcome=HookOutcome.DETACHED) + finally: + if recorded: + await self._notify_change(executions=False) return # Observed to its end like any other run, though nobody waits on it. - self._start_tracked(hook, payload, job, trace, scope="background", waited_at="none") + self._start_tracked(hook, payload, job, trace, run, scope="background", waited_at="none") await self._notify_change() def _start_tracked( @@ -669,6 +859,7 @@ def _start_tracked( payload: dict[str, Any], job: OutboxJob | None, trace: HookOperationTrace | None, + run: _Run, *, scope: str, waited_at: HookWaitPoint, @@ -677,39 +868,49 @@ def _start_tracked( The caller checked that the manager is open and reports the run only after this: a drain or close that runs during the report already sees - the task, which owns the run's span and snapshot entry from here on. + the task, which owns the run's span, snapshot entry and run log entry + from here on. """ - key = self._add_execution(hook, waited_at=waited_at) - task = asyncio.create_task(self._run_async_tracked(hook, payload, job, trace, execution_key=key)) + key = self._add_execution(hook, waited_at=waited_at, run_id=run.run_id) + self._register_run(hook, run) + task = asyncio.create_task(self._run_async_tracked(hook, payload, job, trace, execution_key=key, run=run)) operation_id = trace.operation_id if trace is not None and trace.start_committed else None tracked = _Tracked( - hook=hook, task=task, scope=scope, job=job, operation_id=operation_id, execution_key=key, trace=trace + hook=hook, + task=task, + scope=scope, + job=job, + operation_id=operation_id, + execution_key=key, + trace=trace, + run=run, ) self._inflight.append(tracked) if scope in ("detached", "background"): task.add_done_callback(lambda _task, tracked=tracked: self._reap_background(tracked)) - def _settle_unstarted(self, tracked: _Tracked) -> bool: - """Settle a run cancelled before its first step; return whether its snapshot entry was dropped. + def _settle_unstarted(self, tracked: _Tracked) -> tuple[bool, bool]: + """Settle a run cancelled before its first step. - Such a task never ran the body that closes the run's span and drops - its entry. A no-op for a run that has not ended, or whose body ran: - that body closed the span (closing twice keeps the first) and dropped - the entry. + Returns whether its snapshot entry was dropped and whether its run log + entry was ended. Such a task never ran the body that closes the run's + span, drops its entry and ends its run. A no-op for a run that has not + ended, or whose body ran: that body closed the span (closing twice + keeps the first), dropped the entry and ended the run. """ if not tracked.task.cancelled(): - return False + return False, False if tracked.trace is not None: tracked.trace.finished_soon(outcome=HookOutcome.CANCELLED) - if tracked.execution_key is None: - return False - return self._executions.pop(tracked.execution_key, None) is not None + dropped = tracked.execution_key is not None and self._executions.pop(tracked.execution_key, None) is not None + ended = tracked.run is not None and self._end_run(tracked.run, outcome=HookOutcome.CANCELLED) + return dropped, ended def _reap_background(self, tracked: _Tracked) -> None: with contextlib.suppress(ValueError): self._inflight.remove(tracked) # Nothing here cancels these runs (loop teardown does), so a dropped - # entry goes unreported: the next report leaves it out. + # entry or ended run goes unreported: the next report leaves it out. self._settle_unstarted(tracked) try: tracked.task.result() @@ -726,16 +927,25 @@ async def _run_async_tracked( trace: HookOperationTrace | None = None, *, execution_key: int | None = None, + run: _Run | None = None, ) -> HookResult: """Wrap ``runner.run_and_wait`` with outbox + semaphore + error logging.""" + run = run if run is not None else _Run(new_analytics_id()) + ended = False try: - return await self._run_async_tracked_inner(hook, payload, job, trace, execution_key) + result = await self._run_async_tracked_inner(hook, payload, job, trace, execution_key, run) + ended = self._end_run(run, result=result, applied=_nonblocking_applied(result)) + return result + except asyncio.CancelledError: + ended = self._end_run(run, outcome=HookOutcome.CANCELLED) + raise finally: - # Reported however it ends, as for a blocking run; not after the - # manager closed first: ``background`` runs are not drained and may - # end long after the close. - if execution_key is not None and self._executions.pop(execution_key, None) is not None: - await self._notify_change() + # Reported however it ends, as for a blocking run; no snapshot + # after the manager closed first: ``background`` runs are not + # drained and may end long after the close. The run log still + # records them. + removed = execution_key is not None and self._executions.pop(execution_key, None) is not None + await self._notify_change(executions=removed, runs=ended) async def _run_async_tracked_inner( self, @@ -744,6 +954,7 @@ async def _run_async_tracked_inner( job: OutboxJob | None, trace: HookOperationTrace | None, execution_key: int | None, + run: _Run, ) -> HookResult: try: # The slot is taken inside the ``try``: a hook cancelled while it @@ -751,11 +962,11 @@ async def _run_async_tracked_inner( # span was opened before the task was created, and once the task # took a step only this handler closes it. async with self._sem: - if execution_key is not None and self._mark_running(execution_key): - await self._notify_change() + running = execution_key is not None and self._mark_running(execution_key) + await self._notify_change(executions=running, runs=self._start_run(run)) if job is not None: self._outbox.mark_started(job) - result = await self._runner.run_and_wait(hook, payload) + result = await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text) except asyncio.CancelledError: if job is not None and hook.execution.delivery == "durable": logger.info( @@ -867,6 +1078,69 @@ def _hooks_by_id(hooks: list[HookConfig]) -> dict[str, list[HookConfig]]: return grouped +def _tool_args(envelope: dict[str, Any]) -> Any: + tool = envelope.get("tool") + return tool.get("args") if isinstance(tool, dict) else None + + +def _asks_unsupported(event: HookEvent, decision: dict[str, Any]) -> bool: + """Whether a result file asks for something *event* does not take.""" + action = str(decision.get("action") or "allow").lower() + return ( + (action == "block" and event not in GATED_EVENTS) + or (action == "modify" and event not in _ARGS_OVERRIDE_EVENTS) + or (bool(decision.get("system_reminder")) and event not in _SYSTEM_REMINDER_EVENTS) + or (bool(decision.get("extra_context")) and event not in _EXTRA_CONTEXT_EVENTS) + ) + + +def _asks_anything(decision: dict[str, Any]) -> bool: + action = str(decision.get("action") or "allow").lower() + return action in ("block", "modify") or bool(decision.get("system_reminder") or decision.get("extra_context")) + + +def _blocking_effect( + event: HookEvent, + result: HookResult, + decision: HookDecision, + *, + blocked: bool, + args_before: Any, + args_after: Any, + reminders_before: int, + contexts_before: int, +) -> tuple[HookRunEffect | None, HookRunApplied]: + """What iCode took from one blocking run, folded into *decision*; at most one effect. + + *blocked* when this run blocked; *args_before* is None unless it changed the override. + """ + failed = _result_failed(result) + if blocked: + return "blocked", HookRunApplied( + block_reason=decision.block_reason, not_applied="failed_blocked" if failed else None + ) + if failed: + return None, HookRunApplied(not_applied="failed") + if args_before is not None and args_before != args_after: + return "modified_args", HookRunApplied(args=json.dumps(args_after, indent=2, ensure_ascii=False, default=str)) + if len(decision.system_reminders) > reminders_before: + return "added_reminder", HookRunApplied(reminder=decision.system_reminders[-1]) + if len(decision.extra_context) > contexts_before: + return "added_context", HookRunApplied(extra_context=decision.extra_context[-1]) + if _asks_unsupported(event, result.decision): + return None, HookRunApplied(not_applied="unsupported") + return None, HookRunApplied() + + +def _nonblocking_applied(result: HookResult) -> HookRunApplied: + """An ``async`` or ``fire_and_forget`` run's decision is never taken.""" + if _result_failed(result): + return HookRunApplied(not_applied="failed") + if _asks_anything(result.decision): + return HookRunApplied(not_applied="nonblocking") + return HookRunApplied() + + def _apply_args_override_to_envelope(envelope: dict[str, Any], args_override: dict[str, Any]) -> None: """Merge blocking hook argument overrides into the current tool payload.""" tool = envelope.get("tool") diff --git a/src/chrys/service/hooks/run_log.py b/src/chrys/service/hooks/run_log.py new file mode 100644 index 000000000..d254a7e72 --- /dev/null +++ b/src/chrys/service/hooks/run_log.py @@ -0,0 +1,260 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The hook runs since the process started, across sessions, for the TUI's Hooks panel. + +One log lives as long as the engine that owns it; every main-session hook +manager the engine builds writes its runs here, so session switches and +manager replacements keep them. Writes are synchronous; the manager tells its +observer after each one. Details (payload, result file, output) stay in this +process's memory only. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING + +from chrys.foundation.models.hook_runs import ( + HookResultFileStatus, + HookRunApplied, + HookRunDetail, + HookRunEffect, + HookRunIO, + HookRunRecord, +) + +if TYPE_CHECKING: + from chrys.service.hooks.schema import HookConfig + +HOOK_RUN_LOG_MAX_ENDED = 100 +"""Ended runs kept; queued and running ones are always kept, and not counted.""" + +HOOK_RUN_LOG_MAX_DETAIL_CHARS = 16 * 1024 * 1024 +"""What the kept details may hold together; the oldest ended runs' details go first.""" + +HOOK_RUN_PAYLOAD_MAX_CHARS = 64 * 1024 +"""Payload text kept per run (payloads are ASCII JSON, so characters are bytes).""" + + +@dataclass(slots=True) +class _Entry: + record: HookRunRecord + detail: HookRunDetail + + +def _io_size(io: HookRunIO | None) -> int: + if io is None: + return 0 + return len(io.payload) + len(io.decision) + len(io.stdout) + len(io.stderr) + + +class HookRunLog: + """The hook runs of every session since the process started; the latest ended ones only.""" + + def __init__( + self, + *, + max_ended: int = HOOK_RUN_LOG_MAX_ENDED, + max_detail_chars: int = HOOK_RUN_LOG_MAX_DETAIL_CHARS, + ) -> None: + self._max_ended = max_ended + self._max_detail_chars = max_detail_chars + self._entries: dict[str, _Entry] = {} + self._ended: list[str] = [] + """Ended run ids, oldest end first.""" + self._detail_chars = 0 + self._registered = 0 + """Runs registered so far: the next run's number is one more.""" + self._dropped = 0 + """Ended runs dropped past the cap.""" + + # ── reads ──────────────────────────────────────────────────────── + + def records(self) -> tuple[HookRunRecord, ...]: + """The runs kept, oldest first, by number: a run keeps its place as it starts and ends.""" + # Registered in number order, and a dict keeps that order. + return tuple(entry.record for entry in self._entries.values()) + + @property + def dropped(self) -> int: + """Ended runs no longer kept because more than the cap ended since.""" + return self._dropped + + def record(self, run_id: str) -> HookRunRecord | None: + entry = self._entries.get(run_id) + return entry.record if entry is not None else None + + def detail(self, run_id: str) -> HookRunDetail | None: + entry = self._entries.get(run_id) + return entry.detail if entry is not None else None + + # ── writes ─────────────────────────────────────────────────────── + + def register( + self, + *, + run_id: str, + session_id: str, + hook: HookConfig, + source: str, + source_path: str, + payload_text: str, + tool_name: str, + tool_call_id: str, + sub_agent: str, + retry: bool, + ) -> None: + """Add a run queued for a slot.""" + now = time.monotonic() + self._registered += 1 + record = HookRunRecord( + run_id=run_id, + session_id=session_id, + hook_id=hook.id, + hook_event=str(hook.event), + execution_mode=hook.execution.mode, + detach=hook.execution.detach, + tool_name=tool_name, + sub_agent=sub_agent, + retry=retry, + registered_at=now, + started_at=None, + ended_at=None, + registered_wall=time.time(), + state="queued", + outcome=None, + exit_code=None, + effect=None, + number=self._registered, + ) + run = hook.run + if run.type == "script": + argv: tuple[str, ...] = (run.path, *run.args) + elif run.type == "command": + argv = (*run.argv, *run.args) + else: + argv = (run.shell,) + io = HookRunIO( + payload=payload_text[:HOOK_RUN_PAYLOAD_MAX_CHARS], + payload_size=len(payload_text.encode("utf-8")), + result_file=None, + decision="", + stdout="", + stderr="", + stdout_dropped=0, + stderr_dropped=0, + ) + detail = HookRunDetail( + record=record, + tool_call_id=tool_call_id, + timeout_seconds=hook.execution.timeout_seconds, + on_error=hook.execution.on_error, + delivery=hook.execution.delivery, + source=source, + source_path=source_path, + run_type=run.type, + run_argv=argv, + run_cwd=run.cwd, + run_env_names=tuple(run.env), + log_path="", + shared_log=False, + io=io, + applied=None, + ) + self._entries[run_id] = _Entry(record, detail) + self._detail_chars += _io_size(io) + self._trim_details() + + def mark_running(self, run_id: str) -> bool: + """Mark a queued run as holding its slot; False when it is not queued here.""" + entry = self._entries.get(run_id) + if entry is None or entry.record.state != "queued": + return False + self._set_record(entry, replace(entry.record, state="running", started_at=time.monotonic())) + return True + + def finish( + self, + run_id: str, + *, + outcome: str, + exit_code: int | None = None, + effect: HookRunEffect | None = None, + applied: HookRunApplied | None = None, + result_file: HookResultFileStatus | None = None, + decision: str = "", + stdout: str = "", + stderr: str = "", + stdout_dropped: int = 0, + stderr_dropped: int = 0, + log_path: str = "", + ) -> bool: + """End a queued or running run; False when it is not open here.""" + entry = self._entries.get(run_id) + if entry is None or entry.record.state == "ended": + return False + record = replace( + entry.record, + state="ended", + ended_at=time.monotonic(), + outcome=outcome, + exit_code=exit_code, + effect=effect, + ) + io = entry.detail.io + if io is not None: + new_io = replace( + io, + result_file=result_file, + decision=decision, + stdout=stdout, + stderr=stderr, + stdout_dropped=stdout_dropped, + stderr_dropped=stderr_dropped, + ) + self._detail_chars += _io_size(new_io) - _io_size(io) + io = new_io + shared = bool(log_path) and any( + other.detail.log_path == log_path for key, other in self._entries.items() if key != run_id + ) + if shared: + for key, other in self._entries.items(): + if key != run_id and other.detail.log_path == log_path and not other.detail.shared_log: + other.detail = replace(other.detail, shared_log=True) + entry.record = record + entry.detail = replace( + entry.detail, + record=record, + io=io, + applied=applied if applied is not None else HookRunApplied(), + log_path=log_path, + shared_log=shared, + ) + self._ended.append(run_id) + self._trim_ended() + self._trim_details() + return True + + def _set_record(self, entry: _Entry, record: HookRunRecord) -> None: + entry.record = record + entry.detail = replace(entry.detail, record=record) + + def _trim_ended(self) -> None: + while len(self._ended) > self._max_ended: + entry = self._entries.pop(self._ended.pop(0)) + self._detail_chars -= _io_size(entry.detail.io) + self._dropped += 1 + + def _trim_details(self) -> None: + """Drop the oldest ended runs' details until the rest fit; queued and running runs keep theirs.""" + if self._detail_chars <= self._max_detail_chars: + return + for run_id in self._ended: + entry = self._entries[run_id] + if entry.detail.io is None: + continue + self._detail_chars -= _io_size(entry.detail.io) + entry.detail = replace(entry.detail, io=None) + if self._detail_chars <= self._max_detail_chars: + return diff --git a/src/chrys/service/hooks/runner.py b/src/chrys/service/hooks/runner.py index ae9e387e2..b094a2e93 100644 --- a/src/chrys/service/hooks/runner.py +++ b/src/chrys/service/hooks/runner.py @@ -43,7 +43,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Final +from typing import TYPE_CHECKING, Any, BinaryIO, Final, Literal from chrys.foundation.platform.output_capture import BoundedCapture, drain_process_pipes from chrys.foundation.platform.process import ( @@ -69,6 +69,8 @@ HOOK_OUTPUT_CAPTURE_LIMIT_BYTES = 256 * 1024 """Bytes kept of a waited-for hook's stdout and of its stderr; decisions come from the result file.""" +ResultFileStatus = Literal["ok", "missing", "invalid"] + # --------------------------------------------------------------------------- # Public types # --------------------------------------------------------------------------- @@ -94,6 +96,11 @@ class HookResult: """ stdout: str = "" stderr: str = "" + stdout_dropped: int = 0 + stderr_dropped: int = 0 + """Bytes cut from the middle of each stream past ``HOOK_OUTPUT_CAPTURE_LIMIT_BYTES``.""" + result_file: ResultFileStatus = "missing" + """Whether the result file was read (``ok``), absent or empty, or ignored as invalid.""" duration_ms: int = 0 @@ -156,18 +163,26 @@ def __init__(self, *, hooks_dir: Path) -> None: def hooks_dir(self) -> Path: return self._hooks_dir - async def run_and_wait(self, hook: HookConfig, payload: dict[str, Any]) -> HookResult: + async def run_and_wait( + self, hook: HookConfig, payload: dict[str, Any], *, payload_text: str | None = None + ) -> HookResult: """Spawn *hook* with stdio pipes and await it (with timeout). Used for ``blocking`` and ``async`` modes. The async variant only differs in *who* awaits — the manager does, after dispatch - returns — so this entry point is shared. + returns — so this entry point is shared. *payload_text*, when + given, is written to the payload file as it is, in place of + serializing *payload*. """ start = datetime.now(UTC) result = HookResult(hook_id=hook.id, exit_code=None) invocation: HookInvocation | None = None + # Outside the subprocess block: a run that times out or is stopped + # keeps what it printed until then. + stdout = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) + stderr = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) try: - invocation = self._build_invocation(hook, payload) + invocation = self._build_invocation(hook, payload, payload_text=payload_text) async with managed_subprocess( *invocation.cmd, stdin=asyncio.subprocess.DEVNULL, @@ -177,18 +192,16 @@ async def run_and_wait(self, hook: HookConfig, payload: dict[str, Any]) -> HookR env=invocation.env, ) as proc: process_group_id = getattr(proc, "pid", None) if sys.platform != "win32" else None - stdout = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) - stderr = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) await wait_for_subprocess( drain_process_pipes(proc, stdout, stderr), timeout=hook.execution.timeout_seconds, process_group_id=process_group_id, ) result.exit_code = proc.returncode - result.stdout = stdout.snapshot().text() - result.stderr = stderr.snapshot().text() + _take_output(result, stdout, stderr) except TimeoutError: result.timed_out = True + _take_output(result, stdout, stderr) logger.warning( "Hook %r timed out after %.1fs", hook.id, @@ -197,7 +210,9 @@ async def run_and_wait(self, hook: HookConfig, payload: dict[str, Any]) -> HookR return result except SubprocessStoppedError: result.timed_out = True - result.stderr = "Hook process entered stopped state and was terminated." + _take_output(result, stdout, stderr) + note = "Hook process entered stopped state and was terminated." + result.stderr = f"{result.stderr.rstrip()}\n{note}" if result.stderr.strip() else note logger.warning("Hook %r entered stopped state and was terminated", hook.id) return result except (FileNotFoundError, OSError, ValueError) as exc: @@ -205,7 +220,7 @@ async def run_and_wait(self, hook: HookConfig, payload: dict[str, Any]) -> HookR result.stderr = str(exc) finally: if invocation is not None: - result.decision = _read_result_file(invocation.result_path) + result.decision, result.result_file = _read_result_file_with_status(invocation.result_path) _cleanup_invocation_files(invocation) result.duration_ms = int((datetime.now(UTC) - start).total_seconds() * 1000) return result @@ -216,6 +231,8 @@ def _build_invocation( self, hook: HookConfig, payload: dict[str, Any], + *, + payload_text: str | None = None, ) -> HookInvocation: """Build the (argv, env, cwd) triple for a hook invocation.""" workspace_cwd = payload.get("cwd") or os.getcwd() @@ -267,7 +284,10 @@ def _build_invocation( _set_owner_only(payload_fd) with os.fdopen(payload_fd, "w", encoding="utf-8") as f: payload_fd = -1 - json.dump(payload, f, default=str) + if payload_text is None: + json.dump(payload, f, default=str) + else: + f.write(payload_text) env["CHRYS_HOOK_PAYLOAD_FILE"] = payload_path # Result file: hooks write their decision here so stdout/stderr @@ -333,21 +353,23 @@ async def spawn_detached( payload: dict[str, Any], *, job: OutboxJob | None = None, - ) -> None: - """Spawn a detached worker, redirecting stdio to a log file. + payload_text: str | None = None, + ) -> Path: + """Spawn a detached worker, redirecting stdio to a log file; return that log's path. The worker runs the real hook command, updates durable outbox state, and cleans temp files. On POSIX the worker starts a new session; on Windows it starts in a detached process group. Either way it outlives - the parent Chrys process. + the parent Chrys process. The log is named by the second the run + started, so runs of one hook in the same second share it. """ - invocation = self._build_invocation(hook, payload) + invocation = self._build_invocation(hook, payload, payload_text=payload_text) invocation_path: Path | None = None log_file: Any | None = None + log_path = self._log_path_for(hook, payload) try: invocation_path = self._write_detached_invocation(invocation, hook_id=hook.id, job=job) - log_path = self._log_path_for(hook, payload) log_path.parent.mkdir(parents=True, exist_ok=True) log_file = _open_owner_only_append(log_path) worker_env = os.environ.copy() @@ -384,6 +406,7 @@ async def spawn_detached( if log_file is not None: with contextlib.suppress(OSError): log_file.close() + return log_path def _write_detached_invocation( self, @@ -437,33 +460,47 @@ def _log_path_for(self, hook: HookConfig, payload: dict[str, Any]) -> Path: def _read_result_file(path: Path) -> dict[str, Any]: """Parse ``CHRYS_HOOK_RESULT``; return ``{}`` on absent/empty/invalid.""" + return _read_result_file_with_status(path)[0] + + +def _read_result_file_with_status(path: Path) -> tuple[dict[str, Any], ResultFileStatus]: + """Parse ``CHRYS_HOOK_RESULT``, and say whether it was read, absent or empty, or invalid.""" try: with path.open("rb") as result_file: raw = result_file.read(HOOK_RESULT_FILE_MAX_BYTES + 1) except OSError: - return {} + return {}, "missing" if len(raw) > HOOK_RESULT_FILE_MAX_BYTES: logger.warning( "Hook result file %s exceeded the %d-byte limit and was ignored", path, HOOK_RESULT_FILE_MAX_BYTES, ) - return {} + return {}, "invalid" try: text = raw.decode("utf-8").strip() except UnicodeDecodeError: - return {} + return {}, "invalid" if not text: - return {} + return {}, "missing" try: parsed = json.loads(text) except json.JSONDecodeError as exc: logger.warning("Hook result file %s contained invalid JSON: %s", path, exc) - return {} + return {}, "invalid" if not isinstance(parsed, dict): logger.warning("Hook result file %s must be a JSON object, got %s", path, type(parsed).__name__) - return {} - return parsed + return {}, "invalid" + return parsed, "ok" + + +def _take_output(result: HookResult, stdout: BoundedCapture, stderr: BoundedCapture) -> None: + out = stdout.snapshot() + err = stderr.snapshot() + result.stdout = out.text() + result.stderr = err.text() + result.stdout_dropped = out.dropped + result.stderr_dropped = err.dropped def _cleanup_invocation_files(invocation: HookInvocation) -> None: diff --git a/src/chrys/service/trajectory/hooks.py b/src/chrys/service/trajectory/hooks.py index b6a9d4de4..9c86fd154 100644 --- a/src/chrys/service/trajectory/hooks.py +++ b/src/chrys/service/trajectory/hooks.py @@ -53,9 +53,11 @@ class HookOperationTrace: "_target_operation_id", ) - def __init__(self, context: TrajectoryContext, *, target_operation_id: str | None) -> None: + def __init__( + self, context: TrajectoryContext, *, target_operation_id: str | None, operation_id: str | None = None + ) -> None: self._context = context - self._operation_id = new_analytics_id() + self._operation_id = operation_id or new_analytics_id() self._target_operation_id = target_operation_id self._started_ns = time.monotonic_ns() self._finished = False @@ -69,14 +71,18 @@ def open( target_operation_id: str | None = None, context: TrajectoryContext | None = None, provider: TrajectoryContextProvider | None = None, + operation_id: str | None = None, ) -> HookOperationTrace | None: - """Bind to explicit, ambient, then provider scope; return ``None`` when unrecorded.""" + """Bind to explicit, ambient, then provider scope; return ``None`` when unrecorded. + + *operation_id*, when given, is the run's id elsewhere (the TUI's run log), so the two join. + """ resolved = context or current_trajectory() if resolved is None and provider is not None: resolved = provider() if resolved is None: return None - return cls(resolved, target_operation_id=target_operation_id) + return cls(resolved, target_operation_id=target_operation_id, operation_id=operation_id) @property def operation_id(self) -> str: diff --git a/tests/app/tui/behaviors/test_main_screen_interaction.py b/tests/app/tui/behaviors/test_main_screen_interaction.py index 6661a2dd1..e5d6931d1 100644 --- a/tests/app/tui/behaviors/test_main_screen_interaction.py +++ b/tests/app/tui/behaviors/test_main_screen_interaction.py @@ -337,8 +337,8 @@ async def test_shell_mode_sidebar_tab_strip_keeps_focus_for_keyboard_navigation( pilot=pilot, description="the clicked tab strip keeps focus", ) - await pilot.press("right") - await wait_for(lambda: tabs.active == "tab-context", pilot=pilot, description="Right selects the next tab") + await pilot.press("left") + await wait_for(lambda: tabs.active == "tab-toc", pilot=pilot, description="Left selects the previous tab") assert main.focused is strip main.shell_mode_state = False diff --git a/tests/app/tui/screens/main/test_hook_indicator_flow.py b/tests/app/tui/screens/main/test_hook_indicator_flow.py index b2d24a90c..5e58fa9be 100644 --- a/tests/app/tui/screens/main/test_hook_indicator_flow.py +++ b/tests/app/tui/screens/main/test_hook_indicator_flow.py @@ -19,7 +19,7 @@ from textual.widgets import TabbedContent from chrys.app.tui.app import ChrysApp -from chrys.app.tui.screens.dialogs.runtime_details import RUNTIME_DETAILS_HOOKS_TAB, RuntimeDetailsDialog +from chrys.app.tui.screens.dialogs.runtime_details import RuntimeDetailsDialog from chrys.app.tui.screens.main.screen import MainScreen from chrys.app.tui.widgets.chat.messages import AgentMessage from chrys.app.tui.widgets.chat.messages import UserMessage as UserMessageWidget @@ -29,6 +29,7 @@ from chrys.app.tui.widgets.chrome.hook_indicator import HOOK_INDICATOR_WIDTH, HookIndicator from chrys.app.tui.widgets.chrome.input_bar import InputBar from chrys.app.tui.widgets.markdown import VirtualizedMarkdown +from chrys.app.tui.widgets.sidebar.panel import SIDEBAR_HOOKS_TAB, SidebarPanel from chrys.foundation.events.bus import EventBus from chrys.foundation.events.types import ( ExecutionChanged, @@ -81,6 +82,9 @@ def get(self, name: str) -> AgentProfile | None: class _Engine(SessionGenerationEngine): + # The sidebar's Hooks tab reads it once the indicator opens that tab. + hook_runs = None + def __init__(self, bus: EventBus, cwd: Path, *, start_session: bool = True) -> None: self._bus = bus self._cwd = cwd @@ -646,15 +650,19 @@ async def test_clicking_the_indicator_opens_the_hooks_tab(tmp_path: Path) -> Non await _shows(pilot, "Hooks") indicator = main.query_one(HookIndicator) _start, end = _label_span(app) - # Clicking the label (its last text cell) opens the dialog. + sidebar = main.query_one(SidebarPanel) + if sidebar.is_visible: + sidebar.toggle() + await wait_for(lambda: not sidebar.is_visible, pilot=pilot, description="the sidebar closed") + # Clicking the label (its last text cell) opens the sidebar on its Hooks tab. await click_when_settled(pilot, indicator, offset=(end - 1 - indicator.region.x, 0)) + tabs = sidebar.query_one(TabbedContent) await wait_for( - lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, + lambda: sidebar.is_visible and tabs.active == SIDEBAR_HOOKS_TAB, pilot=pilot, - description="the runtime details dialog", + description="the sidebar open on its Hooks tab", ) - tabs = app.screen.query_one("#runtime-details-tabs", TabbedContent) - await wait_for(lambda: tabs.active == RUNTIME_DETAILS_HOOKS_TAB, pilot=pilot, description="the Hooks tab") + assert app.screen is main async def test_ctrl_q_still_quits(tmp_path: Path) -> None: @@ -1014,11 +1022,12 @@ def keys_whole() -> bool: await wait_for(keys_whole, pilot=pilot, description="the keys whole again") # The whole box is the label: its first cell opens the hooks too. + sidebar = main.query_one(SidebarPanel) await click_when_settled(pilot, indicator, offset=(0, 0)) await wait_for( - lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, + lambda: sidebar.is_visible and sidebar.query_one(TabbedContent).active == SIDEBAR_HOOKS_TAB, pilot=pilot, - description="the runtime details dialog", + description="the sidebar open on its Hooks tab", ) @@ -1420,9 +1429,9 @@ async def test_on_a_terminal_narrower_than_the_box_the_indicator_fits_and_cuts_t async def _open_runtime_details(pilot: Pilot, app: ChrysApp) -> None: - indicator = app.screen.query_one(HookIndicator) - _start, end = _label_span(app) - await click_when_settled(pilot, indicator, offset=(end - 2 - indicator.region.x, 0)) + main = app.screen + assert isinstance(main, MainScreen) + main.action_runtime_details() await wait_for( lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, pilot=pilot, @@ -1536,14 +1545,7 @@ async def test_a_mouse_that_left_under_a_dialog_drops_the_highlight_when_it_clos await _ready(pilot) await _shows(pilot, "Hooks") hover, _label = await _hover_label(pilot, app) - indicator = app.screen.query_one(HookIndicator) - _start, end = _label_span(app) - await click_when_settled(pilot, indicator, offset=(end - 2 - indicator.region.x, 0)) - await wait_for( - lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, - pilot=pilot, - description="the runtime details dialog", - ) + await _open_runtime_details(pilot, app) # The mouse moves to the dialog, far from the bottom-right corner, while it covers MainScreen. dialog = app.screen await pilot.hover(dialog, offset=(2, 2)) @@ -1565,15 +1567,8 @@ async def test_a_mouse_that_stayed_on_the_label_keeps_the_highlight_after_a_dial await _ready(pilot) await _shows(pilot, "Hooks") hover, label = await _hover_label(pilot, app) - indicator = app.screen.query_one(HookIndicator) - _start, end = _label_span(app) - # The click leaves the mouse where it is, on the label. - await click_when_settled(pilot, indicator, offset=(end - 2 - indicator.region.x, 0)) - await wait_for( - lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, - pilot=pilot, - description="the runtime details dialog", - ) + # Opened from the keyboard: the mouse stays where it is, on the label. + await _open_runtime_details(pilot, app) await pilot.press("escape") await wait_for( lambda: app._main_screen is not None and app.screen is app._main_screen, diff --git a/tests/app/tui/screens/main/test_hooks_panel_flow.py b/tests/app/tui/screens/main/test_hooks_panel_flow.py new file mode 100644 index 000000000..4eae927bf --- /dev/null +++ b/tests/app/tui/screens/main/test_hooks_panel_flow.py @@ -0,0 +1,767 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The sidebar's Hooks tab in the real app: fed by the engine's run log and ``HookRunsChanged`` (plan 2, 3, 5.2, 6). + +Where the log matters, real hook subprocesses run through a real manager wired +to the app's bus the way the engine wires it; a held hook polls for a release +file the test creates. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import re +import sys +import textwrap +import time +from pathlib import Path + +import pytest +from textual.geometry import Region +from textual.pilot import Pilot +from textual.screen import ModalScreen +from textual.widgets import Static, TabbedContent + +from chrys.app.tui.app import ChrysApp +from chrys.app.tui.screens.dialogs.hook_run import HookRunModal +from chrys.app.tui.screens.main.screen import MainScreen +from chrys.app.tui.widgets.chat.messages import AgentMessage +from chrys.app.tui.widgets.chat.messages import UserMessage as UserMessageWidget +from chrys.app.tui.widgets.chat.panel import ChatPanel +from chrys.app.tui.widgets.chrome.footer import ChrysFooter +from chrys.app.tui.widgets.chrome.hook_indicator import HookIndicator +from chrys.app.tui.widgets.markdown import VirtualizedMarkdown +from chrys.app.tui.widgets.sidebar.hooks import HookRunList +from chrys.app.tui.widgets.sidebar.panel import SIDEBAR_HOOKS_TAB, SidebarPanel +from chrys.foundation.events.bus import EventBus +from chrys.foundation.events.types import ( + AgentRuntimeDetails, + HookRunsChanged, + RuntimeHookDetails, + RuntimeHookSourceDetails, + SessionReady, +) +from chrys.foundation.models.hook_runs import HookRunDetail, HookRunRecord +from chrys.orchestration.engine.hook_reporting import wire_hook_reporting +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.manager import HookManager +from chrys.service.hooks.run_log import HookRunLog +from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HooksFile +from chrys.service.profiles.agents.schema import AgentProfile +from chrys.service.trajectory.hooks import HookOutcome +from tests.support.pilot_barrier import screen_is_settled +from tests.support.tui_app_harness import SessionGenerationEngine, make_chrys_app +from tests.support.tui_helpers import click_when_settled +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for, wait_until + +_SESSION = "test-session" +_PROFILE = AgentProfile(name="Code", display_name="Code", description="Test profile") + + +class _Registry: + def list_profiles(self) -> list[AgentProfile]: + return [_PROFILE] + + def load_all(self) -> None: + return + + def get(self, name: str) -> AgentProfile | None: + return _PROFILE if name == _PROFILE.name else None + + +def _runtime(hooks: int) -> AgentRuntimeDetails: + return AgentRuntimeDetails( + hook_sources=[ + RuntimeHookSourceDetails( + scope="global", + source_path="/config/hooks/hooks.yaml", + hooks=[RuntimeHookDetails(id=f"h{n}", event="after_turn") for n in range(hooks)], + ) + ] + if hooks + else [] + ) + + +class _Engine(SessionGenerationEngine): + def __init__(self, bus: EventBus, cwd: Path, hook_runs: object, *, configured: int = 2) -> None: + self._bus = bus + self._cwd = cwd + self.hook_runs = hook_runs + self._configured = configured + self.turn_lifecycle_task = None + + async def start(self, _profile: AgentProfile) -> None: + await self._bus.publish( + SessionReady( + agent_profile=_PROFILE.name, + display_name=_PROFILE.display_name, + session_id=_SESSION, + primary_cwd=str(self._cwd), + runtime_details=_runtime(self._configured), + ) + ) + + +def _make_app(tmp_path: Path, bus: EventBus, hook_runs: object, *, configured: int = 2) -> ChrysApp: + workspace = tmp_path / "work" + workspace.mkdir(exist_ok=True) + return make_chrys_app( + tmp_path / "state", + engine=_Engine(bus, workspace, hook_runs, configured=configured), + event_bus=bus, + agent_registry=_Registry(), + ) + + +async def _ready(pilot: Pilot) -> MainScreen: + app = pilot.app + assert isinstance(app, ChrysApp) + + def ready() -> bool: + screen = app._main_screen + return screen is not None and app.screen is screen and not screen._state.run.agent_loading + + await wait_for(ready, pilot=pilot, description="session ready") + main = app._main_screen + assert main is not None + return main + + +async def _show_hooks_tab(pilot: Pilot, main: MainScreen) -> HookRunList: + sidebar = main.query_one(SidebarPanel) + if not sidebar.is_visible: + sidebar.toggle() + sidebar.focus_tab(SIDEBAR_HOOKS_TAB) + hooks = sidebar.hooks_panel + await wait_for(lambda: hooks.is_on_screen and hooks.size.width > 0, pilot=pilot, description="Hooks tab shown") + return hooks + + +def _rows(main: MainScreen) -> list[str]: + rows = main.query_one("#hook-runs-rows", Static) + width, height = rows.size + if not width or not height: + return [] + lines = [strip.text.rstrip() for strip in rows.render_lines(Region(0, 0, width, height)) if strip.text.strip()] + # From the icon on: drop the run number before it. + return [re.sub(r"^\s*\d+ ", "", line) for line in lines] + + +def _empty(main: MainScreen) -> str: + empty = main.query_one("#hook-runs-empty", Static) + return str(empty.render()) if empty.display else "" + + +def _held(release: Path) -> list[str]: + script = textwrap.dedent( + f""" + import os, time + while not os.path.exists({str(release)!r}): + time.sleep(0.01) + """ + ) + return [sys.executable, "-c", script] + + +def _manager(tmp_path: Path, *hooks: HookConfig) -> HookManager: + hooks_dir = tmp_path / "hooks" + hooks_dir.mkdir(exist_ok=True) + return HookManager(file=HooksFile(hooks=list(hooks)), hooks_dir=hooks_dir) + + +def _hook(hook_id: str, event: HookEvent, argv: list[str], *, tool_name: str | None = None) -> HookConfig: + return HookConfig( + id=hook_id, + event=event, + run=HookRun(type="command", argv=argv), + execution=HookExecution(mode="blocking", timeout_seconds=30.0), + match=HookMatch(tool_name=tool_name), + ) + + +def _payload(name: str = "zsh") -> dict[str, object]: + return {"tool": {"name": name, "kind": "shell", "call_id": "c1", "args": {"command": "ls"}}} + + +# --- Fed by real runs ----------------------------------------------------------- + + +async def test_real_runs_appear_running_then_ended(tmp_path: Path) -> None: + release = tmp_path / "release" + manager = _manager(tmp_path, _hook("slow-shell", HookEvent.BEFORE_TOOL_CALL, _held(release))) + bus, log = EventBus(), HookRunLog() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: _empty(main) == "No hook has run since iCode started", pilot=pilot, description="empty") + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload())) + try: + await wait_for( + lambda: bool(_rows(main)) and _rows(main)[0].endswith("running"), + timeout=ENGINE_TURN_TIMEOUT, + pilot=pilot, + description="the running row", + ) + assert "slow-shell · zsh" in _rows(main)[0] + title = str(main.query_one("#hook-runs-title", Static).render()) + assert title == "Hooks (1 active)" + finally: + release.touch() + await asyncio.wait_for(fire, ENGINE_TURN_TIMEOUT) + await wait_for( + lambda: bool(_rows(main)) and _rows(main)[0].startswith("✓"), + pilot=pilot, + description="the ended row", + ) + assert str(main.query_one("#hook-runs-title", Static).render()) == "Hooks" + await manager.drain_session() + + +async def test_a_cancelled_run_shows_cancelled_without_waiting_for_idle(tmp_path: Path) -> None: + release = tmp_path / "release" + manager = _manager(tmp_path, _hook("gate", HookEvent.BEFORE_TOOL_CALL, _held(release))) + bus, log = EventBus(), HookRunLog() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload())) + try: + await wait_for( + lambda: bool(_rows(main)) and _rows(main)[0].endswith("running"), + timeout=ENGINE_TURN_TIMEOUT, + pilot=pilot, + description="running", + ) + # What an interrupt does to the turn's task: no idle event follows here. + fire.cancel() + with contextlib.suppress(asyncio.CancelledError): + await fire + await wait_for( + lambda: bool(_rows(main)) and _rows(main)[0].endswith("cancelled"), + pilot=pilot, + description="shown cancelled", + ) + assert _rows(main)[0].startswith("■") + finally: + release.touch() + await manager.drain_session() + + +async def test_clicking_a_row_opens_its_details_with_the_payload_the_hook_received(tmp_path: Path) -> None: + received = tmp_path / "received.json" + copy = textwrap.dedent( + f""" + import os, shutil + shutil.copyfile(os.environ["CHRYS_HOOK_PAYLOAD_FILE"], {str(received)!r}) + print("guarded") + """ + ) + manager = _manager(tmp_path, _hook("guard", HookEvent.BEFORE_TOOL_CALL, [sys.executable, "-c", copy])) + bus, log = EventBus(), HookRunLog() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + await manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload()) + await wait_for(lambda: len(_rows(main)) == 1, pilot=pilot, description="one row") + await click_when_settled(pilot, main.query_one("#hook-runs-rows", Static), offset=(4, 0)) + await wait_for( + lambda: isinstance(app.screen, HookRunModal) and app.screen.is_mounted, + pilot=pilot, + description="the details open", + ) + modal = app.screen + assert isinstance(modal, HookRunModal) + assert str(modal.query_one("#hook-run-container").border_title) == "Hook #1 · guard" + (record,) = log.records() + detail = log.detail(record.run_id) + assert detail is not None and detail.io is not None + assert detail.io.payload == received.read_text(encoding="utf-8") + await manager.drain_session() + + +async def test_runs_of_every_session_stay_in_one_timeline(tmp_path: Path) -> None: + bus, log = EventBus(), HookRunLog() + first = _manager(tmp_path, _hook("first-lint", HookEvent.AFTER_TURN, [sys.executable, "-c", "pass"])) + second = _manager(tmp_path, _hook("second-lint", HookEvent.AFTER_TURN, [sys.executable, "-c", "pass"])) + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + wire_hook_reporting(first, bus, session_id=_SESSION, run_log=log) + await first.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await wait_for(lambda: len(_rows(main)) == 1, pilot=pilot, description="first session's run") + # A switch to another session clears nothing. + await bus.publish(SessionReady(session_id="other", agent_profile="Code", runtime_details=_runtime(1))) + wire_hook_reporting(second, bus, session_id="other", run_log=log) + await second.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await wait_for(lambda: len(_rows(main)) == 2, pilot=pilot, description="both sessions' runs") + assert "first-lint" in _rows(main)[0] and "second-lint" in _rows(main)[1] + + +async def test_no_configured_hook_shows_no_hooks_configured(tmp_path: Path) -> None: + bus = EventBus() + app = _make_app(tmp_path, bus, HookRunLog(), configured=0) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: _empty(main) == "No hooks configured", pilot=pilot, description="unconfigured") + + +# --- Entry and workflow mode (section 2) ---------------------------------------- + + +async def test_clicking_the_indicator_opens_a_closed_sidebar_on_the_hooks_tab(tmp_path: Path) -> None: + bus = EventBus() + app = _make_app(tmp_path, bus, HookRunLog()) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + sidebar = main.query_one(SidebarPanel) + sidebar.focus_tab("tab-tasks") + if sidebar.is_visible: + sidebar.toggle() + await wait_for(lambda: not sidebar.is_visible, pilot=pilot, description="closed") + indicator = main.query_one(HookIndicator) + await wait_for(lambda: indicator.region.width > 2, pilot=pilot, description="indicator laid out") + await click_when_settled(pilot, indicator, offset=(indicator.region.width - 3, 0)) + tabs = sidebar.query_one(TabbedContent) + await wait_for( + lambda: sidebar.is_visible and tabs.active == SIDEBAR_HOOKS_TAB, + pilot=pilot, + description="open on Hooks", + ) + + +async def test_clicking_the_indicator_while_it_shows_a_run_opens_that_runs_details(tmp_path: Path) -> None: + release = tmp_path / "release" + manager = _manager( + tmp_path, + _hook("quick", HookEvent.BEFORE_TOOL_CALL, [sys.executable, "-c", "pass"]), + _hook("slow-gate", HookEvent.BEFORE_TOOL_CALL, _held(release)), + ) + bus, log = EventBus(), HookRunLog() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + sidebar = main.query_one(SidebarPanel) + sidebar.focus_tab("tab-tasks") + if sidebar.is_visible: + sidebar.toggle() + await wait_for(lambda: not sidebar.is_visible, pilot=pilot, description="closed") + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload())) + try: + indicator = main.query_one(HookIndicator) + # The indicator names a waited-on run once it has lasted a second. + await wait_for( + lambda: "slow-gate" in str(indicator.render()) or fire.done(), + timeout=ENGINE_TURN_TIMEOUT, + pilot=pilot, + description="the indicator shows the running hook", + ) + assert not fire.done() + await click_when_settled(pilot, indicator, offset=(indicator.region.width - 3, 0)) + await wait_for( + lambda: isinstance(app.screen, HookRunModal) and app.screen.is_mounted, + pilot=pilot, + description="that run's details open", + ) + modal = app.screen + assert isinstance(modal, HookRunModal) + assert str(modal.query_one("#hook-run-container").border_title) == "Hook #2 · slow-gate" + # Still running: it opens on its input. + assert modal.query_one(TabbedContent).active == "hook-run-input" + # Behind them, the list they belong to: closing the details lands on it. + assert sidebar.is_visible + assert sidebar.query_one(TabbedContent).active == SIDEBAR_HOOKS_TAB + finally: + release.touch() + await asyncio.wait_for(fire, ENGINE_TURN_TIMEOUT) + await manager.drain_session() + + +async def test_workflow_mode_hides_the_hooks_tab() -> None: + from textual.app import App, ComposeResult + + class _SidebarApp(App): + def compose(self) -> ComposeResult: + yield SidebarPanel() + + async with _SidebarApp().run_test(size=(80, 30)) as pilot: + sidebar = pilot.app.query_one(SidebarPanel) + tabs = sidebar.query_one(TabbedContent) + sidebar.focus_tab(SIDEBAR_HOOKS_TAB) + await wait_for(lambda: tabs.active == SIDEBAR_HOOKS_TAB, pilot=pilot, description="on Hooks") + sidebar.set_workflow_mode(True) + await wait_for(lambda: tabs.active != SIDEBAR_HOOKS_TAB, pilot=pilot, description="moved off Hooks") + assert not tabs.get_tab(SIDEBAR_HOOKS_TAB).display + sidebar.focus_tab(SIDEBAR_HOOKS_TAB) + assert tabs.active != SIDEBAR_HOOKS_TAB + sidebar.set_workflow_mode(False) + await wait_for(lambda: tabs.get_tab(SIDEBAR_HOOKS_TAB).display, pilot=pilot, description="back") + + +# --- Performance (section 6) ---------------------------------------------------- + + +class _FixedLog: + """The run log's read side over fixed records, counting reads.""" + + def __init__(self, records: tuple[HookRunRecord, ...]) -> None: + self.rows = records + self.reads = 0 + self.dropped = 0 + + def records(self) -> tuple[HookRunRecord, ...]: + self.reads += 1 + return self.rows + + def record(self, run_id: str) -> HookRunRecord | None: + return next((r for r in self.rows if r.run_id == run_id), None) + + def detail(self, run_id: str) -> HookRunDetail | None: + return None + + +def _record(run_id: str, *, state: str = "ended", age: float = 1.0) -> HookRunRecord: + now = time.monotonic() + ended = state == "ended" + return HookRunRecord( + run_id=run_id, + session_id=_SESSION, + hook_id=f"hook-{run_id}", + hook_event="after_tool_call", + execution_mode="blocking", + detach=False, + tool_name="write_file", + sub_agent="", + retry=False, + registered_at=now - age - 0.1, + started_at=now - age - 0.1, + ended_at=now - age if ended else None, + registered_wall=time.time() - age, + state=state, # type: ignore[arg-type] + outcome=HookOutcome.SUCCESS if ended else None, + exit_code=0 if ended else None, + effect="added_context" if ended else None, + ) + + +async def _populate(pilot: Pilot, main: MainScreen) -> None: + panel = main.query_one(ChatPanel) + transcript = [] + for index in range(24): + transcript.extend( + ( + UserMessageWidget(f"Question {index}\nwith a second line"), + AgentMessage(f"## Answer {index}\n\nA paragraph with `code` and **emphasis**."), + ) + ) + await panel.mount(*transcript) + await wait_for( + lambda: len(panel.walk_children()) >= 96 and all(md.source for md in panel.query(VirtualizedMarkdown)), + pilot=pilot, + description="populated transcript", + ) + + +async def test_a_full_list_updating_and_ticking_on_a_long_transcript_does_not_restyle_relayout_or_recompose( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ended = tuple(_record(f"e{n}", age=1.0 + n) for n in range(100)) + log = _FixedLog((_record("live", state="running", age=0.5), *ended)) + bus = EventBus() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _populate(pilot, main) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: len(_rows(main)) > 5, pilot=pilot, description="rows painted") + await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="settled underlay") + seen: dict[str, list[object]] = {"styles": [], "layout": [], "footer": []} + monkeypatch.setattr(main, "update_node_styles", lambda animate=True: seen["styles"].append(animate)) + monkeypatch.setattr(main, "_refresh_layout", lambda *_a, **_k: seen["layout"].append(None)) + + async def footer_recompose() -> None: + seen["footer"].append(None) + + monkeypatch.setattr(main.query_one(ChrysFooter), "recompose", footer_recompose) + first = _rows(main)[0] + # The timer ticks the live row. + await wait_for(lambda: _rows(main)[0] != first, timeout=4.0, pilot=pilot, description="a tick") + # Changes land with the row count kept (a count change may lay the list out once): one more + # ended run, then the live one ends. + log.rows = (log.rows[0], _record("new", age=0.2), *ended[:99]) + await bus.publish(HookRunsChanged(session_id=_SESSION), raise_handler_errors=True) + await wait_for(lambda: any("hook-new" in row for row in _rows(main)), pilot=pilot, description="new row") + log.rows = (_record("live", age=0.1), _record("new", age=0.2), *ended[:99]) + await bus.publish(HookRunsChanged(session_id=_SESSION), raise_handler_errors=True) + await wait_for(lambda: _rows(main)[0].startswith("✓"), pilot=pilot, description="live ended") + await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="settled after") + assert seen == {"styles": [], "layout": [], "footer": []} + + +async def test_a_hidden_tab_neither_reads_nor_renders_on_a_change(tmp_path: Path) -> None: + log = _FixedLog((_record("live", state="running"), _record("e1"))) + bus = EventBus() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + hooks = await _show_hooks_tab(pilot, main) + await wait_for(lambda: len(_rows(main)) == 2, pilot=pilot, description="rows") + main.query_one(SidebarPanel).focus_tab("tab-tasks") + await wait_for(lambda: not hooks.is_on_screen, pilot=pilot, description="Hooks tab hidden") + reads = log.reads + rows = main.query_one("#hook-runs-rows", Static) + painted = rows.content + log.rows = (_record("e2"), _record("e1")) + for _ in range(3): + await bus.publish(HookRunsChanged(session_id=_SESSION), raise_handler_errors=True) + assert not await wait_until(lambda: log.reads > reads or rows.content is not painted, timeout=1.5, pilot=pilot) + main.query_one(SidebarPanel).focus_tab(SIDEBAR_HOOKS_TAB) + await wait_for( + lambda: len(_rows(main)) == 2 and _rows(main)[0].startswith("✓"), pilot=pilot, description="reread" + ) + assert log.reads == reads + 1 + + +# --- Under a modal (section 6) -------------------------------------------------- + + +_ELAPSED_RUNNING = re.compile(r"(\d+)s\s+running$") + + +def _running_seconds(main: MainScreen) -> int | None: + rows = _rows(main) + match = _ELAPSED_RUNNING.search(rows[0]) if rows else None + return int(match.group(1)) if match else None + + +async def _open_modal(pilot: Pilot, app: ChrysApp, main: MainScreen) -> None: + main.action_runtime_details() + await wait_for( + lambda: app.screen is not main and isinstance(app.screen, ModalScreen) and app.screen.is_mounted, + pilot=pilot, + description="a modal over MainScreen", + ) + + +async def _close_modal(pilot: Pilot, app: ChrysApp, main: MainScreen) -> None: + await pilot.press("escape") + await wait_for(lambda: app.screen is main, pilot=pilot, description="back on MainScreen") + + +async def test_the_running_time_stops_under_a_modal_and_catches_up_once_it_closes(tmp_path: Path) -> None: + live = _record("live", state="running", age=5.0) + assert live.started_at is not None + log = _FixedLog((live, _record("e1"))) + app = _make_app(tmp_path, EventBus(), log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: _running_seconds(main) is not None, pilot=pilot, description="the running row") + shown = _running_seconds(main) + await wait_for(lambda: _running_seconds(main) != shown, timeout=3.0, pilot=pilot, description="a tick") + + await _open_modal(pilot, app, main) + covered = _running_seconds(main) + # Nothing sends the covered list a Hide: its own first tick parks the timer. + assert not await wait_until(lambda: _running_seconds(main) != covered, timeout=2.5, pilot=pilot), ( + "the running time kept counting under the modal" + ) + + await _close_modal(pilot, app, main) + # At once, not on the next tick: the row shows the time the run has really been running. + await wait_for( + lambda: ( + (seconds := _running_seconds(main)) is not None + and abs(seconds - int(time.monotonic() - live.started_at)) <= 1 + and seconds > (covered or 0) + 1 + ), + timeout=0.5, + pilot=pilot, + description="caught up", + ) + caught_up = _running_seconds(main) + await wait_for( + lambda: (_running_seconds(main) or 0) > (caught_up or 0), + timeout=3.0, + pilot=pilot, + description="ticking again", + ) + + +async def test_a_change_under_a_modal_is_read_once_it_closes(tmp_path: Path) -> None: + log = _FixedLog((_record("live", state="running", age=2.0), _record("e1"))) + bus = EventBus() + app = _make_app(tmp_path, bus, log) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: len(_rows(main)) == 2, pilot=pilot, description="rows") + await _open_modal(pilot, app, main) + rows = main.query_one("#hook-runs-rows", Static) + reads = log.reads + painted = rows.content + # The live run ends while the modal covers the list. + log.rows = (_record("live", age=0.1), _record("e1")) + for _ in range(3): + await bus.publish(HookRunsChanged(session_id=_SESSION), raise_handler_errors=True) + assert not await wait_until(lambda: log.reads > reads or rows.content is not painted, timeout=1.5, pilot=pilot) + await _close_modal(pilot, app, main) + await wait_for(lambda: _rows(main)[0].startswith("✓"), pilot=pilot, description="the ended run shown") + assert log.reads == reads + 1 + + +# --- Where the list stands ------------------------------------------------------ + + +class _DetailedLog(_FixedLog): + def detail(self, run_id: str) -> HookRunDetail | None: + record = self.record(run_id) + if record is None: + return None + return HookRunDetail( + record=record, + tool_call_id="", + timeout_seconds=30.0, + on_error="warn", + delivery="", + source="global", + source_path="", + run_type="command", + run_argv=("true",), + run_cwd="", + run_env_names=(), + log_path="", + shared_log=False, + io=None, + applied=None, + ) + + +def _numbered(count: int) -> tuple[HookRunRecord, ...]: + return tuple(dataclasses.replace(_record(f"r{number}"), number=number) for number in range(1, count + 1)) + + +def _row_background(main: MainScreen, row: int) -> object: + rows = main.query_one("#hook-runs-rows", Static) + (strip,) = rows.render_lines(Region(0, row, rows.size.width, 1)) + return next(iter(strip)).style.bgcolor if strip.cell_length else None + + +async def test_the_hooks_tab_first_shown_stands_at_the_newest_run(tmp_path: Path) -> None: + app = _make_app(tmp_path, EventBus(), _FixedLog(_numbered(60))) + async with app.run_test(size=(160, 30)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + box = main.query_one("#hook-runs-box") + await wait_for( + lambda: box.max_scroll_y > 0 and box.scroll_y == box.max_scroll_y, pilot=pilot, description="at the end" + ) + + +async def test_the_indicator_scrolls_the_list_to_the_run_it_opens_and_marks_its_row(tmp_path: Path) -> None: + app = _make_app(tmp_path, EventBus(), _DetailedLog(_numbered(60))) + async with app.run_test(size=(160, 30)) as pilot: + main = await _ready(pilot) + sidebar = main.query_one(SidebarPanel) + sidebar.focus_tab("tab-tasks") + if sidebar.is_visible: + sidebar.toggle() + await wait_for(lambda: not sidebar.is_visible, pilot=pilot, description="closed") + main.post_message(HookIndicator.Clicked("r20")) + await wait_for( + lambda: isinstance(app.screen, HookRunModal) and app.screen.is_mounted, + pilot=pilot, + description="that run's details open", + ) + box = main.query_one("#hook-runs-box") + row = 19 + + def shows_row() -> bool: + return box.size.height > 0 and 0 < box.scroll_y <= row < box.scroll_y + box.size.height + + await wait_for(shows_row, pilot=pilot, description="row #20 in view, not at either end") + assert box.scroll_y < box.max_scroll_y + assert _row_background(main, row) != _row_background(main, row - 1) == _row_background(main, row + 1) + + +# --- Only the TUI keeps a log (5.1) --------------------------------------------- + + +def test_the_tui_entry_point_assembles_an_engine_that_records_hook_runs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fake_platform +) -> None: + import os + + from chrys.app.tui import app as tui_app + from chrys.app.tui.screens import logs as logs_mod + from chrys.foundation.config.settings import Settings + from chrys.foundation.config.settings_store import LoadedSettings, SettingsHandle + from chrys.orchestration import startup as startup_mod + from chrys.orchestration.startup import RuntimeBootstrap + + captured: dict[str, object] = {} + + class _App: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self._return_code = 0 + + def run(self) -> None: + return + + class _Registry: + def load_all(self) -> None: + return + + def get(self, _profile_id: str) -> None: + return None + + def list_profiles(self) -> list[object]: + return [] + + class _Engine: + def __init__(self, *_args: object, **kwargs: object) -> None: + captured.update(kwargs) + loaded = kwargs["loaded_settings"] + assert isinstance(loaded, LoadedSettings) + self._handle = SettingsHandle(loaded) + + @property + def settings_handle(self) -> SettingsHandle: + return self._handle + + monkeypatch.setattr(sys, "argv", ["icode"]) + monkeypatch.setattr("chrys.foundation.platform.get_platform", lambda: fake_platform(config_dir=tmp_path)) + monkeypatch.setattr(startup_mod, "configure_utf8_stdio", lambda: None) + monkeypatch.setattr( + startup_mod, + "bootstrap_runtime", + lambda **_kwargs: RuntimeBootstrap(loaded=LoadedSettings(settings=Settings(), provenance={})), + ) + monkeypatch.setattr(logs_mod, "install_log_handler", lambda: None) + monkeypatch.setattr(tui_app, "configure_debug_logging", lambda _path: object()) + monkeypatch.setattr(tui_app, "stop_debug_logging", lambda _runtime: None) + monkeypatch.setattr(tui_app, "_restore_terminal_after_run", lambda _app: None) + monkeypatch.setattr(tui_app, "_reset_terminal_title_after_run", lambda: None) + monkeypatch.setattr(tui_app, "EventBus", object) + monkeypatch.setattr(tui_app, "AgentProfileRegistry", _Registry) + monkeypatch.setattr(tui_app, "ModelProfileRegistry", _Registry) + # ``main`` imports these inside, from their own modules. + monkeypatch.setattr("chrys.service.state.store.JsonFileStateStore", object) + monkeypatch.setattr("chrys.orchestration.engine.assembly.assemble_agent_engine", _Engine) + original_cwd = Path.cwd() + try: + tui_app.main(app_cls=_App) # type: ignore[arg-type] + finally: + os.chdir(original_cwd) + assert captured["record_hook_runs"] is True diff --git a/tests/app/tui/screens/test_background_task_modal.py b/tests/app/tui/screens/test_background_task_modal.py index 490b47a3a..b677d1d65 100644 --- a/tests/app/tui/screens/test_background_task_modal.py +++ b/tests/app/tui/screens/test_background_task_modal.py @@ -18,7 +18,7 @@ from chrys.app.tui.screens.dialogs.background_task import ( ROLLBACK_NOTE, BackgroundTaskModal, - _LogTail, + LogTail, confirm_stopping, ) from chrys.app.tui.screens.dialogs.confirm import ConfirmDialog @@ -48,7 +48,7 @@ def _task(log_path: Path, **fields: object) -> BackgroundTaskView: def test_log_tail_reads_only_what_was_added(tmp_path: Path) -> None: log = tmp_path / "x.log" log.write_bytes(b"one\ntwo\n") - tail = _LogTail(str(log)) + tail = LogTail(str(log)) assert tail.read() == ("one\ntwo\n", False) assert tail.read() == ("", False) with log.open("ab") as file: @@ -59,7 +59,7 @@ def test_log_tail_reads_only_what_was_added(tmp_path: Path) -> None: def test_log_tail_starts_over_when_the_log_shrinks(tmp_path: Path) -> None: log = tmp_path / "x.log" log.write_bytes(b"one\ntwo\n") - tail = _LogTail(str(log)) + tail = LogTail(str(log)) tail.read() log.write_bytes(b"new\n") assert tail.read() == ("new\n", True) @@ -68,7 +68,7 @@ def test_log_tail_starts_over_when_the_log_shrinks(tmp_path: Path) -> None: def test_log_tail_starts_over_when_the_log_rotated_to_the_same_size(tmp_path: Path) -> None: log = tmp_path / "x.log" log.write_bytes(b"old\n") - tail = _LogTail(str(log)) + tail = LogTail(str(log)) tail.read() # Between two reads the log rotated and its new file grew as large as the old one. rotated = tmp_path / "new.log" @@ -81,7 +81,7 @@ def test_log_tail_skips_ahead_from_a_whole_line(tmp_path: Path, monkeypatch: pyt monkeypatch.setattr(background_task, "_TAIL_BYTES", 12) log = tmp_path / "x.log" log.write_bytes(b"first line\nsecond\nthird\n") - tail = _LogTail(str(log)) + tail = LogTail(str(log)) # The last 12 bytes start inside "second": that partial line is dropped. assert tail.read() == ("third\n", True) with log.open("ab") as file: @@ -91,7 +91,7 @@ def test_log_tail_skips_ahead_from_a_whole_line(tmp_path: Path, monkeypatch: pyt def test_log_tail_joins_a_character_split_between_reads(tmp_path: Path) -> None: log = tmp_path / "x.log" - tail = _LogTail(str(log)) + tail = LogTail(str(log)) chunks = [b"done ", "中文".encode()[:4], "中文".encode()[4:] + b"\n"] log.write_bytes(b"") read = "" diff --git a/tests/app/tui/screens/test_hook_run_modal.py b/tests/app/tui/screens/test_hook_run_modal.py new file mode 100644 index 000000000..64577a31d --- /dev/null +++ b/tests/app/tui/screens/test_hook_run_modal.py @@ -0,0 +1,531 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The hook run details dialog: its Input and Output tabs, polling, log tail and click-to-copy sections (plan section 4).""" + +from __future__ import annotations + +import dataclasses +import json +import time +from pathlib import Path + +import pytest +from rich.text import Text +from textual.app import App +from textual.content import Content +from textual.widgets import Button, Log, Static, TabbedContent + +from chrys.app.tui.screens.dialogs.hook_run import HookRunModal, _CopySection +from chrys.foundation.models.hook_runs import HookRunApplied, HookRunDetail, HookRunIO, HookRunRecord +from chrys.service.trajectory.hooks import HookOutcome +from tests.support.tui_helpers import click_when_settled, rich_plain +from tests.support.waiting import wait_for, wait_until + +_PAYLOAD = '{"event":"before_tool_call","tool":{"name":"zsh","args":{"command":"rm -rf build"}}}' + + +def _record(**fields: object) -> HookRunRecord: + now = time.monotonic() + base: dict[str, object] = { + "run_id": "run-1", + "session_id": "session-abc", + "hook_id": "guard", + "hook_event": "before_tool_call", + "execution_mode": "blocking", + "detach": False, + "tool_name": "zsh", + "sub_agent": "", + "retry": False, + "registered_at": now - 3.0, + "started_at": now - 3.0, + "ended_at": now - 2.5, + "registered_wall": time.time() - 3.0, + "state": "ended", + "outcome": HookOutcome.SUCCESS, + "exit_code": 0, + "effect": None, + "number": 7, + } + base.update(fields) + return HookRunRecord(**base) # type: ignore[arg-type] + + +def _io(**fields: object) -> HookRunIO: + base: dict[str, object] = { + "payload": _PAYLOAD, + "payload_size": len(_PAYLOAD), + "result_file": "missing", + "decision": "", + "stdout": "", + "stderr": "", + "stdout_dropped": 0, + "stderr_dropped": 0, + } + base.update(fields) + return HookRunIO(**base) # type: ignore[arg-type] + + +def _detail(record: HookRunRecord | None = None, **fields: object) -> HookRunDetail: + record = record or _record() + base: dict[str, object] = { + "record": record, + "tool_call_id": "call-42", + "timeout_seconds": 30.0, + "on_error": "warn", + "delivery": "best_effort", + "source": "global", + "source_path": "/config/hooks/hooks.yaml", + "run_type": "command", + "run_argv": ("python3", "guard.py", "--strict"), + "run_cwd": "/repo", + "run_env_names": ("API_TOKEN",), + "log_path": "", + "shared_log": False, + "io": _io(), + "applied": HookRunApplied() if record.state == "ended" else None, + } + base.update(fields) + return HookRunDetail(**base) # type: ignore[arg-type] + + +class _ModalApp(App): + def __init__(self, detail: HookRunDetail) -> None: + super().__init__() + self.current: HookRunDetail | None = detail + self.asked: list[str] = [] + self.modal = HookRunModal(detail, latest=self._latest) + + def _latest(self, run_id: str) -> HookRunDetail | None: + self.asked.append(run_id) + return self.current + + def on_mount(self) -> None: + self.push_screen(self.modal) + + +def _plain(content: object) -> str: + if isinstance(content, str): + return content + if isinstance(content, Content): + return content.plain + if isinstance(content, Text): + return content.plain + return rich_plain(content) # type: ignore[arg-type] + + +def _pane_text(modal: HookRunModal, pane: str) -> str: + parts: list[str] = [] + for widget in modal.query_one(f"#{pane}").query("*"): + if isinstance(widget, _CopySection): + parts.append(_plain(widget.border_title)) + elif isinstance(widget, Static): + parts.append(_plain(widget.content)) + elif isinstance(widget, Log): + parts.append("\n".join(widget.lines)) + return "\n".join(parts) + + +def _active(modal: HookRunModal) -> str: + return modal.query_one(TabbedContent).active + + +def _subtitle(modal: HookRunModal) -> str: + return str(modal.query_one("#hook-run-container").border_subtitle or "") + + +def _title(modal: HookRunModal) -> str: + return str(modal.query_one("#hook-run-container").border_title or "") + + +async def _open(pilot, app: _ModalApp) -> HookRunModal: + await wait_for(lambda: app.screen is app.modal and app.modal.is_mounted, pilot=pilot, description="modal shown") + return app.modal + + +# --- Title, tab choice, Input --------------------------------------------------- + + +async def test_an_ended_run_opens_on_output_with_its_title_and_state() -> None: + record = _record(outcome=HookOutcome.FAILED, exit_code=3) + app = _ModalApp(_detail(record, io=_io(stderr="boom"), applied=HookRunApplied(not_applied="failed"))) + async with app.run_test(size=(120, 40)) as pilot: + modal = await _open(pilot, app) + assert _title(modal) == "Hook #7 · guard" + assert _active(modal) == "hook-run-output" + subtitle = _subtitle(modal) + assert "exit 3" in subtitle + assert "500ms" in subtitle or "0.5s" in subtitle, subtitle + + +@pytest.mark.parametrize("state", ["running", "queued"]) +async def test_a_live_run_opens_on_input_and_says_its_output_comes_later(state: str) -> None: + now = time.monotonic() + started = now - 1.0 if state == "running" else None + record = _record(state=state, started_at=started, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(120, 40)) as pilot: + modal = await _open(pilot, app) + assert _active(modal) == "hook-run-input" + assert "Running… output shows when the hook ends" in _pane_text(modal, "hook-run-output") + assert state in _subtitle(modal) + + +async def test_the_input_tab_shows_the_configured_run_and_the_payload() -> None: + record = _record(registered_at=time.monotonic() - 10.0, started_at=time.monotonic() - 4.0) + record = dataclasses.replace(record, registered_wall=time.mktime((2026, 10, 21, 9, 30, 0, 0, 0, -1))) + app = _ModalApp(_detail(record, delivery="durable", on_error="block", timeout_seconds=12.0)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-input") + for expected in ( + "Event", + "before_tool_call", + "Mode", + "blocking", + "12", + "block", + "durable", + "Session", + "session-abc", + "Triggered by", + "Main agent", + "zsh", + "call-42", + "Started", + "09:30:06", + "Queued", + "Source", + "global", + "/config/hooks/hooks.yaml", + "Run", + "guard.py", + "--strict", + "/repo", + "API_TOKEN", + "Payload", + ): + assert expected in text, (expected, text) + assert "Retry of an earlier run" not in text + # Pretty-printed: one key per line. + assert '"command": "rm -rf build"' in text + assert "\n" in text.split("Payload", 1)[1] + + +async def test_the_input_tab_names_a_sub_agent_and_a_retry_and_hides_start_and_queue_when_unknown() -> None: + now = time.monotonic() + record = _record(sub_agent="Explore", retry=True, registered_at=now - 2.0, started_at=None, state="queued") + record = dataclasses.replace(record, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-input") + assert "Explore" in text + assert "Main agent" not in text + assert "Retry of an earlier run" in text + assert "Started" not in text, "still queued: no start time" + + +async def test_a_run_that_never_queued_shows_no_queue_time() -> None: + record = _record() + app = _ModalApp(_detail(record)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-input") + assert "Started" in text + assert "Queued" not in text + + +async def test_a_cut_payload_says_so() -> None: + io = _io(payload=_PAYLOAD[:40], payload_size=200_000) + app = _ModalApp(_detail(io=io)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + assert "Payload cut at 64 KB of" in _pane_text(modal, "hook-run-input") + + +# --- Output --------------------------------------------------------------------- + + +async def test_the_output_tab_shows_the_decision_what_was_applied_and_both_streams() -> None: + decision = {"action": "block", "reason": "rm denied"} + io = _io( + result_file="ok", + decision=json.dumps(decision), + stdout="checked 3 paths", + stderr="warning: slow disk", + stdout_dropped=1234, + ) + record = _record(effect="blocked") + app = _ModalApp(_detail(record, io=io, applied=HookRunApplied(block_reason="hook 'guard': rm denied"))) + async with app.run_test(size=(140, 60)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-output") + for expected in ( + "Result", + "blocked", + "Decision", + '"reason": "rm denied"', + "Applied", + "rm denied", + "stdout", + "checked 3 paths", + "1234 bytes from the middle were not kept", + "stderr", + "warning: slow disk", + ): + assert expected in text, (expected, text) + assert "No result file" not in text + + +@pytest.mark.parametrize( + ("status", "expected"), + [("missing", "No result file"), ("invalid", "The result file was invalid and was ignored")], +) +async def test_no_or_an_invalid_result_file_is_named(status: str, expected: str) -> None: + app = _ModalApp(_detail(io=_io(result_file=status))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + assert expected in _pane_text(modal, "hook-run-output") + + +@pytest.mark.parametrize( + ("applied", "expected"), + [ + (HookRunApplied(args='{"path": "/safe", "mode": "r"}'), '"path": "/safe"'), + (HookRunApplied(reminder="remember the style guide"), "remember the style guide"), + (HookRunApplied(extra_context="ruff found 2 issues"), "ruff found 2 issues"), + (HookRunApplied(not_applied="nonblocking"), "Async and fire_and_forget hooks can't change the action"), + (HookRunApplied(not_applied="unsupported"), "before_tool_call doesn't support this action"), + (HookRunApplied(not_applied="failed"), "The hook failed, so its result file was ignored"), + ( + HookRunApplied(not_applied="failed_blocked"), + "The hook failed and on_error is block, so the action was blocked", + ), + ], + ids=["args", "reminder", "context", "nonblocking", "unsupported", "failed", "failed-blocked"], +) +async def test_applied_names_what_was_taken_or_why_not(applied: HookRunApplied, expected: str) -> None: + effect = ( + "modified_args" + if applied.args + else "added_reminder" + if applied.reminder + else "added_context" + if applied.extra_context + else "blocked" + if applied.not_applied == "failed_blocked" + else None + ) + outcome = HookOutcome.FAILED if applied.not_applied in ("failed", "failed_blocked") else HookOutcome.SUCCESS + record = _record(effect=effect, outcome=outcome, exit_code=1 if outcome == HookOutcome.FAILED else 0) + app = _ModalApp(_detail(record, applied=applied, io=_io(result_file="ok", decision="{}"))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + assert expected in _pane_text(modal, "hook-run-output") + + +async def test_a_timed_out_run_shows_its_partial_output() -> None: + record = _record(outcome=HookOutcome.TIMED_OUT, exit_code=None) + app = _ModalApp(_detail(record, io=_io(stdout="half done"), applied=HookRunApplied(not_applied="failed"))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-output") + assert "timed out" in text + assert "half done" in text + + +async def test_a_run_whose_details_are_gone_says_so_and_keeps_the_configured_fields() -> None: + app = _ModalApp(_detail(io=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + gone = "Details are no longer kept for this run" + assert gone in _pane_text(modal, "hook-run-output") + text = _pane_text(modal, "hook-run-input") + assert gone in text + assert "guard.py" in text and "before_tool_call" in text + assert "rm -rf build" not in text + + +# --- Polling -------------------------------------------------------------------- + + +async def test_a_running_run_fills_its_output_once_it_ends_without_switching_tab() -> None: + now = time.monotonic() + record = _record(state="running", started_at=now - 1.0, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + await wait_for(lambda: app.asked, timeout=3.0, pilot=pilot, description="latest() polled") + assert app.asked[0] == "run-1" + ended = dataclasses.replace(record, state="ended", ended_at=time.monotonic(), outcome=HookOutcome.SUCCESS) + app.current = _detail(ended, io=_io(stdout="all clean"), applied=HookRunApplied()) + await wait_for( + lambda: "all clean" in _pane_text(modal, "hook-run-output"), + timeout=4.0, + pilot=pilot, + description="output filled", + ) + assert "Running…" not in _pane_text(modal, "hook-run-output") + assert _active(modal) == "hook-run-input", "the user may be reading the payload" + polled = len(app.asked) + assert not await wait_until(lambda: len(app.asked) > polled, timeout=2.5, pilot=pilot), "polling stops" + + +# --- Detached runs -------------------------------------------------------------- + + +async def test_a_detached_run_tails_its_log_and_notes_a_shared_one(tmp_path: Path) -> None: + log = tmp_path / "handed-off.20261021T093000Z.log" + log.write_text("worker started\n", encoding="utf-8") + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, log_path=str(log), shared_log=True, io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + await wait_for( + lambda: "worker started" in _pane_text(modal, "hook-run-output"), + timeout=4.0, + pilot=pilot, + description="log tail", + ) + text = _pane_text(modal, "hook-run-output") + assert "Log" in text and str(log) in text + assert "Another run of this hook started in the same second and writes to this log too" in text + + +# --- Click to copy -------------------------------------------------------------- + + +@pytest.fixture +def copied(monkeypatch: pytest.MonkeyPatch) -> list[str]: + seen: list[str] = [] + monkeypatch.setattr("chrys.app.tui.clipboard.clipboard_copy", seen.append) + return seen + + +def _sections(modal: HookRunModal, pane: str) -> list[_CopySection]: + return list(modal.query_one(f"#{pane}").query(_CopySection)) + + +def _notices(app: App) -> list[str]: + return [str(notification.message) for notification in app._notifications] + + +async def _ended_with_output(pilot) -> HookRunModal: + modal = await _open(pilot, pilot.app) + tabs = modal.query_one(TabbedContent) + await wait_for(lambda: tabs.active == "hook-run-output", pilot=pilot, description="opened on output") + return modal + + +_OUTPUT_IO = {"result_file": "ok", "decision": '{"action":"allow"}', "stdout": "out-text\nline2", "stderr": "err-text"} + + +async def test_the_dialog_has_no_copy_buttons() -> None: + app = _ModalApp(_detail(io=_io(**_OUTPUT_IO))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + assert not modal.query(Button) + + +async def test_a_click_anywhere_in_a_section_copies_that_section_and_says_which(copied: list[str]) -> None: + app = _ModalApp(_detail(io=_io(**_OUTPUT_IO))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + result, decision, _applied, stdout, _stderr = _sections(modal, "hook-run-output") + + # On the body, not the heading: the whole section is the target. + await click_when_settled(pilot, stdout.children[-1]) + await wait_for(lambda: copied == ["out-text\nline2"], pilot=pilot, description="stdout copied") + assert "Copied stdout" in _notices(app) + await click_when_settled(pilot, decision.children[0]) + await wait_for(lambda: copied[1:] == ['{"action":"allow"}'], pilot=pilot, description="decision copied") + await click_when_settled(pilot, result.children[0]) + await wait_for(lambda: len(copied) == 3, pilot=pilot, description="result copied") + assert copied[2].startswith("✓ succeeded") + + modal.query_one(TabbedContent).active = "hook-run-input" + overview, payload = _sections(modal, "hook-run-input") + await click_when_settled(pilot, payload.children[-1]) + await wait_for(lambda: len(copied) == 4, pilot=pilot, description="payload copied") + assert json.loads(copied[3]) == json.loads(_PAYLOAD) + assert "Copied Payload" in _notices(app) + await click_when_settled(pilot, overview.children[0]) + await wait_for(lambda: len(copied) == 5, pilot=pilot, description="overview copied") + assert copied[4].splitlines()[0] == "Event: before_tool_call" + assert "Payload" not in copied[4] + + +async def test_a_click_that_ends_a_text_selection_copies_nothing_more( + copied: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + app = _ModalApp(_detail(io=_io(**_OUTPUT_IO))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + stdout = _sections(modal, "hook-run-output")[3] + with monkeypatch.context() as selecting: + selecting.setattr(type(modal), "get_selected_text", lambda self: "out") + await click_when_settled(pilot, stdout.children[-1]) + assert not await wait_until(lambda: bool(copied), timeout=0.5, pilot=pilot) + await click_when_settled(pilot, stdout.children[-1]) + await wait_for(lambda: copied == ["out-text\nline2"], pilot=pilot, description="copied once not selecting") + + +async def test_the_mouse_over_any_part_of_a_section_lights_the_whole_section() -> None: + app = _ModalApp(_detail(io=_io(**_OUTPUT_IO))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + _, decision, applied, *_ = _sections(modal, "hook-run-output") + await pilot.hover(decision.children[-1]) + await wait_for(lambda: decision.has_class("-hover"), pilot=pilot, description="lit from its body") + await pilot.hover(decision.children[0]) + assert decision.has_class("-hover") + await pilot.hover(applied.children[0]) + await wait_for( + lambda: applied.has_class("-hover") and not decision.has_class("-hover"), + pilot=pilot, + description="the light moves to the next section", + ) + + +async def test_a_detached_run_copies_its_log_path_and_its_log_apart(copied: list[str], tmp_path: Path) -> None: + log = tmp_path / "x.log" + log.write_text("worker started\n", encoding="utf-8") + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, log_path=str(log), shared_log=True, io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + await wait_for( + lambda: "worker started" in _pane_text(modal, "hook-run-output"), pilot=pilot, description="log tail" + ) + *_, path, tail = _sections(modal, "hook-run-output") + await click_when_settled(pilot, path.children[0]) + await wait_for(lambda: copied == [str(log)], pilot=pilot, description="log path copied") + assert "Copied log path" in _notices(app) + await click_when_settled(pilot, tail.children[0]) + await wait_for(lambda: len(copied) == 2, pilot=pilot, description="log copied") + assert copied[1] == "worker started", copied + await pilot.press("c") + await wait_for(lambda: len(copied) == 3, pilot=pilot, description="whole tab copied") + assert f"## Log file\n{log}\n\n## Log\nworker started" in copied[2], copied[2] + + +async def test_c_copies_the_whole_open_tab(copied: list[str]) -> None: + app = _ModalApp(_detail(io=_io(**_OUTPUT_IO))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="output copied") + output = copied[0] + assert output.startswith("## Result\n✓ succeeded") + sections = ["## Decision\n", "## Applied\n", "## stdout\nout-text", "## stderr\nerr-text"] + positions = [output.find(section) for section in sections] + assert -1 not in positions and positions == sorted(positions), output + assert "## Payload" not in output + + modal.query_one(TabbedContent).active = "hook-run-input" + await wait_for(lambda: _active(modal) == "hook-run-input", pilot=pilot, description="input tab") + await pilot.press("c") + await wait_for(lambda: len(copied) == 2, pilot=pilot, description="input copied") + fields, payload = copied[1].split("\n\n## Payload\n") + assert fields.splitlines()[:2] == ["## Overview", "Event: before_tool_call"] + assert json.loads(payload) == json.loads(_PAYLOAD) diff --git a/tests/app/tui/widgets/test_hook_run_list.py b/tests/app/tui/widgets/test_hook_run_list.py new file mode 100644 index 000000000..2c7cd5b85 --- /dev/null +++ b/tests/app/tui/widgets/test_hook_run_list.py @@ -0,0 +1,553 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The hook runs list in the sidebar's Hooks tab (plan section 3, 6).""" + +from __future__ import annotations + +import dataclasses +import re +import time +from collections.abc import Callable + +import pytest +from textual.app import App, ComposeResult +from textual.containers import Vertical, VerticalScroll +from textual.geometry import Region +from textual.widgets import Static + +from chrys.app.tui.widgets.sidebar import hooks as hooks_module +from chrys.app.tui.widgets.sidebar.hooks import HookRunList, run_duration, run_label +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.service.hooks.run_log import HOOK_RUN_LOG_MAX_ENDED +from chrys.service.trajectory.hooks import HookOutcome +from tests.support.tui_helpers import click_when_settled, resize_when_settled +from tests.support.waiting import wait_for, wait_until + +_SPINNER = range(0x2800, 0x2900) + + +def _record( + run_id: str, + *, + hook_id: str | None = None, + state: str = "ended", + outcome: str | None = HookOutcome.SUCCESS, + effect: str | None = None, + exit_code: int | None = 0, + tool_name: str = "zsh", + event: str = "before_tool_call", + sub_agent: str = "", + session_id: str = "s1", + detach: bool = False, + queued_for: float = 0.0, + ran_for: float | None = 0.0405, + ended_ago: float = 1.0, + number: int = 1, +) -> HookRunRecord: + now = time.monotonic() + if state == "queued": + registered, started, ended = now - queued_for, None, None + outcome, exit_code = None, None + elif state == "running": + started = now - (ran_for or 0.0) + registered, ended = started - queued_for, None + outcome, exit_code = None, None + else: + ended = now - ended_ago + started = None if ran_for is None else ended - ran_for + registered = (started if started is not None else ended) - queued_for + return HookRunRecord( + run_id=run_id, + session_id=session_id, + hook_id=hook_id or run_id, + hook_event=event, + execution_mode="blocking", + detach=detach, + tool_name=tool_name, + sub_agent=sub_agent, + retry=False, + registered_at=registered, + started_at=started, + ended_at=ended, + registered_wall=time.time() - (now - registered), + state=state, # type: ignore[arg-type] + outcome=outcome, + exit_code=exit_code, + effect=effect, # type: ignore[arg-type] + number=number, + ) + + +class _Source: + def __init__(self, *records: HookRunRecord, configured: int = 1, dropped: int = 0) -> None: + self.records = tuple(records) + self.configured = configured + self.dropped = dropped + """Ended runs the log dropped past its cap.""" + self.reads = 0 + + def read(self) -> tuple[HookRunRecord, ...]: + self.reads += 1 + return self.records + + +class _ListApp(App): + def __init__(self, source: _Source) -> None: + super().__init__() + self.source = source + self.opened: list[str] = [] + + def compose(self) -> ComposeResult: + with Vertical(id="holder"): + yield HookRunList() + + def on_mount(self) -> None: + hooks = self.query_one(HookRunList) + hooks.bind_source(self.source.read, lambda: self.source.configured, lambda: self.source.dropped) + hooks.runs_changed() + + def on_hook_run_list_opened(self, event: HookRunList.Opened) -> None: + self.opened.append(event.run_id) + + +_NUMBER = re.compile(r"^\s*\d+ ") + + +def _numbered_rows(app: App) -> list[str]: + rows = app.query_one("#hook-runs-rows", Static) + width, height = rows.size + return [strip.text.rstrip() for strip in rows.render_lines(Region(0, 0, width, height)) if strip.text.strip()] + + +def _rows(app: App) -> list[str]: + """The rows from their icon on: the run number before it has a test of its own.""" + return [_NUMBER.sub("", row) for row in _numbered_rows(app)] + + +def _title(app: App) -> str: + return str(app.query_one("#hook-runs-title", Static).render()) + + +def _empty(app: App) -> str: + empty = app.query_one("#hook-runs-empty", Static) + return str(empty.render()) if empty.display else "" + + +async def _shown(pilot, count: int) -> list[str]: + await wait_for(lambda: len(_rows(pilot.app)) == count, pilot=pilot, description=f"{count} rows") + return _rows(pilot.app) + + +# --- Each row: icon, label, duration, outcome ----------------------------------- + + +@pytest.mark.parametrize( + ("fields", "icon", "outcome"), + [ + ({"state": "queued", "queued_for": 2.0}, "◌", "queued"), + ({}, "✓", ""), + ({"effect": "blocked"}, "⊘", "blocked"), + ({"effect": "modified_args"}, "✓", "modified args"), + ({"effect": "added_reminder"}, "✓", "added reminder"), + ({"effect": "added_context"}, "✓", "added context"), + ({"outcome": HookOutcome.FAILED, "exit_code": 1}, "✗", "exit 1"), + ({"outcome": HookOutcome.FAILED, "exit_code": 2, "effect": "blocked"}, "✗", "exit 2 · blocked"), + ({"outcome": HookOutcome.TIMED_OUT, "exit_code": None}, "✗", "timed out"), + ({"outcome": HookOutcome.TIMED_OUT, "exit_code": None, "effect": "blocked"}, "✗", "timed out · blocked"), + ({"outcome": HookOutcome.LAUNCH_ERROR, "exit_code": None}, "✗", "couldn't start"), + ({"outcome": HookOutcome.CANCELLED, "exit_code": None}, "■", "cancelled"), + ({"outcome": HookOutcome.DETACHED, "exit_code": None, "detach": True, "ran_for": None}, "→", "detached"), + ( + {"outcome": HookOutcome.SPAWN_FAILED, "exit_code": None, "detach": True, "ran_for": None}, + "✗", + "couldn't start", + ), + ], + ids=[ + "queued", + "success", + "blocked", + "modified", + "reminder", + "context", + "exit", + "exit-blocked", + "timed-out", + "timed-out-blocked", + "launch-error", + "cancelled", + "handed-off", + "spawn-failed", + ], +) +async def test_a_rows_icon_and_outcome_follow_how_it_ended_and_what_was_applied( + fields: dict[str, object], icon: str, outcome: str +) -> None: + app = _ListApp(_Source(_record("r1", hook_id="check", **fields))) # type: ignore[arg-type] + async with app.run_test(size=(60, 20)) as pilot: + (row,) = await _shown(pilot, 1) + assert row.startswith(f"{icon} check · zsh"), row + if outcome: + assert row.endswith(outcome), row + else: + # Nothing applied: only the duration follows the label. + assert row.split("zsh", 1)[1].split() == ["40ms"], row + + +async def test_a_running_row_shows_a_spinner_and_running() -> None: + app = _ListApp(_Source(_record("r1", hook_id="slow", state="running", ran_for=6.0))) + async with app.run_test(size=(60, 20)) as pilot: + (row,) = await _shown(pilot, 1) + assert ord(row[0]) in _SPINNER, row + assert row.endswith("running") + assert "slow · zsh" in row + + +def test_the_label_names_the_tool_or_the_event_and_the_sub_agent() -> None: + assert run_label(_record("a", hook_id="guard", tool_name="write_file")) == "guard · write_file" + assert run_label(_record("b", hook_id="lint", tool_name="", event="after_turn")) == "lint · after_turn" + assert run_label(_record("c", hook_id="protect", tool_name="edit_file", sub_agent="Explore")) == ( + "protect · edit_file · Explore" + ) + + +async def test_external_text_is_shown_literally_and_sanitized() -> None: + record = _record("r1", hook_id="[bold]gate[/]\x1b[31m", tool_name="tool[/x]", sub_agent="Ex\x07plore") + app = _ListApp(_Source(record)) + async with app.run_test(size=(80, 20)) as pilot: + (row,) = await _shown(pilot, 1) + assert "[bold]gate[/]" in row + assert "tool[/x]" in row + assert "\x1b" not in row and "\x07" not in row + + +def test_durations() -> None: + now = time.monotonic() + queued = _record("q", state="queued", queued_for=2.0) + assert run_duration(queued, queued.registered_at + 2.0) == "2s" + running = _record("r", state="running", ran_for=6.0, queued_for=10.0) + assert running.started_at is not None + assert run_duration(running, running.started_at + 6.0) == "6s", "from the slot, not the registration" + # As everywhere in iCode: whole seconds from a second on. + assert run_duration(_record("e", ran_for=1.2, queued_for=5.0), now) == "1s" + assert run_duration(_record("g", ran_for=6.04), now) == "6s" + assert run_duration(_record("h", ran_for=14.2), now) == "14s" + assert run_duration(_record("f", ran_for=0.0405), now) == "40ms" + never_started = _record("c", outcome=HookOutcome.CANCELLED, exit_code=None, ran_for=None, queued_for=3.0) + assert run_duration(never_started, now) == "" + detached = _record("d", outcome=HookOutcome.DETACHED, exit_code=None, detach=True, ran_for=0.01) + assert run_duration(detached, now) == "" + + +async def test_the_running_duration_grows_while_shown() -> None: + app = _ListApp(_Source(_record("r1", hook_id="slow", state="running", ran_for=3.0))) + async with app.run_test(size=(60, 20)) as pilot: + (first,) = await _shown(pilot, 1) + await wait_for( + lambda: _rows(pilot.app) and _rows(pilot.app)[0] != first, + timeout=4.0, + pilot=pilot, + description="the duration ticked", + ) + + +# --- The list as a whole -------------------------------------------------------- + + +async def test_rows_keep_the_order_active_first_then_ended_and_mix_sessions() -> None: + records = ( + _record("q", hook_id="queued-check", state="queued", queued_for=2.0, session_id="s2"), + _record("r", hook_id="slow-shell", state="running", ran_for=6.0, session_id="s1"), + _record("e1", hook_id="ruff", effect="added_context", ended_ago=1.0, session_id="s2"), + _record("e2", hook_id="protect", effect="modified_args", ended_ago=5.0, session_id="s1"), + ) + app = _ListApp(_Source(*records)) + async with app.run_test(size=(70, 20)) as pilot: + rows = await _shown(pilot, 4) + assert [row.split(" · ")[0][2:] for row in rows] == ["queued-check", "slow-shell", "ruff", "protect"] + assert "s1" not in " ".join(rows) and "s2" not in " ".join(rows), "rows do not name the session" + + +async def test_each_row_starts_with_its_run_number_aligned_as_the_messages_tab_numbers_turns() -> None: + records = ( + _record("r", hook_id="slow", state="running", number=104), + _record("e", hook_id="lint", number=9), + ) + app = _ListApp(_Source(*records)) + async with app.run_test(size=(70, 20)) as pilot: + await _shown(pilot, 2) + first, second = _numbered_rows(pilot.app) + assert first.startswith("104 ") and second.startswith(" 9 "), (first, second) + # Right-aligned: the icons line up in one column. + assert first.index("slow") == second.index("lint") + + +async def test_a_long_result_gives_way_to_the_name_past_a_third_of_the_row() -> None: + record = _record( + "e", hook_id="all-modify-timeout-and-more", outcome=HookOutcome.TIMED_OUT, exit_code=None, effect="blocked" + ) + app = _ListApp(_Source(record)) + async with app.run_test(size=(42, 10)) as pilot: + (row,) = await _shown(pilot, 1) + width = pilot.app.query_one("#hook-runs-rows", Static).size.width + outcome = row[row.index("40ms") + len("40ms") :].strip() + assert outcome.endswith("…") and len(outcome) <= width // 3, row + name = row[2 : row.index("40ms")].strip() + assert len(name) > len(outcome), row + # Wider, the whole result shows: the cap follows the width. + await resize_when_settled(pilot, 90, 10) + await wait_for( + lambda: _rows(pilot.app)[0].endswith("timed out · blocked"), pilot=pilot, description="whole result" + ) + + +def _ended(count: int, *, start: int = 1) -> tuple[HookRunRecord, ...]: + return tuple(_record(f"e{n}", hook_id=f"hook-{n}", number=n) for n in range(start, start + count)) + + +async def test_the_list_opens_on_the_newest_run_and_follows_new_ones_while_at_the_bottom() -> None: + source = _Source(*_ended(30)) + app = _ListApp(source) + async with app.run_test(size=(60, 12)) as pilot: + await _shown(pilot, 30) + box = pilot.app.query_one("#hook-runs-box", VerticalScroll) + await wait_for(lambda: box.max_scroll_y > 0, pilot=pilot, description="more rows than fit") + await wait_for(lambda: box.scroll_y == box.max_scroll_y, pilot=pilot, description="opened at the newest") + + source.records = _ended(32) + pilot.app.query_one(HookRunList).runs_changed() + await _shown(pilot, 32) + await wait_for( + lambda: box.scroll_y == box.max_scroll_y and box.max_scroll_y > 0, + pilot=pilot, + description="followed the new runs to the bottom", + ) + + +async def test_a_list_scrolled_up_to_an_older_run_stays_there_as_new_runs_join() -> None: + source = _Source(*_ended(30)) + app = _ListApp(source) + async with app.run_test(size=(60, 12)) as pilot: + await _shown(pilot, 30) + box = pilot.app.query_one("#hook-runs-box", VerticalScroll) + await wait_for(lambda: box.scroll_y == box.max_scroll_y > 0, pilot=pilot, description="at the newest") + box.scroll_to(y=2, animate=False) + await wait_for(lambda: box.scroll_y == 2, pilot=pilot, description="scrolled up") + + source.records = _ended(32) + pilot.app.query_one(HookRunList).runs_changed() + await _shown(pilot, 32) + assert not await wait_until(lambda: box.scroll_y != 2, timeout=0.5, pilot=pilot) + + +async def test_the_title_counts_running_and_queued_runs() -> None: + source = _Source(_record("e")) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 1) + assert _title(pilot.app) == "Hooks" + source.records = (_record("q", state="queued"), _record("r", state="running"), _record("e")) + pilot.app.query_one(HookRunList).runs_changed() + await wait_for(lambda: _title(pilot.app) == "Hooks (2 active)", pilot=pilot, description="2 active") + source.records = (_record("q", state="queued"), _record("e")) + pilot.app.query_one(HookRunList).runs_changed() + await wait_for(lambda: _title(pilot.app) == "Hooks (1 active)", pilot=pilot, description="1 active") + + +@pytest.mark.parametrize( + ("configured", "expected"), [(0, "No hooks configured"), (2, "No hook has run since iCode started")] +) +async def test_the_empty_states_before_any_run(configured: int, expected: str) -> None: + app = _ListApp(_Source(configured=configured)) + async with app.run_test(size=(60, 20)) as pilot: + await wait_for(lambda: _empty(pilot.app) == expected, pilot=pilot, description=expected) + assert _rows(pilot.app) == [] or not pilot.app.query_one("#hook-runs-box").display + + +async def test_a_list_whose_runs_were_all_cleared_says_so_and_not_that_nothing_ran() -> None: + source = _Source(_record("e1", hook_id="done-1", number=1), _record("e2", hook_id="done-2", number=2)) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 2) + assert _empty(pilot.app) == "" + await click_when_settled(pilot, "#hook-runs-clear") + await wait_for( + lambda: _empty(pilot.app) == "Ended runs cleared", pilot=pilot, description="the cleared empty state" + ) + assert _rows(pilot.app) == [] or not pilot.app.query_one("#hook-runs-box").display + # A new run shows again, and the empty state goes. + source.records = (*source.records, _record("e3", hook_id="done-3", number=3)) + pilot.app.query_one(HookRunList).runs_changed() + (row,) = await _shown(pilot, 1) + assert "done-3" in row + assert _empty(pilot.app) == "" + + +async def test_clear_ended_hides_ended_rows_until_new_runs_end() -> None: + source = _Source(_record("r", hook_id="slow", state="running"), _record("e1", hook_id="done-1")) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 2) + await click_when_settled(pilot, "#hook-runs-clear") + rows = await _shown(pilot, 1) + assert "slow" in rows[0] + hooks = pilot.app.query_one(HookRunList) + # The backend still returns it: hidden on the TUI side only. + source.records = (_record("e2", hook_id="done-2"), *source.records) + hooks.runs_changed() + rows = await _shown(pilot, 2) + assert any("done-2" in row for row in rows) + assert not any("done-1" in row for row in rows) + assert "e1" in hooks._cleared + # Once the log no longer keeps it, the cleared set lets it go. + source.records = tuple(record for record in source.records if record.run_id != "e1") + hooks.runs_changed() + await wait_for(lambda: "e1" not in hooks._cleared, pilot=pilot, description="pruned") + + +def _unlisted(app: App) -> str: + notice = app.query_one("#hook-runs-unlisted", Static) + return str(notice.render()) if notice.display else "" + + +# The number is the log's own cap. +_ONLY_LATEST = f"Only the latest {HOOK_RUN_LOG_MAX_ENDED} runs are kept" + + +async def test_the_notice_shows_only_once_the_log_dropped_runs_past_its_cap() -> None: + # Numbers past 1 alone do not show it: only the log's own count of dropped runs. + source = _Source(*_ended(3, start=40)) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 3) + assert not await wait_until(lambda: _unlisted(pilot.app) != "", timeout=0.3, pilot=pilot) + source.records = _ended(3, start=41) + source.dropped = 1 + pilot.app.query_one(HookRunList).runs_changed() + await wait_for(lambda: _unlisted(pilot.app) == _ONLY_LATEST, pilot=pilot, description="notice") + # Above the oldest row, scrolled with them. + notice = pilot.app.query_one("#hook-runs-unlisted", Static) + rows = pilot.app.query_one("#hook-runs-rows", Static) + assert notice.parent is rows.parent and notice.region.y < rows.region.y + # Not selectable: it does not look like a row to click. + assert notice.allow_select is False + assert notice.text_selection is None + + +async def test_clearing_ended_runs_never_shows_the_notice_and_numbers_carry_on() -> None: + source = _Source( + _record("e1", hook_id="done-1", number=1), + _record("r", hook_id="slow", state="running", number=2), + _record("e3", hook_id="done-3", number=3), + ) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 3) + await click_when_settled(pilot, "#hook-runs-clear") + (left,) = await _shown(pilot, 1) + assert "slow" in left + assert not await wait_until(lambda: _unlisted(pilot.app) != "", timeout=0.3, pilot=pilot) + # A new run after the clear keeps counting: #4, not a renumbered #2. + source.records = (*source.records, _record("e4", hook_id="done-4", number=4)) + pilot.app.query_one(HookRunList).runs_changed() + await _shown(pilot, 2) + numbered = _numbered_rows(pilot.app) + assert [row.split()[0] for row in numbered] == ["2", "4"], numbered + assert _unlisted(pilot.app) == "" + + +async def _scroll_when_selecting_the_20th_row(start: int, *, dropped: int = 0) -> float: + app = _ListApp(_Source(*_ended(60, start=start), dropped=dropped)) + async with app.run_test(size=(60, 12)) as pilot: + await _shown(pilot, 60) + box = pilot.app.query_one("#hook-runs-box") + await wait_for(lambda: box.scroll_y == box.max_scroll_y > 0, pilot=pilot, description="at the end") + pilot.app.query_one(HookRunList).select(f"e{start + 19}") + await wait_for(lambda: box.scroll_y < box.max_scroll_y, pilot=pilot, description="scrolled to it") + return box.scroll_y + + +async def test_a_selected_row_scrolls_into_view_below_the_notice() -> None: + plain = await _scroll_when_selecting_the_20th_row(start=1) + # Once the log dropped runs, the notice takes the first line: the same row sits one line lower. + assert await _scroll_when_selecting_the_20th_row(start=41, dropped=40) == plain + 1 + + +async def test_clicking_a_row_opens_its_run() -> None: + app = _ListApp(_Source(_record("run-a", hook_id="first"), _record("run-b", hook_id="second", ended_ago=3))) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 2) + rows = pilot.app.query_one("#hook-runs-rows", Static) + await click_when_settled(pilot, rows, offset=(4, 1)) + await wait_for(lambda: pilot.app.opened == ["run-b"], pilot=pilot, description="opened") + + +# --- Hidden: no reads, no paints, no timer (section 6) -------------------------- + + +async def test_a_hidden_list_only_marks_a_reread_and_reads_once_when_shown() -> None: + source = _Source(_record("e")) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 1) + holder = pilot.app.query_one("#holder") + holder.display = False + await wait_for(lambda: not pilot.app.query_one(HookRunList).is_on_screen, pilot=pilot, description="hidden") + reads = source.reads + hooks = pilot.app.query_one(HookRunList) + source.records = (_record("e2"), _record("e")) + for _ in range(3): + hooks.runs_changed() + assert not await wait_until(lambda: source.reads > reads, timeout=0.5, pilot=pilot) + holder.display = True + await _shown(pilot, 2) + assert source.reads == reads + 1 + + +async def test_several_changes_in_one_cycle_are_read_once() -> None: + source = _Source(_record("e")) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 1) + reads = source.reads + hooks = pilot.app.query_one(HookRunList) + source.records = (_record("e2"), _record("e")) + for _ in range(5): + hooks.runs_changed() + await _shown(pilot, 2) + assert source.reads == reads + 1 + + +async def test_the_timer_stops_while_hidden_and_when_nothing_is_active(monkeypatch: pytest.MonkeyPatch) -> None: + source = _Source(_record("r", state="running", ran_for=2.0)) + app = _ListApp(source) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 1) + rows = pilot.app.query_one("#hook-runs-rows", Static) + updates: list[object] = [] + original: Callable[..., None] = rows.update + + def counting(*args: object, **kwargs: object) -> None: + updates.append(args) + original(*args, **kwargs) + + monkeypatch.setattr(rows, "update", counting) + await wait_for(lambda: len(updates) >= 1, timeout=3.0, pilot=pilot, description="a tick while shown") + holder = pilot.app.query_one("#holder") + holder.display = False + await wait_for(lambda: not pilot.app.query_one(HookRunList).is_on_screen, pilot=pilot, description="hidden") + seen = len(updates) + assert not await wait_until(lambda: len(updates) > seen, timeout=1.5, pilot=pilot), "ticked while hidden" + holder.display = True + source.records = (dataclasses.replace(_record("r"), run_id="r"),) + pilot.app.query_one(HookRunList).runs_changed() + await wait_for( + lambda: bool(_rows(pilot.app)) and _rows(pilot.app)[0].startswith("✓"), pilot=pilot, description="ended" + ) + seen = len(updates) + assert not await wait_until(lambda: len(updates) > seen, timeout=1.5, pilot=pilot), "ticked with none active" + + +def test_the_module_exposes_its_row_helpers() -> None: + assert hooks_module.run_state(_record("q", state="queued")) == "queued" + assert hooks_module.run_state(_record("r", state="running")) == "running" diff --git a/tests/app/tui/widgets/test_sidebar_panel.py b/tests/app/tui/widgets/test_sidebar_panel.py index d559edee0..c3892eb8f 100644 --- a/tests/app/tui/widgets/test_sidebar_panel.py +++ b/tests/app/tui/widgets/test_sidebar_panel.py @@ -6,8 +6,10 @@ import pytest from rich.cells import cell_len +from textual import events from textual.app import App, ComposeResult from textual.containers import Horizontal +from textual.widget import Widget from textual.widgets import Static, Tab, TabbedContent, Tabs from chrys.app.tui import i18n as tui_i18n @@ -19,7 +21,7 @@ from chrys.foundation.config.settings import Settings from chrys.foundation.models.todos import TodoItem from tests.support.buddies import a_buddy -from tests.support.waiting import wait_for +from tests.support.waiting import wait_for, wait_until class SidebarSizingApp(App): @@ -119,7 +121,7 @@ async def test_sidebar_relabels_tabs_and_current_child_chrome_in_place(monkeypat tabs_widget = sidebar.query_one(TabbedContent).query_one(Tabs) tabs_before = tuple(tabs_widget.query(Tab).results(Tab)) assert sidebar in controller._surfaces - assert _tab_labels(sidebar) == ["Messages", "Tasks", "Context", "Debug", "Buddy"] + assert _tab_labels(sidebar) == ["Messages", "Tasks", "Context", "Hooks", "Debug", "Buddy"] assert str(sidebar.debug_panel.query_one(".debug-title", Static).render()) == "Event Stream" sidebar.todo_state = TodoListState(items=(TodoItem(content="done", status="completed"),)) @@ -142,7 +144,7 @@ async def test_sidebar_relabels_tabs_and_current_child_chrome_in_place(monkeypat assert controller.switch_locale("zh-Hans").status is LocaleSwitchStatus.EFFECTIVE_CHANGED tabs_after = tuple(tabs_widget.query(Tab).results(Tab)) assert tabs_after == tabs_before - assert _tab_labels(sidebar) == ["消息", "任务", "上下文", "调试", "伙伴"] + assert _tab_labels(sidebar) == ["消息", "任务", "上下文", "钩子", "调试", "伙伴"] assert str(sidebar.tasks_panel.query_one(".tasks-label", Static).render()) == "待办 (1/1)" assert str(sidebar.tasks_panel.query_one("#tasks-empty", Static).render()) == "暂无待办" assert str(sidebar.toc_panel.query_one(".toc-empty", Static).render()) == "你的对话将显示在这里" @@ -167,3 +169,61 @@ async def test_sidebar_relabels_tabs_and_current_child_chrome_in_place(monkeypat assert sidebar is not None assert sidebar not in controller._surfaces + + +def _wheel(event_type: type[events.MouseEvent], widget: Widget) -> events.MouseEvent: + """A wheel step over *widget* as the terminal sends it: screen coordinates, no widget named.""" + x, y = widget.region.x + 1, widget.region.y + return event_type( + None, x=x, y=y, delta_x=0, delta_y=1, button=0, shift=False, meta=False, ctrl=False, screen_x=x, screen_y=y + ) + + +async def _scroll(pilot, widget: Widget, event_type: type[events.MouseEvent], strip: Widget, *, steps: int = 1) -> None: + """Send up to *steps* wheel steps one at a time (queued together, the same event merges into one); stop at an end. + + Through the App, as the driver delivers them: the screen finds the widget under the mouse. + """ + for _ in range(steps): + before = strip.scroll_x + pilot.app.post_message(_wheel(event_type, widget)) + if not await wait_until(lambda before=before: strip.scroll_x != before, timeout=0.5, pilot=pilot): + return + + +async def test_the_wheel_over_a_narrow_tab_strip_slides_it_to_the_cut_off_tabs() -> None: + async with SidebarSizingApp().run_test(size=(120, 30)) as pilot: + sidebar = pilot.app.query_one(SidebarPanel) + content = sidebar.query_one(TabbedContent) + tabs = content.query_one(Tabs) + strip = tabs.query_one("#tabs-scroll") + await wait_for(lambda: strip.max_scroll_x > 0, pilot=pilot, description="the tabs overflow the sidebar") + *_, buddy = tabs.query(Tab).results(Tab) + assert buddy.region.right > strip.region.right, "the last tab starts cut off" + await _scroll(pilot, tabs, events.MouseScrollDown, strip, steps=10) + await wait_for(lambda: strip.scroll_x == strip.max_scroll_x, pilot=pilot, description="slid to the end") + await wait_for(lambda: buddy.region.right <= strip.region.right, pilot=pilot, description="the last tab whole") + + await _scroll(pilot, tabs, events.MouseScrollLeft, strip, steps=10) + await wait_for(lambda: strip.scroll_x == 0, pilot=pilot, description="slid back to the start") + + await _scroll(pilot, tabs, events.MouseScrollRight, strip) + await wait_for(lambda: strip.scroll_x > 0, pilot=pilot, description="a sideways wheel slides it too") + await _scroll(pilot, tabs, events.MouseScrollUp, strip) + await wait_for(lambda: strip.scroll_x == 0, pilot=pilot, description="up slides it back") + assert content.active == "tab-toc", "sliding the strip picks no tab" + + +async def test_the_wheel_over_a_tab_pane_leaves_the_tab_strip_alone() -> None: + async with SidebarSizingApp().run_test(size=(120, 30)) as pilot: + sidebar = pilot.app.query_one(SidebarPanel) + tabs = sidebar.query_one(TabbedContent).query_one(Tabs) + strip = tabs.query_one("#tabs-scroll") + await wait_for(lambda: strip.max_scroll_x > 0, pilot=pilot, description="the tabs overflow the sidebar") + pane_child = sidebar.toc_panel + # A positive control first: the same event over the strip does move it. + await _scroll(pilot, tabs, events.MouseScrollRight, strip) + await wait_for(lambda: strip.scroll_x > 0, pilot=pilot, description="the strip moved") + moved = strip.scroll_x + pilot.app.post_message(_wheel(events.MouseScrollRight, pane_child)) + assert not await wait_until(lambda: strip.scroll_x != moved, timeout=0.3, pilot=pilot) diff --git a/tests/app/tui/widgets/test_tasks_panel.py b/tests/app/tui/widgets/test_tasks_panel.py index da512abd5..d3dd7a162 100644 --- a/tests/app/tui/widgets/test_tasks_panel.py +++ b/tests/app/tui/widgets/test_tasks_panel.py @@ -225,6 +225,7 @@ async def test_sidebar_panel_routes_todo_state_to_tasks_tab() -> None: "tab-toc", "tab-tasks", "tab-context", + "tab-hooks", "tab-debug", "tab-buddy", ] diff --git a/tests/architecture/trajectory_wait_manifest.json b/tests/architecture/trajectory_wait_manifest.json index e03964d3d..8df363d37 100644 --- a/tests/architecture/trajectory_wait_manifest.json +++ b/tests/architecture/trajectory_wait_manifest.json @@ -776,6 +776,9 @@ "chrys.orchestration.engine.hook_reporting": { "wire_hook_reporting._publish_executions": [ ["awaitable", "await bus.publish(HookExecutionsChanged(session_id=session_id, executions=source.executions()))", "unresolved"] + ], + "wire_hook_reporting._publish_runs": [ + ["awaitable", "await bus.publish(HookRunsChanged(session_id=session_id))", "unresolved"] ] }, "chrys.orchestration.engine.loader": { @@ -2404,7 +2407,7 @@ }, "chrys.orchestration.sub_agents.tools": { "SubAgentTools._fire_pre_compact_hook": [ - ["awaitable", "await self._hook_manager.fire(HookEvent.PRE_COMPACT, {'session_id': self._session_id, 'profile': self._hook_profile_names.get(tool_name, tool_name), 'cwd': self._workspace_cwd, 'trigger': info.trigger, 'usage_pct': info.usage_pct, 'tokens_before': info.tokens_before, 'sub_agent': {'name': self._display_names.get(tool_name, tool_name), 'tool_name': tool_name}}, target_operation_id=info.trajectory_operation_id)", "unresolved"] + ["awaitable", "await self._hook_manager.fire(HookEvent.PRE_COMPACT, {'session_id': self._session_id, 'profile': self._hook_profile_names.get(tool_name, tool_name), 'cwd': self._workspace_cwd, 'trigger': info.trigger, 'usage_pct': info.usage_pct, 'tokens_before': info.tokens_before, 'sub_agent': {'name': self._display_names.get(tool_name, tool_name), 'tool_name': tool_name}}, target_operation_id=info.trajectory_operation_id, origin=origin)", "unresolved"] ], "SubAgentTools._fire_sub_agent_hook": [ ["awaitable", "await self._hook_manager.fire(event, payload, target_operation_id=target_operation_id)", "unresolved"] @@ -2484,7 +2487,7 @@ ["awaitable", "await prepared.own_or_release(lambda: agent.__aexit__(None, None, None))", "unresolved"] ], "SubAgentTools.register._acquire._make_invocation_runtime._on_pre_compact": [ - ["wrapper", "await self._fire_pre_compact_hook(_tool_name, info)", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._fire_pre_compact_hook"] + ["wrapper", "await self._fire_pre_compact_hook(_tool_name, info, origin=emitter.origin)", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._fire_pre_compact_hook"] ], "SubAgentTools.register._acquire._make_invocation_runtime._publish_compaction_status": [ ["wrapper", "await self._publish_sub_agent_compaction_status(status, _tool_name, emitter)", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._publish_sub_agent_compaction_status"] @@ -2493,7 +2496,7 @@ ["awaitable", "await emitter.publish(InvocationRetryAttempt(scope='compaction', origin=emitter.origin, agent_name=self._display_names.get(_tool_name, _tool_name), message=f'LAST_WORDS compaction: {info.reason}', attempt=info.attempt, max_attempts=info.max_attempts, delay_seconds=int(info.delay_seconds), session_id=self._session_id))", "unresolved"] ], "SubAgentTools.register._acquire._on_pre_compact": [ - ["wrapper", "await self._fire_pre_compact_hook(tool_name, info)", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._fire_pre_compact_hook"] + ["wrapper", "await self._fire_pre_compact_hook(tool_name, info, origin=None)", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._fire_pre_compact_hook"] ], "SubAgentTools.register._acquire._publish_compaction_status": [ ["wrapper", "await self._publish_sub_agent_compaction_status(status, _tool_name, self._emitter_for(invocation_id))", "unresolved", "chrys.orchestration.sub_agents.tools.SubAgentTools._publish_sub_agent_compaction_status"] @@ -3427,11 +3430,11 @@ ["wrapper", "await self._ensure_auto_fulfill_block_subscription()", "unresolved", "chrys.service.agent_middleware.control.approval.ApprovalMiddleware._ensure_auto_fulfill_block_subscription"], ["awaitable", "await self._bus.publish(ApprovalRequest(request_id=request_id, call_id=call_id, tool_name=tool_name, tool_kind=tool_kind, args=parsed_args, session_id=self._session_id, caller_name=self._caller_name, user_message=self._user_message, workspace_roots=list(self._workspace_roots), workspace_cwd=self._workspace_cwd or '', judging=judging, reuse_offer=reuse.candidate.offer() if reuse is not None and reuse.candidate else None))", "unresolved"], ["awaitable", "await approval_trace.requested(tool_name=tool_name, approval_mode=self._approval_mode.value, approval_level=_approval_level(policy=policy_requires_approval, dev_sub_agent_review=dev_sub_agent_review, sensitive=sensitive_shell or sensitive_filesystem_read or sensitive_filesystem_write))", "unresolved"], - ["wrapper", "await await_approval_with_hooks(future=future, judge_task=judge_task, manager=self._hook_manager, session_id=self._session_id, profile_name=self._profile_name, workspace_cwd=self._workspace_cwd or '', caller_name=self._caller_name, request_id=request_id, tool_name=tool_name, tool_kind=tool_kind, call_id=call_id, args=parsed_args, target_operation_id=approval_trace.request_id if approval_trace is not None else None)", "unresolved", "chrys.service.agent_middleware.control.approval_hooks.await_approval_with_hooks"], + ["wrapper", "await await_approval_with_hooks(future=future, judge_task=judge_task, manager=self._hook_manager, session_id=self._session_id, profile_name=self._profile_name, workspace_cwd=self._workspace_cwd or '', caller_name=self._caller_name, request_id=request_id, tool_name=tool_name, tool_kind=tool_kind, call_id=call_id, args=parsed_args, target_operation_id=approval_trace.request_id if approval_trace is not None else None, origin=publisher.origin if publisher is not None else None)", "unresolved", "chrys.service.agent_middleware.control.approval_hooks.await_approval_with_hooks"], ["future", "await judge_task", "unresolved"], ["awaitable", "await approval_trace.resolved(approved=approved, decider=ApprovalDecider.USER if correlation.resolved_by_event else ApprovalDecider.JUDGE, reason_code='user_reason' if reason else '', arguments_modified=bool(modified_args), judge_audit=judge_audit)", "unresolved"], ["wrapper", "await self._publish_abandoned(request_id, ended_by=exc)", "unresolved", "chrys.service.agent_middleware.control.approval.ApprovalMiddleware._publish_abandoned"], - ["wrapper", "await apply_before_tool_hooks(manager=self._hook_manager, context=context, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=context.arguments, workspace_cwd=self._workspace_cwd or '', target_operation_id=tool_operation_id(context.metadata))", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.apply_before_tool_hooks"], + ["wrapper", "await apply_before_tool_hooks(manager=self._hook_manager, context=context, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=context.arguments, workspace_cwd=self._workspace_cwd or '', target_operation_id=tool_operation_id(context.metadata), origin=publisher.origin if publisher is not None else None)", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.apply_before_tool_hooks"], ["awaitable", "await publisher.publish(InvocationToolCallArgsUpdated(origin=publisher.origin, agent_name=self._caller_name, tool_name=tool_name, tool_kind=tool_kind, call_id=call_id, args=final_args, session_id=self._session_id))", "unresolved"], ["thread_pool", "await asyncio.to_thread(binding.targets, context)", "unresolved"], ["wrapper", "await self._remember(binding, reuse.candidate, remember_choice)", "unresolved", "chrys.service.agent_middleware.control.approval.ApprovalMiddleware._remember"], @@ -3442,9 +3445,9 @@ "await_approval_with_hooks": [ ["future", "await future", "unresolved"], ["multi_wait", "await asyncio.wait((future, judge_task), return_when=asyncio.FIRST_COMPLETED)", "unresolved"], - ["awaitable", "await manager.fire(HookEvent.APPROVAL_REQUESTED, payload, target_operation_id=target_operation_id)", "unresolved"], + ["awaitable", "await manager.fire(HookEvent.APPROVAL_REQUESTED, payload, target_operation_id=target_operation_id, origin=origin)", "unresolved"], ["future", "await future", "unresolved"], - ["awaitable", "await manager.fire(HookEvent.APPROVAL_RESOLVED, {**payload, 'approved': result.approved}, target_operation_id=target_operation_id)", "unresolved"] + ["awaitable", "await manager.fire(HookEvent.APPROVAL_RESOLVED, {**payload, 'approved': result.approved}, target_operation_id=target_operation_id, origin=origin)", "unresolved"] ] }, "chrys.service.agent_middleware.control.ask_user": { @@ -3494,11 +3497,11 @@ }, "chrys.service.agent_middleware.events.hook_dispatch": { "apply_before_tool_hooks": [ - ["awaitable", "await manager.fire(HookEvent.BEFORE_TOOL_CALL, _build_payload(session_id=session_id, profile_name=profile_name, tool_name=tool_name, kind=kind, call_id=call_id, args=args, workspace_cwd=workspace_cwd), target_operation_id=target_operation_id)", "unresolved"] + ["awaitable", "await manager.fire(HookEvent.BEFORE_TOOL_CALL, _build_payload(session_id=session_id, profile_name=profile_name, tool_name=tool_name, kind=kind, call_id=call_id, args=args, workspace_cwd=workspace_cwd), target_operation_id=target_operation_id, origin=origin)", "unresolved"] ], "fire_after_tool_hooks": [ - ["awaitable", "await manager.fire(HookEvent.AFTER_TOOL_CALL, payload, target_operation_id=target_operation_id)", "unresolved"], - ["awaitable", "await manager.fire(HookEvent.TOOL_ERROR, payload, target_operation_id=target_operation_id)", "unresolved"] + ["awaitable", "await manager.fire(HookEvent.AFTER_TOOL_CALL, payload, target_operation_id=target_operation_id, origin=origin)", "unresolved"], + ["awaitable", "await manager.fire(HookEvent.TOOL_ERROR, payload, target_operation_id=target_operation_id, origin=origin)", "unresolved"] ] }, "chrys.service.agent_middleware.events.hosted_tools": { @@ -3579,7 +3582,7 @@ }, "chrys.service.agent_middleware.events.sub_agent_events": { "SubAgentEventMiddleware._process": [ - ["wrapper", "await apply_before_tool_hooks(manager=self._hook_manager, context=context, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=args, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None)", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.apply_before_tool_hooks"], + ["wrapper", "await apply_before_tool_hooks(manager=self._hook_manager, context=context, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=args, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None, origin=origin)", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.apply_before_tool_hooks"], ["wrapper", "await self._process_tool_call(context, _blocked_call_next, call_id, provider_call_id, tool_name, args, trajectory, preamble, origin)", "unresolved", "chrys.service.agent_middleware.events.sub_agent_events.SubAgentEventMiddleware._process_tool_call"], ["wrapper", "async with preparation_lock(self._mutation_tracker.get_implicit_window_lock(), preamble)", "unresolved", "chrys.service.trajectory.preparation.preparation_lock"], ["wrapper", "await self._process_tool_call(context, call_next, call_id, provider_call_id, tool_name, args, trajectory, preamble, origin)", "unresolved", "chrys.service.agent_middleware.events.sub_agent_events.SubAgentEventMiddleware._process_tool_call"], @@ -3595,7 +3598,7 @@ ["awaitable", "await trajectory.started(tool_name=tool_name, tool_kind=tool_kind, batch_index=None, invocation_order=get_tool_invocation_order(context), arguments=args, invocation_metadata=_meta, tool_context=resolve_tool_call_context(context.function, args) or None, preamble_operation_id=preamble.operation_id if preamble is not None and preamble.start_committed else None)", "unresolved"], ["awaitable", "await call_next()", "unresolved"], ["wrapper", "await finalize_mutation_tracking(self._mutation_tracker, mut_ctx, call_id, coordinator=self._mutation_coordinator, tool_errored=tool_failed)", "unresolved", "chrys.service.mutations.pipeline.finalize_mutation_tracking"], - ["wrapper", "await fire_after_tool_hooks(manager=self._hook_manager, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=args, result_text=result_text, duration_ms=duration_ms, errored=errored, failed=structured_failed, approval_rejected=rejected, rejection_source=source, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None)", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.fire_after_tool_hooks"], + ["wrapper", "await fire_after_tool_hooks(manager=self._hook_manager, session_id=self._session_id, profile_name=self._profile_name, tool_name=tool_name, kind=tool_kind, call_id=call_id, args=args, result_text=result_text, duration_ms=duration_ms, errored=errored, failed=structured_failed, approval_rejected=rejected, rejection_source=source, workspace_cwd=self._workspace_cwd, target_operation_id=trajectory.operation_id if trajectory is not None else None, origin=origin)", "unresolved", "chrys.service.agent_middleware.events.hook_dispatch.fire_after_tool_hooks"], ["awaitable", "await self._bus.publish(InvocationToolCallResult(origin=origin, agent_name=self._agent_name, tool_name=tool_name, call_id=call_id, result=result_text, image_contents=result_images, duration_ms=duration_ms, metadata=metadata, session_id=self._session_id))", "unresolved"], ["awaitable", "await trajectory.payload_observed(result_text=preview_result_ceiling(result_text, self._tool_result_ceiling_tokens, observation=payload_observation), image_count=len(result_images), observation=payload_observation)", "unresolved"], ["awaitable", "await trajectory.finished(outcome=tool_outcome(cancelled=False, rejected=rejected, errored=errored, result_metadata=metadata), duration_ms=elapsed_ms, result_metadata=metadata, error_kind=raised_error_kind)", "unresolved"], @@ -3981,49 +3984,54 @@ ["timeout", "await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=timeout)", "unresolved"], ["gather", "asyncio.gather(*tasks, return_exceptions=True)", "unresolved"], ["gather", "await asyncio.gather(*tasks, return_exceptions=True)", "unresolved"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._notify_change(executions=dropped, runs=ended)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager._notify_change": [ - ["awaitable", "await self.on_change(self)", "unresolved"] + ["awaitable", "await self.on_change(self)", "unresolved"], + ["awaitable", "await self._on_runs_change()", "unresolved"] ], "HookManager._run_async_tracked": [ - ["wrapper", "await self._run_async_tracked_inner(hook, payload, job, trace, execution_key)", "unresolved", "chrys.service.hooks.manager.HookManager._run_async_tracked_inner"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._run_async_tracked_inner(hook, payload, job, trace, execution_key, run)", "unresolved", "chrys.service.hooks.manager.HookManager._run_async_tracked_inner"], + ["wrapper", "await self._notify_change(executions=removed, runs=ended)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager._run_async_tracked_inner": [ ["async_context", "async with self._sem", "unresolved"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await self._runner.run_and_wait(hook, payload)", "unresolved"], + ["wrapper", "await self._notify_change(executions=running, runs=self._start_run(run))", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text)", "unresolved"], ["awaitable", "await trace.finished(outcome=HookOutcome.LAUNCH_ERROR)", "unresolved"], ["awaitable", "await trace.finished(outcome=_hook_outcome(result, blocked=False), exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved"] ], "HookManager._run_blocking": [ ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], ["async_context", "async with self._sem", "unresolved"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await self._runner.run_and_wait(hook, payload)", "unresolved"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._notify_change(executions=running, runs=self._start_run(run))", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text)", "unresolved"], + ["wrapper", "await self._notify_change(executions=removed, runs=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager._spawn_nonblocking": [ ["awaitable", "await trace.started(hook_id=hook.id, hook_event=str(hook.event), execution_mode=hook.execution.mode, detach=hook.execution.detach, delivery=hook.execution.delivery, scope=scope, drain_scope=drain_scope)", "unresolved"], ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await self._runner.spawn_detached(hook, payload, job=job)", "unresolved"], + ["awaitable", "await self._runner.spawn_detached(hook, payload, job=job, payload_text=run.payload_text)", "unresolved"], ["awaitable", "await trace.finished(outcome=HookOutcome.SPAWN_FAILED)", "unresolved"], + ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], ["awaitable", "await trace.finished(outcome=HookOutcome.DETACHED)", "unresolved"], + ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager.drain_session": [ ["wrapper", "await self._drain(scopes={'turn', 'session'}, timeout=self._file.settings.shutdown_grace_seconds)", "unresolved", "chrys.service.hooks.manager.HookManager._drain"], - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._notify_change(runs=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager.drain_turn": [ ["wrapper", "await self._drain(scopes={'turn'}, timeout=None)", "unresolved", "chrys.service.hooks.manager.HookManager._drain"] ], "HookManager.fire": [ ["awaitable", "await trace.started(hook_id=hook.id, hook_event=str(event), execution_mode=hook.execution.mode, detach=hook.execution.detach, delivery=hook.execution.delivery, scope=scope, drain_scope=scope)", "unresolved"], - ["wrapper", "await self._run_blocking(hook, envelope, waited_at='none' if scope == 'detached' else 'call')", "unresolved", "chrys.service.hooks.manager.HookManager._run_blocking"], + ["wrapper", "await self._run_blocking(hook, envelope, waited_at='none' if scope == 'detached' else 'call', run=run)", "unresolved", "chrys.service.hooks.manager.HookManager._run_blocking"], + ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], ["awaitable", "await trace.finished(outcome=_hook_outcome(result, blocked=decision.blocked and (not blocked_before)), arguments_modified=decision.args_override != override_before, exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved"], - ["wrapper", "await self._spawn_nonblocking(hook, envelope, scope=scope, session_drain_generation=session_drain_generation, target_operation_id=target_operation_id)", "unresolved", "chrys.service.hooks.manager.HookManager._spawn_nonblocking"] + ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["wrapper", "await self._spawn_nonblocking(hook, envelope, scope=scope, session_drain_generation=session_drain_generation, target_operation_id=target_operation_id, origin=origin)", "unresolved", "chrys.service.hooks.manager.HookManager._spawn_nonblocking"] ], "HookManager.recover_outbox": [ ["wrapper", "await self._spawn_nonblocking(hook, job.payload, scope='session', session_drain_generation=self._session_drain_generation, existing_job=job)", "unresolved", "chrys.service.hooks.manager.HookManager._spawn_nonblocking"] diff --git a/tests/foundation/i18n/_catalog_oracle_ids.py b/tests/foundation/i18n/_catalog_oracle_ids.py index 9caf51cd9..16eded7fd 100644 --- a/tests/foundation/i18n/_catalog_oracle_ids.py +++ b/tests/foundation/i18n/_catalog_oracle_ids.py @@ -8,6 +8,7 @@ from tests.foundation.i18n._buddy_catalog_oracle_ids import BUDDY_MESSAGE_IDS from tests.foundation.i18n._error_catalog_oracle_ids import ERROR_MESSAGE_IDS from tests.foundation.i18n._help_catalog_oracle_ids import HELP_MESSAGE_IDS +from tests.foundation.i18n._hook_panel_catalog_oracle_ids import HOOK_PANEL_MESSAGE_IDS from tests.foundation.i18n._settings_catalog_oracle_ids import SETTINGS_MESSAGE_IDS from tests.foundation.i18n._web_catalog_oracle_ids import WEB_MESSAGE_IDS from tests.foundation.i18n._workflow_catalog_oracle_ids import WORKFLOW_MESSAGE_IDS @@ -1938,6 +1939,7 @@ | WORKFLOW_MESSAGE_IDS | HELP_MESSAGE_IDS | WEB_MESSAGE_IDS + | HOOK_PANEL_MESSAGE_IDS | ERROR_MESSAGE_IDS | SETTINGS_MESSAGE_IDS ) diff --git a/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py b/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py new file mode 100644 index 000000000..3f6977c12 --- /dev/null +++ b/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py @@ -0,0 +1,74 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""Pinned message ids for the sidebar's Hooks tab and the hook run details dialog.""" + +from __future__ import annotations + +HOOK_PANEL_MESSAGE_IDS: frozenset[str | tuple[str, str]] = frozenset( + { + "tui.hook_run.applied.args", + "tui.hook_run.applied.blocked", + "tui.hook_run.applied.context", + "tui.hook_run.applied.nothing", + "tui.hook_run.applied.reminder", + "tui.hook_run.copy.log_path", + "tui.hook_run.copy.section", + "tui.hook_run.copy.section_too_large", + "tui.hook_run.copy.section_unavailable", + "tui.hook_run.copy.log_path_unavailable", + "tui.hook_run.field.overview", + "tui.hook_run.details_gone", + "tui.hook_run.exit_code", + "tui.hook_run.field.applied", + "tui.hook_run.field.decision", + "tui.hook_run.field.event", + "tui.hook_run.field.log", + "tui.hook_run.field.log_file", + "tui.hook_run.field.mode", + "tui.hook_run.field.payload", + "tui.hook_run.field.queued", + "tui.hook_run.field.result", + "tui.hook_run.field.run", + "tui.hook_run.field.session", + "tui.hook_run.field.source", + "tui.hook_run.field.started", + "tui.hook_run.field.stderr", + "tui.hook_run.field.stdout", + "tui.hook_run.field.triggered_by", + "tui.hook_run.invalid_result_file", + "tui.hook_run.main_agent", + "tui.hook_run.no_result_file", + "tui.hook_run.not_applied.failed", + "tui.hook_run.not_applied.failed_blocked", + "tui.hook_run.not_applied.nonblocking", + "tui.hook_run.not_applied.unsupported", + "tui.hook_run.payload_truncated", + "tui.hook_run.retry", + "tui.hook_run.running", + "tui.hook_run.shared_log", + "tui.hook_run.succeeded", + "tui.hook_run.tab.input", + "tui.hook_run.tab.output", + "tui.hook_run.title", + "tui.hook_run.truncated", + "tui.sidebar.hooks.clear_ended", + "tui.sidebar.hooks.cleared", + "tui.sidebar.hooks.empty", + "tui.sidebar.hooks.empty_unconfigured", + "tui.sidebar.hooks.only_latest_kept", + "tui.sidebar.hooks.outcome.added_context", + "tui.sidebar.hooks.outcome.added_reminder", + "tui.sidebar.hooks.outcome.blocked", + "tui.sidebar.hooks.outcome.cancelled", + "tui.sidebar.hooks.outcome.couldnt_start", + "tui.sidebar.hooks.outcome.exit", + "tui.sidebar.hooks.outcome.detached", + "tui.sidebar.hooks.outcome.modified_args", + "tui.sidebar.hooks.outcome.queued", + "tui.sidebar.hooks.outcome.running", + "tui.sidebar.hooks.outcome.timed_out", + "tui.sidebar.hooks.title", + "tui.sidebar.tab.hooks", + ("tui.sidebar.hooks.title_active", "tui.sidebar.hooks.title_active#plural"), + } +) diff --git a/tests/foundation/i18n/test_catalog_oracle.py b/tests/foundation/i18n/test_catalog_oracle.py index 8aa43fad9..af3385cca 100644 --- a/tests/foundation/i18n/test_catalog_oracle.py +++ b/tests/foundation/i18n/test_catalog_oracle.py @@ -23,7 +23,7 @@ def test_live_catalog_artifacts_contain_effective_translations_and_are_loadable( pot = _read_catalog(i18n.POT_PATH) po = _read_catalog(i18n.ZH_PO_PATH) - assert len(EXPECTED_MESSAGE_IDS) == 2335 + assert len(EXPECTED_MESSAGE_IDS) == 2399 assert {_entry_id(message) for message in pot if message.id} == EXPECTED_MESSAGE_IDS assert {_entry_id(message) for message in po if message.id} == EXPECTED_MESSAGE_IDS assert set(_effective_catalog_entries(po)) == EXPECTED_MESSAGE_IDS diff --git a/tests/integration/engine/test_trajectory_events_log.py b/tests/integration/engine/test_trajectory_events_log.py index 6bced5074..e7e5997f3 100644 --- a/tests/integration/engine/test_trajectory_events_log.py +++ b/tests/integration/engine/test_trajectory_events_log.py @@ -1058,8 +1058,10 @@ async def test_a_rollback_that_opens_the_inherited_log_still_records_the_branch( release_background_result = asyncio.Event() original_run = HookRunner.run_and_wait - async def held_result(self: HookRunner, hook: HookConfig, payload: dict[str, Any]) -> HookResult: - result = await original_run(self, hook, payload) + async def held_result( + self: HookRunner, hook: HookConfig, payload: dict[str, Any], *, payload_text: str | None = None + ) -> HookResult: + result = await original_run(self, hook, payload, payload_text=payload_text) background_result_ready.set() await release_background_result.wait() return result diff --git a/tests/orchestration/engine/build/test_lifecycle_hooks.py b/tests/orchestration/engine/build/test_lifecycle_hooks.py index 048285ef6..18c7b6b02 100644 --- a/tests/orchestration/engine/build/test_lifecycle_hooks.py +++ b/tests/orchestration/engine/build/test_lifecycle_hooks.py @@ -109,6 +109,7 @@ class _HookLoader: def __init__(self, bus, session) -> None: self._bus = bus self._session = session + self._hook_runs = None class _Engine: diff --git a/tests/orchestration/engine/build/test_retired_hook_traces.py b/tests/orchestration/engine/build/test_retired_hook_traces.py index a744bb5a8..ae81172b9 100644 --- a/tests/orchestration/engine/build/test_retired_hook_traces.py +++ b/tests/orchestration/engine/build/test_retired_hook_traces.py @@ -48,7 +48,7 @@ async def test_replaced_managers_report_results_until_the_original_recorder_clos tasks: list[asyncio.Task[HookResult]] = [] managers: list[HookManager] = [] - async def held_result(hook: HookConfig, _payload: dict[str, Any]) -> HookResult: + async def held_result(hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None) -> HookResult: await release.wait() return HookResult(hook_id=hook.id, exit_code=exit_code) diff --git a/tests/orchestration/engine/run/test_lifecycle_hooks.py b/tests/orchestration/engine/run/test_lifecycle_hooks.py index b4484ee74..c6f244430 100644 --- a/tests/orchestration/engine/run/test_lifecycle_hooks.py +++ b/tests/orchestration/engine/run/test_lifecycle_hooks.py @@ -466,7 +466,9 @@ async def test_user_interrupt_hook_is_detached_from_turn_drain(tmp_path: Path) - entered = asyncio.Event() release = asyncio.Event() - async def _run_and_wait(_hook: HookConfig, _payload: dict[str, Any]) -> HookResult: + async def _run_and_wait( + _hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + ) -> HookResult: entered.set() await release.wait() return HookResult(hook_id=hook.id, exit_code=0) diff --git a/tests/orchestration/engine/test_hook_run_engine.py b/tests/orchestration/engine/test_hook_run_engine.py new file mode 100644 index 000000000..8b2d4c40f --- /dev/null +++ b/tests/orchestration/engine/test_hook_run_engine.py @@ -0,0 +1,357 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""Engine-driven: the engine's hook run log, who each run is recorded for, and what outlives a session. + +The hooks file sits in the per-test config dir, as a user's would; the hooks +are real subprocesses, held by a release file the test creates. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +import textwrap +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from chrys.foundation.events.bus import EventBus +from chrys.foundation.events.types import ( + ApprovalRequest, + ApprovalResponse, + HookRunsChanged, + SessionNew, + SessionRestore, + SettingsReload, + UserInterrupt, + UserMessage, + WorkspaceChange, +) +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.foundation.platform import get_platform +from chrys.orchestration.session_host import ChrysSessionHost +from chrys.service.hooks.run_log import HookRunLog +from chrys.service.llm.mock import MockResponse +from chrys.service.profiles.agents.registry import AgentProfileRegistry +from chrys.service.profiles.agents.schema import AgentProfile +from chrys.service.state.store import JsonFileStateStore +from chrys.service.trajectory.hooks import HookOutcome +from tests.orchestration.engine.test_hook_reporting_engine import _build_compacting_child_engine +from tests.orchestration.invoker._build_fixtures import build_recipe_engine +from tests.support.waiting import ( + ENGINE_TEST_WAIT_TIMEOUT, + ENGINE_TURN_TIMEOUT, + await_run_task_chain, + wait_for, + with_wait_deadline, +) + + +def _held(release: Path) -> list[str]: + script = textwrap.dedent( + f""" + import os, time + while not os.path.exists({str(release)!r}): + time.sleep(0.01) + """ + ) + return [sys.executable, "-c", script] + + +def _entry( + hook_id: str, event: str, argv: list[str] | None = None, *, mode: str = "blocking", tool_name: str | None = None +) -> dict[str, Any]: + entry: dict[str, Any] = { + "id": hook_id, + "event": event, + "run": {"type": "command", "argv": argv or [sys.executable, "-c", "pass"]}, + "execution": {"mode": mode, "timeout_seconds": 30}, + } + if tool_name is not None: + entry["match"] = {"tool_name": tool_name} + return entry + + +def _write_hooks(*entries: dict[str, Any]) -> None: + hooks_dir = get_platform().config_dir / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + document = {"version": 1, "settings": {"shutdown_grace_seconds": 30}, "hooks": list(entries)} + (hooks_dir / "hooks.json").write_text(json.dumps(document), encoding="utf-8") + + +def _recording(agent_engine: Any) -> Any: + def factory(*args: Any, **kwargs: Any) -> Any: + return agent_engine(*args, record_hook_runs=True, **kwargs) + + return factory + + +def _log(engine: Any) -> HookRunLog: + log = engine.hook_runs + assert isinstance(log, HookRunLog) + return log + + +def _runs(engine: Any, hook_id: str) -> list[HookRunRecord]: + return [record for record in _log(engine).records() if record.hook_id == hook_id] + + +@pytest.fixture +def release(tmp_path: Path) -> Iterator[Path]: + path = tmp_path / "release" + yield path + path.touch() + + +# --- Which engines keep a log (5.1) --------------------------------------------- + + +async def test_only_an_engine_assembled_to_record_has_a_log(agent_engine: Any, tmp_path: Path) -> None: + plain = agent_engine(EventBus(), state_store=JsonFileStateStore(tmp_path / "a")) + assert plain.hook_runs is None + recording = agent_engine(EventBus(), state_store=JsonFileStateStore(tmp_path / "b"), record_hook_runs=True) + assert isinstance(recording.hook_runs, HookRunLog) + assert recording.hook_runs.records() == () + + +async def test_a_headless_session_host_keeps_no_log(tmp_path: Path) -> None: + agents = AgentProfileRegistry() + agents.register(AgentProfile(name="Code", instructions="x")) + host = ChrysSessionHost(profile_name="Code", agent_registry=agents, state_store=JsonFileStateStore(tmp_path)) + try: + assert host.engine.hook_runs is None + finally: + await host.shutdown() + + +# --- Attribution (5.4) ---------------------------------------------------------- + + +@with_wait_deadline(ENGINE_TEST_WAIT_TIMEOUT) +async def test_a_sub_agents_tool_and_approval_hooks_are_recorded_for_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_engine: Any +) -> None: + target = tmp_path / "notes.txt" + target.write_text("notes", encoding="utf-8") + _write_hooks( + _entry("main-gate", "before_tool_call", tool_name="Explore"), + _entry("child-gate", "before_tool_call", tool_name="read_file"), + _entry("child-check", "after_tool_call", tool_name="read_file"), + _entry("on-approval", "approval_requested"), + _entry("on-resolved", "approval_resolved"), + _entry("after-turn", "after_turn"), + ) + engine, _main, _child = await build_recipe_engine( + _recording(agent_engine), + monkeypatch, + tmp_path, + main=[MockResponse(tool_calls=[("Explore", "call-1", {"prompt": "look"})]), MockResponse(text="done")], + child=[MockResponse(tool_calls=[("read_file", "read-1", {"path": str(target)})]), MockResponse(text="read")], + ) + + answers: list[asyncio.Task[None]] = [] + + async def answer(event: ApprovalRequest) -> None: + # A human answers later: approval_requested has run by then. + await wait_for( + lambda: bool(_runs(engine, "on-approval")), timeout=ENGINE_TURN_TIMEOUT, description="approval hook ran" + ) + # Edited args: the approval re-runs before_tool_call with them. + await engine.event_bus.publish( + ApprovalResponse(request_id=event.request_id, approved=True, modified_args={"path": str(target)}) + ) + + async def approve(event: ApprovalRequest) -> None: + answers.append(asyncio.create_task(answer(event))) + + await engine.event_bus.subscribe(ApprovalRequest, approve) + await engine.event_bus.publish(UserMessage(text="delegate")) + await await_run_task_chain(engine, turn_state=engine.turns.turn_state) + await asyncio.gather(*answers) + + child_gate = _runs(engine, "child-gate") + assert len(child_gate) == 2, "the call and its re-check after the edited approval" + for hook_id in ("child-gate", "child-check", "on-approval", "on-resolved"): + runs = _runs(engine, hook_id) + assert runs, hook_id + assert {record.sub_agent for record in runs} == {"Explore"}, (hook_id, runs) + assert {record.tool_name for record in child_gate} == {"read_file"} + for hook_id in ("main-gate", "after-turn"): + (record,) = _runs(engine, hook_id) + assert record.sub_agent == "", hook_id + assert _runs(engine, "main-gate")[0].tool_name == "Explore" + + +@with_wait_deadline(ENGINE_TEST_WAIT_TIMEOUT) +async def test_a_sub_agents_real_pre_compact_is_recorded_for_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_engine: Any +) -> None: + big = tmp_path / "big.txt" + big.write_text("\n".join(f"line {i}: " + "lorem ipsum dolor sit amet " * 8 for i in range(3000)), encoding="utf-8") + _write_hooks(_entry("on-compact", "pre_compact")) + note = ( + "## Task\nThe user asked the explorer to read big.txt in the workspace and report what it holds, " + "briefly and without reading any other file.\n" + "## Progress\nbig.txt was read once in full with read_file. It holds three thousand numbered lines of " + "placeholder lorem ipsum text and nothing else of interest: no code, no configuration and no data.\n" + "## Next\nReply that big.txt is filler text only, without reading it again, and mention that its " + "lines are numbered from zero so the caller can cite them.\n" + ) + engine = await _build_compacting_child_engine( + _recording(agent_engine), + monkeypatch, + tmp_path, + main=[MockResponse(tool_calls=[("Explore", "call-1", {"prompt": "read big.txt"})]), MockResponse(text="done")], + child=[ + MockResponse(tool_calls=[("read_file", "read-1", {"path": str(big)})]), + MockResponse(text=note), + MockResponse(text="read"), + ], + ) + await engine.event_bus.publish(UserMessage(text="delegate")) + await await_run_task_chain(engine, turn_state=engine.turns.turn_state) + runs = _runs(engine, "on-compact") + assert runs, "the child never compacted" + assert {record.sub_agent for record in runs} == {"Explore"} + + +# --- Lifecycle (5.1, 5.2) ------------------------------------------------------- + + +@with_wait_deadline(ENGINE_TEST_WAIT_TIMEOUT) +async def test_an_interrupted_hook_is_recorded_cancelled_and_told( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_engine: Any, release: Path +) -> None: + _write_hooks(_entry("gate", "before_tool_call", _held(release), tool_name="Explore")) + engine, _main, _child = await build_recipe_engine( + _recording(agent_engine), + monkeypatch, + tmp_path, + main=[MockResponse(tool_calls=[("Explore", "call-1", {"prompt": "look"})]), MockResponse(text="done")], + child=[MockResponse(text="looked")], + ) + told: list[tuple[HookRunRecord, ...]] = [] + + async def on_runs(event: HookRunsChanged) -> None: + assert event.session_id + told.append(_log(engine).records()) + + await engine.event_bus.subscribe(HookRunsChanged, on_runs) + await engine.event_bus.publish(UserMessage(text="delegate")) + await wait_for( + lambda: any(r.hook_id == "gate" and r.state == "running" for r in _log(engine).records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the gate running", + ) + await engine.event_bus.publish(UserInterrupt()) + await await_run_task_chain(engine, turn_state=engine.turns.turn_state) + (record,) = _runs(engine, "gate") + assert (record.state, record.outcome) == ("ended", HookOutcome.CANCELLED) + # Told by the time the turn has unwound; no idle sweep needed. + assert told and told[-1] == _log(engine).records() + + +@with_wait_deadline(ENGINE_TEST_WAIT_TIMEOUT) +async def test_runs_survive_a_settings_reload_a_workspace_change_and_session_switches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_engine: Any +) -> None: + _write_hooks(_entry("after-turn", "after_turn")) + engine, _main, _child = await build_recipe_engine( + _recording(agent_engine), + monkeypatch, + tmp_path, + main=[MockResponse(text=f"answer {n}") for n in range(4)], + child=[], + ) + log = _log(engine) + + async def turn(text: str) -> None: + await engine.event_bus.publish(UserMessage(text=text)) + await await_run_task_chain(engine, turn_state=engine.turns.turn_state) + + first_session = engine.session_id + await turn("one") + managers = {id(engine.session.hook_manager)} + await engine.event_bus.publish(SettingsReload(), raise_handler_errors=True) + managers.add(id(engine.session.hook_manager)) + await turn("two") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + await engine.event_bus.publish(WorkspaceChange(primary_cwd=str(elsewhere)), raise_handler_errors=True) + await turn("three") + managers.add(id(engine.session.hook_manager)) + assert len(managers) > 1, "a reload or a working folder change replaced the manager" + assert [r.session_id for r in _runs(engine, "after-turn")] == [first_session] * 3 + + await engine.event_bus.publish(SessionNew(), raise_handler_errors=True) + assert engine.session_id != first_session + second_session = engine.session_id + await turn("four") + assert engine.hook_runs is log + assert [r.session_id for r in _runs(engine, "after-turn")] == [first_session] * 3 + [second_session] + + before_restore = log.records() + await engine.event_bus.publish(SessionRestore(session_id=first_session), raise_handler_errors=True) + await wait_for(lambda: engine.session_id == first_session, timeout=ENGINE_TURN_TIMEOUT, description="restored") + assert engine.hook_runs is log + kept = {r.run_id for r in log.records()} + assert {r.run_id for r in before_restore} <= kept + + +# --- Sensitive data (5.5) ------------------------------------------------------- + + +@with_wait_deadline(ENGINE_TEST_WAIT_TIMEOUT) +async def test_the_details_are_not_written_to_session_files_trajectory_or_logs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, agent_engine: Any, caplog: pytest.LogCaptureFixture +) -> None: + stdout_secret, stderr_secret, result_secret = "STDOUT-SECRET-7d1e", "STDERR-SECRET-41aa", "RESULT-SECRET-90bc" + + # Each secret is assembled by the hook, so the hooks file itself never holds it. + def assembled(secret: str) -> str: + head, tail = secret.split("-", 1) + return f"{head!r} + '-' + {tail!r}" + + script = textwrap.dedent( + f""" + import json, os, sys + print({assembled(stdout_secret)}) + sys.stderr.write({assembled(stderr_secret)}) + json.dump({{"note": {assembled(result_secret)}}}, open(os.environ["CHRYS_HOOK_RESULT"], "w")) + """ + ) + _write_hooks(_entry("talkative", "after_turn", [sys.executable, "-c", script])) + caplog.set_level(logging.DEBUG) + engine, _main, _child = await build_recipe_engine( + _recording(agent_engine), monkeypatch, tmp_path, main=[MockResponse(text="done")], child=[] + ) + await engine.event_bus.publish(UserMessage(text="hello")) + await await_run_task_chain(engine, turn_state=engine.turns.turn_state) + (record,) = _runs(engine, "talkative") + detail = _log(engine).detail(record.run_id) + assert detail is not None and detail.io is not None + assert stdout_secret in detail.io.stdout, "kept in memory for the panel" + + leaks: list[str] = [] + unreadable: list[Path] = [] + for root in (tmp_path, get_platform().config_dir): + for path in root.rglob("*"): + if not path.is_file() or path.suffix == ".py": + continue + try: + data = path.read_bytes() + except PermissionError: + # Windows refuses reads of a byte-range-locked file (the + # session's active lock); only lock files may be skipped. + unreadable.append(path) + continue + secrets = (stdout_secret, stderr_secret, result_secret) + leaks.extend(f"{secret} in {path}" for secret in secrets if secret.encode() in data) + assert leaks == [] + assert all(path.suffix == ".lock" for path in unreadable), unreadable + for secret in (stdout_secret, stderr_secret, result_secret): + assert secret not in caplog.text diff --git a/tests/orchestration/engine/test_hook_run_reporting.py b/tests/orchestration/engine/test_hook_run_reporting.py new file mode 100644 index 000000000..848587aea --- /dev/null +++ b/tests/orchestration/engine/test_hook_run_reporting.py @@ -0,0 +1,275 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""A session's hook manager writes the engine's run log and tells the TUI with ``HookRunsChanged``. + +Covers the publication rules (5.2) and the runs that end late (5.1): cancelled +by a drain, or ending after the manager closed. Hooks are real ``python -c`` +subprocesses; a held hook polls for a release file the test creates. +""" + +from __future__ import annotations + +import asyncio +import sys +import textwrap +from pathlib import Path + +from chrys.foundation.events.bus import EventBus +from chrys.foundation.events.types import Event, HookExecutionsChanged, HookRunsChanged +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.orchestration import session_host +from chrys.orchestration.engine.hook_reporting import wire_hook_reporting +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.manager import HookManager +from chrys.service.hooks.run_log import HookRunLog +from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HookSettings, HooksFile +from chrys.service.trajectory.hooks import HookOutcome +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for, wait_until + +_SESSION = "s1" + + +def _held(release: Path) -> str: + return textwrap.dedent( + f""" + import os, time + while not os.path.exists({str(release)!r}): + time.sleep(0.01) + """ + ) + + +def _hook(hook_id: str, event: HookEvent, *, mode: str = "blocking", script: str = "pass") -> HookConfig: + return HookConfig( + id=hook_id, + event=event, + run=HookRun(type="command", argv=[sys.executable, "-c", script]), + execution=HookExecution(mode=mode, timeout_seconds=30.0), + match=HookMatch(), + ) + + +def _manager(tmp_path: Path, *hooks: HookConfig, **settings: float) -> HookManager: + hooks_dir = tmp_path / "hooks" + hooks_dir.mkdir(exist_ok=True) + return HookManager(file=HooksFile(settings=HookSettings(**settings), hooks=list(hooks)), hooks_dir=hooks_dir) + + +class _Seen: + """Every snapshot and run-log notice in bus order, with the log as each notice found it.""" + + def __init__(self, log: HookRunLog) -> None: + self.log = log + self.events: list[Event] = [] + self.log_at_notice: list[tuple[HookRunRecord, ...]] = [] + + async def on_runs(self, event: HookRunsChanged) -> None: + self.events.append(event) + self.log_at_notice.append(self.log.records()) + + async def on_snapshot(self, event: HookExecutionsChanged) -> None: + self.events.append(event) + + async def attach(self, bus: EventBus) -> None: + await bus.subscribe(HookRunsChanged, self.on_runs) + await bus.subscribe(HookExecutionsChanged, self.on_snapshot) + + def notices(self, since: int = 0) -> list[HookRunsChanged]: + return [e for e in self.events[since:] if isinstance(e, HookRunsChanged)] + + def snapshots(self, since: int = 0) -> list[HookExecutionsChanged]: + return [e for e in self.events[since:] if isinstance(e, HookExecutionsChanged)] + + +def _states(seen: _Seen, hook_id: str, since: int = 0) -> list[str]: + start = len([e for e in seen.events[:since] if isinstance(e, HookRunsChanged)]) + states: list[str] = [] + for records in seen.log_at_notice[start:]: + state = next((r.state for r in records if r.hook_id == hook_id), "absent") + if not states or states[-1] != state: + states.append(state) + return states + + +async def test_each_write_is_followed_by_a_notice_carrying_the_session(tmp_path: Path) -> None: + release = tmp_path / "release" + manager = _manager(tmp_path, _hook("guard", HookEvent.BEFORE_TOOL_CALL, script=_held(release))) + bus, log = EventBus(), HookRunLog() + seen = _Seen(log) + await seen.attach(bus) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, {"tool": {"name": "zsh", "args": {}}})) + try: + await wait_for( + lambda: any(r.state == "running" for records in seen.log_at_notice for r in records), + timeout=ENGINE_TURN_TIMEOUT, + description="a notice after the run took its slot", + ) + finally: + release.touch() + await fire + assert {event.session_id for event in seen.notices()} == {_SESSION} + # Written before the notice: queued, running, ended. + assert _states(seen, "guard") == ["queued", "running", "ended"] + assert seen.log_at_notice[-1] == log.records() + # In one change the snapshot goes first, then the notice. + last_snapshot = max(i for i, e in enumerate(seen.events) if isinstance(e, HookExecutionsChanged)) + last_notice = max(i for i, e in enumerate(seen.events) if isinstance(e, HookRunsChanged)) + assert last_snapshot < last_notice + + +async def test_without_a_run_log_no_notice_is_published(tmp_path: Path) -> None: + manager = _manager(tmp_path, _hook("quick", HookEvent.AFTER_TURN)) + bus, log = EventBus(), HookRunLog() + seen = _Seen(log) + await seen.attach(bus) + wire_hook_reporting(manager, bus, session_id=_SESSION) + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + assert seen.snapshots(), "the snapshots still go out" + assert seen.notices() == [] + assert log.records() == () + + +async def test_a_run_a_non_closing_drain_cancels_is_recorded_cancelled_and_told(tmp_path: Path) -> None: + release = tmp_path / "release" + manager = _manager( + tmp_path, + _hook("lingering", HookEvent.SESSION_END, mode="async", script=_held(release)), + shutdown_grace_seconds=0.3, + ) + bus, log = EventBus(), HookRunLog() + seen = _Seen(log) + await seen.attach(bus) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + try: + await manager.fire(HookEvent.SESSION_END, {}, scope="session") + await wait_for( + lambda: any(r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="running", + ) + await asyncio.wait_for(manager.drain_session(close=False), ENGINE_TURN_TIMEOUT) + (record,) = log.records() + assert (record.state, record.outcome) == ("ended", HookOutcome.CANCELLED) + assert seen.log_at_notice[-1] == log.records() + finally: + release.touch() + await manager.drain_session() + + +async def test_a_background_run_ending_after_close_is_still_recorded_and_told_without_a_snapshot( + tmp_path: Path, +) -> None: + release_holder = tmp_path / "release-holder" + ran = tmp_path / "queued-ran" + manager = _manager( + tmp_path, + HookConfig( + id="holder", + event=HookEvent.AFTER_TOOL_CALL, + run=HookRun(type="command", argv=[sys.executable, "-c", _held(release_holder)]), + execution=HookExecution(mode="fire_and_forget", timeout_seconds=30.0), + match=HookMatch(tool_name="a"), + ), + HookConfig( + id="late", + event=HookEvent.AFTER_TOOL_CALL, + run=HookRun(type="command", argv=[sys.executable, "-c", f"open({str(ran)!r}, 'w').write('ran')"]), + execution=HookExecution(mode="fire_and_forget", timeout_seconds=30.0), + match=HookMatch(tool_name="b"), + ), + max_parallel_hooks=1, + ) + bus, log = EventBus(), HookRunLog() + seen = _Seen(log) + await seen.attach(bus) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + try: + await manager.fire(HookEvent.AFTER_TOOL_CALL, {"tool": {"name": "a", "args": {}}}) + await wait_for( + lambda: any(r.hook_id == "holder" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the holder running", + ) + await manager.fire(HookEvent.AFTER_TOOL_CALL, {"tool": {"name": "b", "args": {}}}) + await wait_for( + lambda: any(r.hook_id == "late" and r.state == "queued" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the second queued", + ) + await asyncio.wait_for(manager.drain_session(close=True), ENGINE_TURN_TIMEOUT) + closed_at = len(seen.events) + finally: + release_holder.touch() + await wait_for( + lambda: all(r.state == "ended" for r in log.records()) and ran.exists(), + timeout=ENGINE_TURN_TIMEOUT, + description="both runs ended after the close", + ) + await wait_for( + lambda: bool(seen.log_at_notice) and seen.log_at_notice[-1] == log.records(), + timeout=ENGINE_TURN_TIMEOUT, + description="the last write told", + ) + by_id = {record.hook_id: record for record in log.records()} + assert {record.session_id for record in by_id.values()} == {_SESSION} + assert by_id["late"].outcome == HookOutcome.SUCCESS + assert by_id["late"].started_at is not None + # Told when it took its slot and when it ended, after the close; no snapshot after it. + assert _states(seen, "late", since=closed_at)[-2:] == ["running", "ended"] + assert seen.snapshots(closed_at) == [] + assert all(notice.session_id == _SESSION for notice in seen.notices(closed_at)) + + +async def test_a_late_run_is_told_even_when_a_newer_session_reports_on_the_same_bus(tmp_path: Path) -> None: + """Section 5.2: not filtered by session; the TUI rereads every session's runs.""" + release = tmp_path / "release" + old = _manager(tmp_path, _hook("ff", HookEvent.AFTER_TOOL_CALL, mode="fire_and_forget", script=_held(release))) + new = _manager(tmp_path, _hook("next", HookEvent.AFTER_TURN)) + bus, log = EventBus(), HookRunLog() + seen = _Seen(log) + await seen.attach(bus) + wire_hook_reporting(old, bus, session_id="old", run_log=log) + try: + await old.fire(HookEvent.AFTER_TOOL_CALL, {"tool": {"name": "x", "args": {}}}) + await wait_for( + lambda: any(r.state == "running" for r in log.records()), timeout=ENGINE_TURN_TIMEOUT, description="ff" + ) + await asyncio.wait_for(old.drain_session(close=True), ENGINE_TURN_TIMEOUT) + wire_hook_reporting(new, bus, session_id="new", run_log=log) + await new.fire(HookEvent.AFTER_TURN, {"turn": 1}) + finally: + release.touch() + await wait_for( + lambda: all(r.state == "ended" for r in log.records()) and seen.log_at_notice[-1] == log.records(), + timeout=ENGINE_TURN_TIMEOUT, + description="the old session's run ended and told", + ) + sessions = {record.hook_id: record.session_id for record in log.records()} + assert sessions == {"ff": "old", "next": "new"} + assert {notice.session_id for notice in seen.notices()} == {"old", "new"} + await new.drain_session() + + +async def test_a_raising_bus_subscriber_does_not_stop_the_hook(tmp_path: Path) -> None: + script = ( + "import json, os\njson.dump({'action': 'block', 'reason': 'no'}, open(os.environ['CHRYS_HOOK_RESULT'], 'w'))" + ) + manager = _manager(tmp_path, _hook("guard", HookEvent.BEFORE_TOOL_CALL, script=script)) + bus, log = EventBus(), HookRunLog() + + async def explode(_event: HookRunsChanged) -> None: + raise RuntimeError("subscriber exploded") + + await bus.subscribe(HookRunsChanged, explode) + wire_hook_reporting(manager, bus, session_id=_SESSION, run_log=log) + decision = await manager.fire(HookEvent.BEFORE_TOOL_CALL, {"tool": {"name": "zsh", "args": {}}}) + assert decision.blocked is True + (record,) = log.records() + assert (record.state, record.effect) == ("ended", "blocked") + assert not await wait_until(lambda: len(log.records()) != 1, timeout=0.2) + + +def test_the_notice_does_not_reach_headless_or_acp_output() -> None: + assert not issubclass(HookRunsChanged, session_host._HEADLESS_RUN_EVENT_TYPES) + assert HookRunsChanged(session_id="s").session_id == "s" diff --git a/tests/orchestration/engine/test_shutdown_close.py b/tests/orchestration/engine/test_shutdown_close.py index 303bf6f06..c22060087 100644 --- a/tests/orchestration/engine/test_shutdown_close.py +++ b/tests/orchestration/engine/test_shutdown_close.py @@ -546,7 +546,9 @@ async def test_session_transition_settles_active_injection_and_blocking_hook_at_ hook_entered = asyncio.Event() hook_release = asyncio.Event() - async def _run_and_wait(_hook: HookConfig, _payload: dict[str, Any]) -> HookResult: + async def _run_and_wait( + _hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + ) -> HookResult: hook_entered.set() await hook_release.wait() return HookResult(hook_id=hook.id, exit_code=0) diff --git a/tests/service/agent_middleware/test_approval.py b/tests/service/agent_middleware/test_approval.py index 4d70b5368..44f5bbaf0 100644 --- a/tests/service/agent_middleware/test_approval.py +++ b/tests/service/agent_middleware/test_approval.py @@ -483,7 +483,12 @@ def has_hooks_for(self, event: HookEvent) -> bool: return event == HookEvent.BEFORE_TOOL_CALL async def fire( - self, _event: HookEvent, _payload: dict[str, Any], *, target_operation_id: str | None = None + self, + _event: HookEvent, + _payload: dict[str, Any], + *, + target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> HookDecision: self.targets.append(target_operation_id) return HookDecision() diff --git a/tests/service/agent_middleware/test_approval_hooks.py b/tests/service/agent_middleware/test_approval_hooks.py index 0bf1de613..f38390805 100644 --- a/tests/service/agent_middleware/test_approval_hooks.py +++ b/tests/service/agent_middleware/test_approval_hooks.py @@ -13,6 +13,7 @@ from chrys.foundation.events.bus import EventBus from chrys.foundation.events.types import ApprovalRequest, ApprovalResponse +from chrys.foundation.models.invocations import InvocationOrigin from chrys.foundation.tool_kinds import KIND_MCP, set_tool_kind from chrys.foundation.trajectory.context import trajectory_scope from chrys.foundation.trajectory.event_types import EventType @@ -61,7 +62,11 @@ async def test_approval_hooks_follow_human_wait_before_tool_execution( order: list[str] = [] async def observe( - event: HookEvent, payload: dict[str, Any], *, target_operation_id: str | None = None + event: HookEvent, + payload: dict[str, Any], + *, + target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> HookDecision: observed.append((event, payload)) order.append(str(event)) @@ -143,7 +148,11 @@ async def test_cancelled_approval_hook_wait_releases_response_subscription() -> hooks.has_hooks_for.return_value = True async def observe( - event: HookEvent, payload: dict[str, Any], *, target_operation_id: str | None = None + event: HookEvent, + payload: dict[str, Any], + *, + target_operation_id: str | None = None, + origin: InvocationOrigin | None = None, ) -> HookDecision: notified.set() return HookDecision() diff --git a/tests/service/agent_middleware/test_approval_reuse_review_regressions.py b/tests/service/agent_middleware/test_approval_reuse_review_regressions.py index 338f85930..b4292f89a 100644 --- a/tests/service/agent_middleware/test_approval_reuse_review_regressions.py +++ b/tests/service/agent_middleware/test_approval_reuse_review_regressions.py @@ -58,7 +58,7 @@ async def test_ui_edit_runs_its_before_hook_once(approval_setup, rewrite): hooks.has_hooks_for.side_effect = lambda event: event == HookEvent.BEFORE_TOOL_CALL seen = [] - async def hook(event, payload, *, target_operation_id=None): + async def hook(event, payload, *, target_operation_id=None, origin=None): command = payload["tool"]["args"]["command"] seen.append(command) return HookDecision(args_override={"command": f"timeout 10 {command}"}) if rewrite else HookDecision() diff --git a/tests/service/hooks/test_manager.py b/tests/service/hooks/test_manager.py index f6429ae2d..ddb648f51 100644 --- a/tests/service/hooks/test_manager.py +++ b/tests/service/hooks/test_manager.py @@ -400,7 +400,9 @@ async def test_explicit_trajectory_context_applies_only_to_blocking_hooks(hooks_ mgr = HookManager(file=_file(blocking, asynchronous), hooks_dir=hooks_dir) release_async = asyncio.Event() - async def _run_and_wait(hook: HookConfig, _payload: dict[str, object]) -> HookResult: + async def _run_and_wait( + hook: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + ) -> HookResult: if hook is asynchronous: await release_async.wait() return HookResult(hook_id=hook.id, exit_code=0) @@ -778,7 +780,9 @@ async def test_session_drain_preserves_background_result_while_log_stays_open( entered = asyncio.Event() release = asyncio.Event() - async def run_and_wait(config: HookConfig, _payload: dict[str, Any]) -> HookResult: + async def run_and_wait( + config: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + ) -> HookResult: entered.set() await release.wait() return HookResult(hook_id=config.id, exit_code=0) @@ -854,9 +858,12 @@ async def held_emit( hooks_dir=hooks_dir, ) - async def held_spawn(hook: HookConfig, payload: dict[str, Any], *, job: OutboxJob | None = None) -> None: + async def held_spawn( + hook: HookConfig, payload: dict[str, Any], *, job: OutboxJob | None = None, payload_text: str | None = None + ) -> Path: started.set() await release_ack.wait() + return Path("hook.log") # Model the asynchronous OS handoff without creating an orphan process. monkeypatch.setattr(mgr._runner, "spawn_detached", held_spawn) @@ -896,7 +903,9 @@ async def test_drain_session_fences_inflight_blocking_hook_dispatch(hooks_dir: P release = asyncio.Event() executed: list[str] = [] - async def _run_and_wait(hook: HookConfig, _payload: dict[str, object]) -> HookResult: + async def _run_and_wait( + hook: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + ) -> HookResult: executed.append(hook.id) if hook is blocking_one: entered.set() @@ -943,7 +952,9 @@ async def test_drain_session_fences_blocking_dispatch_during_start_ack(hooks_dir mgr = HookManager(file=_file(hook), hooks_dir=hooks_dir) executed: list[str] = [] - async def _run_and_wait(config: HookConfig, _payload: dict[str, object]) -> HookResult: + async def _run_and_wait( + config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + ) -> HookResult: executed.append(config.id) return HookResult(hook_id=config.id, exit_code=0) @@ -991,7 +1002,9 @@ async def test_drain_session_fences_durable_async_dispatch_during_start_ack(hook mgr = HookManager(file=file, hooks_dir=hooks_dir) executed: list[str] = [] - async def _run_and_wait(config: HookConfig, _payload: dict[str, object]) -> HookResult: + async def _run_and_wait( + config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + ) -> HookResult: executed.append(config.id) return HookResult(hook_id=config.id, exit_code=0) @@ -1066,7 +1079,9 @@ async def test_detached_scope_async_hook_is_reaped_only_after_completion(hooks_d entered = asyncio.Event() release = asyncio.Event() - async def _run_and_wait(config: HookConfig, _payload: dict[str, object]) -> HookResult: + async def _run_and_wait( + config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + ) -> HookResult: entered.set() await release.wait() return HookResult(hook_id=config.id, exit_code=0) diff --git a/tests/service/hooks/test_manager_executions.py b/tests/service/hooks/test_manager_executions.py index 612a3b023..5f9f29f1d 100644 --- a/tests/service/hooks/test_manager_executions.py +++ b/tests/service/hooks/test_manager_executions.py @@ -815,12 +815,13 @@ async def test_a_queued_interrupt_observer_that_takes_its_slot_after_close_publi def test_the_view_carries_only_what_the_indicator_uses() -> None: - """Only what the indicator reads: no trigger point and no sub-agent attribution.""" + """The run's details live in the run log; the view only names the run there (``run_id``).""" assert [field.name for field in dataclasses.fields(HookExecutionView)] == [ "hook_id", "waited_at", "queued", "started_at", + "run_id", ] diff --git a/tests/service/hooks/test_run_log.py b/tests/service/hooks/test_run_log.py new file mode 100644 index 000000000..c6ab08510 --- /dev/null +++ b/tests/service/hooks/test_run_log.py @@ -0,0 +1,511 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""The run log a :class:`HookManager` writes: one record per run, how it ended and what iCode applied. + +Every hook is a real ``python -c`` subprocess; a held hook polls for a release +file the test creates. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import sys +import textwrap +import time +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from chrys.foundation.models.hook_runs import HookRunDetail, HookRunRecord +from chrys.foundation.trajectory.context import trajectory_scope +from chrys.foundation.trajectory.event_types import EventType +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.manager import HookManager +from chrys.service.hooks.run_log import HookRunLog +from chrys.service.hooks.runner import HookRunner +from chrys.service.hooks.schema import ( + HookConfig, + HookExecution, + HookMatch, + HookRun, + HookSettings, + HooksFile, + MergedHooksFile, +) +from chrys.service.trajectory.hooks import HookOutcome +from tests.service.trajectory._fakes import FakeSink, make_context +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for + +_SESSION = "s1" + + +def _result(decision: dict[str, object], *, exit_code: int = 0) -> str: + return textwrap.dedent( + f""" + import json, os, sys + with open(os.environ["CHRYS_HOOK_RESULT"], "w") as f: + json.dump({decision!r}, f) + sys.exit({exit_code}) + """ + ) + + +def _held(release: Path) -> str: + return textwrap.dedent( + f""" + import os, time + while not os.path.exists({str(release)!r}): + time.sleep(0.01) + """ + ) + + +def _hook( + hook_id: str, + event: HookEvent, + *, + mode: str = "blocking", + script: str = "pass", + argv: list[str] | None = None, + timeout: float = 30.0, + on_error: str = "warn", + tool_name: str | None = None, + detach: bool = False, + delivery: str = "best_effort", +) -> HookConfig: + return HookConfig( + id=hook_id, + event=event, + run=HookRun(type="command", argv=argv or [sys.executable, "-c", script]), + execution=HookExecution( + mode=mode, timeout_seconds=timeout, on_error=on_error, detach=detach, delivery=delivery + ), + match=HookMatch(tool_name=tool_name), + ) + + +class _Logged: + """A manager recording into a run log, with every change callback counted.""" + + def __init__(self, manager: HookManager, log: HookRunLog) -> None: + self.manager = manager + self.log = log + self.changes: list[tuple[HookRunRecord, ...]] = [] + + async def on_change(self) -> None: + self.changes.append(self.log.records()) + + def only(self, hook_id: str) -> HookRunRecord: + matches = [record for record in self.log.records() if record.hook_id == hook_id] + assert len(matches) == 1, matches + return matches[0] + + def detail(self, hook_id: str) -> HookRunDetail: + detail = self.log.detail(self.only(hook_id).run_id) + assert detail is not None + return detail + + +def _logged( + hooks_dir: Path, *hooks: HookConfig, log: HookRunLog | None = None, session_id: str = _SESSION, **settings: float +) -> _Logged: + manager = HookManager(file=HooksFile(settings=HookSettings(**settings), hooks=list(hooks)), hooks_dir=hooks_dir) + logged = _Logged(manager, log if log is not None else HookRunLog()) + manager.record_runs(logged.log, session_id=session_id, on_change=logged.on_change) + return logged + + +def _tool(name: str = "zsh", args: dict[str, object] | None = None) -> dict[str, object]: + return {"profile": "Code", "tool": {"name": name, "kind": "shell", "call_id": "call-7", "args": args or {}}} + + +@pytest.fixture +def hooks_dir(tmp_path: Path) -> Path: + path = tmp_path / "hooks" + path.mkdir() + return path + + +@pytest.fixture +async def release(tmp_path: Path) -> AsyncIterator[Path]: + path = tmp_path / "release" + yield path + path.touch() + + +# --- How a run ended and what was applied (sections 3, 5.1) --------------------- + + +async def test_a_successful_run_with_no_decision_is_recorded_ended_with_no_effect(hooks_dir: Path) -> None: + logged = _logged(hooks_dir, _hook("quiet", HookEvent.BEFORE_TOOL_CALL, script="print('hi')")) + before = time.monotonic() + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool("zsh")) + record = logged.only("quiet") + assert record.state == "ended" + assert record.outcome == HookOutcome.SUCCESS + assert record.effect is None + assert record.exit_code == 0 + assert (record.session_id, record.hook_event, record.execution_mode) == (_SESSION, "before_tool_call", "blocking") + assert (record.tool_name, record.sub_agent, record.retry, record.detach) == ("zsh", "", False, False) + assert record.started_at is not None and record.ended_at is not None + assert before <= record.registered_at <= record.started_at <= record.ended_at <= time.monotonic() + detail = logged.detail("quiet") + assert detail.tool_call_id == "call-7" + assert detail.io is not None and detail.applied is not None + assert detail.io.result_file == "missing" + assert "hi" in detail.io.stdout + assert detail.applied.not_applied is None + assert (detail.applied.block_reason, detail.applied.args) == ("", "") + assert logged.changes, "every write is followed by the change callback" + assert logged.changes[-1] == logged.log.records() + + +async def test_a_block_is_recorded_as_success_with_the_blocked_effect(hooks_dir: Path) -> None: + decision = {"action": "block", "reason": "rm denied", "extra_context": "ignored"} + logged = _logged(hooks_dir, _hook("guard", HookEvent.BEFORE_TOOL_CALL, script=_result(decision))) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + assert result.blocked is True + record = logged.only("guard") + assert (record.outcome, record.effect, record.exit_code) == (HookOutcome.SUCCESS, "blocked", 0) + detail = logged.detail("guard") + assert detail.io is not None and detail.applied is not None + assert detail.io.result_file == "ok" + assert json.loads(detail.io.decision) == decision + assert "rm denied" in detail.applied.block_reason + # A block returns at once: nothing else is merged. + assert detail.applied.extra_context == "" + + +async def test_modified_args_records_the_merged_arguments(hooks_dir: Path) -> None: + decision = {"action": "modify", "args_override": {"path": "/safe"}} + logged = _logged(hooks_dir, _hook("rewrite", HookEvent.BEFORE_TOOL_CALL, script=_result(decision))) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"path": "/etc/passwd", "mode": "r"})) + assert result.args_override == {"path": "/safe"} + record = logged.only("rewrite") + assert (record.outcome, record.effect) == (HookOutcome.SUCCESS, "modified_args") + applied = logged.detail("rewrite").applied + assert applied is not None + assert json.loads(applied.args) == {"path": "/safe", "mode": "r"} + + +async def test_returning_the_same_arguments_is_not_a_modification(hooks_dir: Path) -> None: + """Section 3: compared by value before and after the merge, not by whether an override was returned.""" + same = {"action": "modify", "args_override": {"path": "/a"}} + logged = _logged(hooks_dir, _hook("echo-args", HookEvent.BEFORE_TOOL_CALL, script=_result(same))) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"path": "/a"})) + assert logged.only("echo-args").effect is None + + +async def test_a_later_hook_returning_the_already_merged_arguments_did_not_modify_them(hooks_dir: Path) -> None: + first = {"action": "modify", "args_override": {"path": "/b"}} + logged = _logged( + hooks_dir, + _hook("first", HookEvent.BEFORE_TOOL_CALL, script=_result(first)), + _hook("second", HookEvent.BEFORE_TOOL_CALL, script=_result(first)), + ) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"path": "/a"})) + assert logged.only("first").effect == "modified_args" + assert logged.only("second").effect is None + + +async def test_an_added_reminder_is_recorded(hooks_dir: Path) -> None: + decision = {"system_reminder": "remember the style guide"} + logged = _logged(hooks_dir, _hook("remind", HookEvent.USER_PROMPT_SUBMIT, script=_result(decision))) + result = await logged.manager.fire(HookEvent.USER_PROMPT_SUBMIT, {"prompt": "hello"}) + assert result.system_reminders == ["remember the style guide"] + assert logged.only("remind").effect == "added_reminder" + applied = logged.detail("remind").applied + assert applied is not None and applied.reminder == "remember the style guide" + + +@pytest.mark.parametrize("event", [HookEvent.AFTER_TOOL_CALL, HookEvent.TOOL_ERROR]) +async def test_added_context_is_recorded(hooks_dir: Path, event: HookEvent) -> None: + decision = {"extra_context": "ruff found 2 issues"} + logged = _logged(hooks_dir, _hook("ruff", event, script=_result(decision))) + result = await logged.manager.fire(event, _tool("write_file")) + assert result.extra_context == ["ruff found 2 issues"] + record = logged.only("ruff") + assert (record.outcome, record.effect, record.tool_name) == (HookOutcome.SUCCESS, "added_context", "write_file") + applied = logged.detail("ruff").applied + assert applied is not None and applied.extra_context == "ruff found 2 issues" + + +async def test_a_block_on_an_event_that_cannot_block_is_not_recorded_as_blocked(hooks_dir: Path) -> None: + logged = _logged( + hooks_dir, _hook("late-block", HookEvent.AFTER_TURN, script=_result({"action": "block", "reason": "x"})) + ) + result = await logged.manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + assert result.blocked is False + record = logged.only("late-block") + assert (record.outcome, record.effect) == (HookOutcome.SUCCESS, None) + applied = logged.detail("late-block").applied + assert applied is not None and applied.not_applied == "unsupported" + + +@pytest.mark.parametrize( + ("mode", "event", "decision"), + [ + ("async", HookEvent.BEFORE_TOOL_CALL, {"action": "block", "reason": "too late"}), + ("fire_and_forget", HookEvent.AFTER_TOOL_CALL, {"extra_context": "never read"}), + ], + ids=["async-block", "fire-and-forget-context"], +) +async def test_a_nonblocking_runs_decision_is_never_applied( + hooks_dir: Path, mode: str, event: HookEvent, decision: dict[str, object] +) -> None: + logged = _logged(hooks_dir, _hook("observer", event, mode=mode, script=_result(decision))) + result = await logged.manager.fire(event, _tool()) + assert result.blocked is False + await wait_for( + lambda: any(r.hook_id == "observer" and r.state == "ended" for r in logged.log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the nonblocking run ended", + ) + record = logged.only("observer") + assert (record.outcome, record.effect, record.execution_mode) == (HookOutcome.SUCCESS, None, mode) + applied = logged.detail("observer").applied + assert applied is not None and applied.not_applied == "nonblocking" + await logged.manager.drain_turn() + + +async def test_a_failed_run_is_recorded_with_its_exit_code_and_its_result_file_ignored(hooks_dir: Path) -> None: + script = _result({"action": "block", "reason": "ignored"}, exit_code=1) + logged = _logged(hooks_dir, _hook("fails", HookEvent.BEFORE_TOOL_CALL, script=script)) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + assert result.blocked is False + record = logged.only("fails") + assert (record.outcome, record.exit_code, record.effect) == (HookOutcome.FAILED, 1, None) + applied = logged.detail("fails").applied + assert applied is not None and applied.not_applied == "failed" + + +async def test_a_failure_that_blocks_by_on_error_is_failed_and_blocked(hooks_dir: Path) -> None: + logged = _logged( + hooks_dir, + _hook("gate", HookEvent.BEFORE_TOOL_CALL, script="import sys; sys.exit(2)", on_error="block"), + ) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + assert result.blocked is True + record = logged.only("gate") + assert (record.outcome, record.exit_code, record.effect) == (HookOutcome.FAILED, 2, "blocked") + applied = logged.detail("gate").applied + assert applied is not None and applied.not_applied == "failed_blocked" + + +@pytest.mark.parametrize(("on_error", "effect"), [("warn", None), ("block", "blocked")]) +async def test_a_timed_out_run_is_recorded_with_its_partial_output( + hooks_dir: Path, on_error: str, effect: str | None +) -> None: + script = "import sys, time\nprint('half done', flush=True)\ntime.sleep(30)\n" + logged = _logged( + hooks_dir, _hook("slow", HookEvent.BEFORE_TOOL_CALL, script=script, timeout=1.0, on_error=on_error) + ) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + record = logged.only("slow") + assert (record.outcome, record.effect) == (HookOutcome.TIMED_OUT, effect) + io = logged.detail("slow").io + assert io is not None and "half done" in io.stdout + + +async def test_a_run_that_could_not_start_is_recorded_as_a_launch_error(hooks_dir: Path) -> None: + logged = _logged( + hooks_dir, _hook("gone", HookEvent.BEFORE_TOOL_CALL, argv=["/definitely/not/a/real/chrys-hook-command"]) + ) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + record = logged.only("gone") + assert (record.outcome, record.state, record.effect) == (HookOutcome.LAUNCH_ERROR, "ended", None) + + +async def test_a_cancelled_running_run_is_recorded_cancelled_and_reported(hooks_dir: Path, release: Path) -> None: + logged = _logged(hooks_dir, _hook("held", HookEvent.BEFORE_TOOL_CALL, script=_held(release))) + fire = asyncio.create_task(logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool())) + await wait_for( + lambda: any(r.state == "running" for r in logged.log.records()) or fire.done(), + timeout=ENGINE_TURN_TIMEOUT, + description="the run running", + ) + assert not fire.done() + fire.cancel() + with contextlib.suppress(asyncio.CancelledError): + await fire + record = logged.only("held") + assert (record.state, record.outcome) == ("ended", HookOutcome.CANCELLED) + assert record.started_at is not None and record.ended_at is not None + # Reported by the time the cancelled task finished. + assert logged.changes[-1] == logged.log.records() + + +@pytest.mark.parametrize("runs", [1, 2]) +async def test_runs_a_zero_grace_drain_cancels_before_their_first_step_are_recorded_cancelled( + hooks_dir: Path, release: Path, runs: int +) -> None: + """With no grace the drain cancels a run's task before its body runs: the drain ends its record.""" + hooks = [_hook(f"end-{i}", HookEvent.SESSION_END, mode="async", script=_held(release)) for i in range(runs)] + logged = _logged(hooks_dir, *hooks, shutdown_grace_seconds=0) + await logged.manager.fire(HookEvent.SESSION_END, {}, scope="session") + assert {record.state for record in logged.log.records()} == {"queued"} + # No await in between: the drain gets there before the runs' tasks take a step. + await asyncio.wait_for(logged.manager.drain_session(close=False), ENGINE_TURN_TIMEOUT) + records = logged.log.records() + assert len(records) == runs + assert {(record.state, record.outcome, record.started_at) for record in records} == { + ("ended", HookOutcome.CANCELLED, None) + } + # Reported once the drain ended them. + assert logged.changes[-1] == records + await logged.manager.drain_session() + + +@pytest.mark.skipif(sys.platform == "win32", reason="detached worker timing is flaky on Windows CI") +async def test_a_detached_run_is_recorded_as_detached_with_its_log(hooks_dir: Path) -> None: + hook = _hook("handed-off", HookEvent.SESSION_END, mode="fire_and_forget", detach=True, script="print('x')") + logged = _logged(hooks_dir, hook) + await logged.manager.fire(HookEvent.SESSION_END, {"session_id": _SESSION}, scope="session") + record = logged.only("handed-off") + assert (record.state, record.outcome, record.detach) == ("ended", HookOutcome.DETACHED, True) + detail = logged.detail("handed-off") + assert detail.log_path + assert Path(detail.log_path).parent == hooks_dir / "logs" / _SESSION + assert logged.changes + + +async def test_a_failed_detached_hand_off_is_recorded_as_spawn_failed( + hooks_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + async def refuse(*_args: object, **_kwargs: object) -> Path: + raise OSError("no more processes") + + monkeypatch.setattr(HookRunner, "spawn_detached", refuse) + hook = _hook("handed-off", HookEvent.SESSION_END, mode="fire_and_forget", detach=True) + logged = _logged(hooks_dir, hook) + await logged.manager.fire(HookEvent.SESSION_END, {"session_id": _SESSION}, scope="session") + record = logged.only("handed-off") + assert (record.state, record.outcome) == ("ended", HookOutcome.SPAWN_FAILED) + + +# --- The trajectory join (5.1) -------------------------------------------------- + + +async def test_run_ids_are_the_trajectory_operation_ids_and_outcomes_match(hooks_dir: Path) -> None: + logged = _logged( + hooks_dir, + _hook("ok", HookEvent.BEFORE_TOOL_CALL, script="pass", tool_name="a"), + _hook("fails", HookEvent.BEFORE_TOOL_CALL, script="import sys; sys.exit(4)", tool_name="b"), + _hook("gone", HookEvent.BEFORE_TOOL_CALL, argv=["/definitely/not/a/real/chrys-hook"], tool_name="c"), + _hook("slow", HookEvent.BEFORE_TOOL_CALL, script="import time; time.sleep(30)", timeout=0.5, tool_name="d"), + _hook("guard", HookEvent.BEFORE_TOOL_CALL, script=_result({"action": "block", "reason": "no"}), tool_name="e"), + ) + sink = FakeSink() + with trajectory_scope(make_context(sink)): + for name in "abcde": + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(name)) + finished = {draft.operation_id: draft.payload for draft in sink.of_type(EventType.HOOK_OPERATION_FINISHED)} + started = {draft.operation_id: draft.payload for draft in sink.of_type(EventType.HOOK_OPERATION_STARTED)} + records = {record.hook_id: record for record in logged.log.records()} + assert set(records) == {"ok", "fails", "gone", "slow", "guard"} + for record in records.values(): + assert record.run_id in finished, record + assert started[record.run_id]["hook_key"] == record.hook_id + trajectory_outcome = finished[record.run_id]["outcome"] + if record.effect == "blocked" and record.outcome == HookOutcome.SUCCESS: + assert trajectory_outcome == HookOutcome.BLOCKED + else: + assert trajectory_outcome == record.outcome, record + assert records["guard"].outcome == HookOutcome.SUCCESS + assert records["guard"].effect == "blocked" + + +async def test_runs_are_recorded_without_a_trajectory(hooks_dir: Path) -> None: + logged = _logged(hooks_dir, _hook("a", HookEvent.AFTER_TURN), _hook("b", HookEvent.AFTER_TURN)) + await logged.manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + records = logged.log.records() + assert {record.hook_id for record in records} == {"a", "b"} + assert len({record.run_id for record in records}) == 2 + assert all(record.run_id for record in records) + + +# --- Configured fields of the detail (section 4) -------------------------------- + + +async def test_the_detail_carries_the_configured_run_but_only_env_names(hooks_dir: Path) -> None: + hook = HookConfig( + id="configured", + event=HookEvent.BEFORE_TOOL_CALL, + run=HookRun( + type="command", + argv=[sys.executable, "-c", "pass"], + env={"API_TOKEN": "sk-very-secret-value", "MODE": "strict-mode-value"}, + cwd=str(hooks_dir), + ), + execution=HookExecution(mode="blocking", timeout_seconds=12.5, on_error="block"), + ) + logged = _logged(hooks_dir, hook) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + detail = logged.detail("configured") + assert detail.run_type == "command" + assert detail.run_argv == (sys.executable, "-c", "pass") + assert detail.run_cwd == str(hooks_dir) + assert set(detail.run_env_names) == {"API_TOKEN", "MODE"} + assert (detail.timeout_seconds, detail.on_error, detail.delivery) == (12.5, "block", "best_effort") + assert "sk-very-secret-value" not in repr(detail) + assert "strict-mode-value" not in repr(detail) + + +async def test_the_detail_names_the_source_of_each_hook(hooks_dir: Path) -> None: + project_hook = _hook("from-project", HookEvent.AFTER_TURN) + global_hook = _hook("from-global", HookEvent.AFTER_TURN) + manager = HookManager( + file=MergedHooksFile( + project=HooksFile(hooks=[project_hook], source="/project/.chrys/hooks/hooks.yaml"), + global_=HooksFile(hooks=[global_hook], source="/config/hooks/hooks.yaml"), + hooks=[project_hook, global_hook], + ), + hooks_dir=hooks_dir, + ) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + by_id = {record.hook_id: log.detail(record.run_id) for record in log.records()} + project, global_ = by_id["from-project"], by_id["from-global"] + assert project is not None and global_ is not None + assert (project.source, project.source_path) == ("project", "/project/.chrys/hooks/hooks.yaml") + assert (global_.source, global_.source_path) == ("global", "/config/hooks/hooks.yaml") + + +# --- Callbacks only observe (5.2) ----------------------------------------------- + + +async def test_a_raising_change_callback_changes_neither_the_decision_nor_the_trajectory(hooks_dir: Path) -> None: + script = _result({"action": "modify", "args_override": {"cmd": "safe"}}) + + async def run(manager: HookManager) -> tuple[object, list[str], object]: + sink = FakeSink() + with trajectory_scope(make_context(sink)): + decision = await manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + outcome = sink.only(EventType.HOOK_OPERATION_FINISHED).payload["outcome"] + return decision, [str(kind) for kind in sink.event_types], outcome + + control = HookManager( + file=HooksFile(hooks=[_hook("rewrite", HookEvent.BEFORE_TOOL_CALL, script=script)]), hooks_dir=hooks_dir + ) + expected = await run(control) + + observed = HookManager( + file=HooksFile(hooks=[_hook("rewrite", HookEvent.BEFORE_TOOL_CALL, script=script)]), hooks_dir=hooks_dir + ) + calls: list[str] = [] + + async def explode() -> None: + calls.append("runs") + raise RuntimeError("subscriber exploded") + + log = HookRunLog() + observed.record_runs(log, session_id=_SESSION, on_change=explode) + actual = await run(observed) + assert calls + assert actual == expected + (record,) = log.records() + assert (record.state, record.effect) == ("ended", "modified_args") diff --git a/tests/service/hooks/test_run_log_retention.py b/tests/service/hooks/test_run_log_retention.py new file mode 100644 index 000000000..f5d4fc63e --- /dev/null +++ b/tests/service/hooks/test_run_log_retention.py @@ -0,0 +1,494 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""What the hook run log keeps: queue timing, the payload as registered, and its row and detail limits. + +Every hook is a real ``python -c`` subprocess; a held hook polls for a release +file the test creates. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import sys +import textwrap +import time +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.manager import HookManager +from chrys.service.hooks.run_log import ( + HOOK_RUN_LOG_MAX_DETAIL_CHARS, + HOOK_RUN_LOG_MAX_ENDED, + HOOK_RUN_PAYLOAD_MAX_CHARS, + HookRunLog, +) +from chrys.service.hooks.runner import HookRunner +from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HookSettings, HooksFile +from chrys.service.trajectory.hooks import HookOutcome +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for + +_SESSION = "s1" + + +def _held(release: Path) -> str: + return textwrap.dedent( + f""" + import os, time + while not os.path.exists({str(release)!r}): + time.sleep(0.01) + """ + ) + + +def _copy_payload(target: Path, then: str = "") -> str: + return ( + textwrap.dedent( + f""" + import json, os + with open(os.environ["CHRYS_HOOK_PAYLOAD_FILE"], "rb") as source: + data = source.read() + with open({str(target)!r}, "wb") as copy: + copy.write(data) + """ + ) + + then + ) + + +def _hook( + hook_id: str, + event: HookEvent, + *, + mode: str = "blocking", + script: str = "pass", + tool_name: str | None = None, + delivery: str = "best_effort", +) -> HookConfig: + return HookConfig( + id=hook_id, + event=event, + run=HookRun(type="command", argv=[sys.executable, "-c", script]), + execution=HookExecution(mode=mode, timeout_seconds=30.0, delivery=delivery), + match=HookMatch(tool_name=tool_name), + ) + + +def _manager(hooks_dir: Path, *hooks: HookConfig, **settings: float) -> HookManager: + return HookManager(file=HooksFile(settings=HookSettings(**settings), hooks=list(hooks)), hooks_dir=hooks_dir) + + +def _tool(name: str = "zsh", args: dict[str, object] | None = None) -> dict[str, object]: + return {"tool": {"name": name, "kind": "shell", "call_id": "c1", "args": args or {}}} + + +def _by_id(log: HookRunLog, hook_id: str) -> list[HookRunRecord]: + return [record for record in log.records() if record.hook_id == hook_id] + + +@pytest.fixture +def hooks_dir(tmp_path: Path) -> Path: + path = tmp_path / "hooks" + path.mkdir() + return path + + +@pytest.fixture +async def release(tmp_path: Path) -> AsyncIterator[Path]: + path = tmp_path / "release" + yield path + path.touch() + + +def test_the_limits_are_the_planned_ones() -> None: + assert HOOK_RUN_LOG_MAX_ENDED == 100 + assert HOOK_RUN_LOG_MAX_DETAIL_CHARS == 16 * 1024 * 1024 + assert HOOK_RUN_PAYLOAD_MAX_CHARS == 64 * 1024 + + +# --- Queueing (max_parallel_hooks=1) -------------------------------------------- + + +async def test_a_queued_run_has_no_start_until_it_takes_the_slot(hooks_dir: Path, release: Path) -> None: + manager = _manager( + hooks_dir, + _hook("holder", HookEvent.AFTER_TOOL_CALL, mode="fire_and_forget", script=_held(release)), + _hook("waiting", HookEvent.BEFORE_TOOL_CALL), + max_parallel_hooks=1, + ) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.AFTER_TOOL_CALL, _tool()) + await wait_for( + lambda: any(r.hook_id == "holder" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the holder running", + ) + wall_before = time.time() + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool())) + try: + await wait_for( + lambda: bool(_by_id(log, "waiting")) or fire.done(), timeout=ENGINE_TURN_TIMEOUT, description="queued" + ) + (queued,) = _by_id(log, "waiting") + assert queued.state == "queued" + assert queued.started_at is None and queued.ended_at is None + assert wall_before <= queued.registered_wall <= time.time() + detail = log.detail(queued.run_id) + assert detail is not None and detail.io is not None, "a queued run's input is already kept" + assert detail.applied is None + seen_queued_at = time.monotonic() + release.touch() + await asyncio.wait_for(fire, ENGINE_TURN_TIMEOUT) + finally: + release.touch() + (ended,) = _by_id(log, "waiting") + assert ended.state == "ended" + assert ended.registered_at == queued.registered_at + assert ended.started_at is not None and ended.started_at > seen_queued_at + await manager.drain_session() + + +async def test_a_run_cancelled_while_queued_never_started(hooks_dir: Path, release: Path) -> None: + manager = _manager( + hooks_dir, + _hook("holder", HookEvent.AFTER_TOOL_CALL, mode="fire_and_forget", script=_held(release)), + _hook("waiting", HookEvent.BEFORE_TOOL_CALL), + max_parallel_hooks=1, + ) + log = HookRunLog() + changes: list[tuple[HookRunRecord, ...]] = [] + + async def on_change() -> None: + changes.append(log.records()) + + manager.record_runs(log, session_id=_SESSION, on_change=on_change) + await manager.fire(HookEvent.AFTER_TOOL_CALL, _tool()) + await wait_for( + lambda: any(r.hook_id == "holder" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the holder running", + ) + fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool())) + await wait_for( + lambda: any(r.hook_id == "waiting" and r.state == "queued" for r in log.records()) or fire.done(), + timeout=ENGINE_TURN_TIMEOUT, + description="queued", + ) + fire.cancel() + with contextlib.suppress(asyncio.CancelledError): + await fire + (record,) = _by_id(log, "waiting") + assert (record.state, record.outcome, record.started_at) == ("ended", HookOutcome.CANCELLED, None) + assert changes[-1] == log.records() + release.touch() + await manager.drain_session() + + +# --- The payload as registered (5.3) -------------------------------------------- + + +async def test_the_payload_is_what_the_hook_received_at_registration(hooks_dir: Path, tmp_path: Path) -> None: + first_received, second_received = tmp_path / "first", tmp_path / "second" + rewrite = ( + 'with open(os.environ["CHRYS_HOOK_RESULT"], "w") as f:\n' + ' json.dump({"action": "modify", "args_override": {"path": "/b"}}, f)\n' + ) + manager = _manager( + hooks_dir, + _hook("rewrite", HookEvent.BEFORE_TOOL_CALL, script=_copy_payload(first_received, rewrite)), + _hook("after-rewrite", HookEvent.BEFORE_TOOL_CALL, script=_copy_payload(second_received)), + ) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"path": "/a"})) + + (first,) = _by_id(log, "rewrite") + (second,) = _by_id(log, "after-rewrite") + first_detail, second_detail = log.detail(first.run_id), log.detail(second.run_id) + assert first_detail is not None and first_detail.io is not None + assert second_detail is not None and second_detail.io is not None + # Byte for byte what each hook read from its payload file. + assert first_detail.io.payload == first_received.read_text(encoding="utf-8") + assert second_detail.io.payload == second_received.read_text(encoding="utf-8") + # The earlier run's payload is not changed by the later merge. + assert json.loads(first_detail.io.payload)["tool"]["args"]["path"] == "/a" + assert json.loads(second_detail.io.payload)["tool"]["args"]["path"] == "/b" + assert first_detail.io.payload_size == len(first_detail.io.payload) + + +async def test_a_large_payload_is_cut_and_its_size_kept(hooks_dir: Path, tmp_path: Path) -> None: + received = tmp_path / "received" + manager = _manager(hooks_dir, _hook("big", HookEvent.BEFORE_TOOL_CALL, script=_copy_payload(received))) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"content": "x" * 200_000})) + (record,) = log.records() + detail = log.detail(record.run_id) + assert detail is not None and detail.io is not None + full = received.read_text(encoding="utf-8") + assert len(full) > 200_000, "the hook itself gets the whole payload" + assert len(detail.io.payload) <= HOOK_RUN_PAYLOAD_MAX_CHARS + assert full.startswith(detail.io.payload) + assert detail.io.payload_size == len(full) + + +async def test_without_a_log_nothing_is_serialized_for_it(hooks_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str | None] = [] + original = HookRunner.run_and_wait + + async def spy(self: HookRunner, hook: HookConfig, payload: dict[str, object], *, payload_text: str | None = None): + seen.append(payload_text) + return await original(self, hook, payload, payload_text=payload_text) + + monkeypatch.setattr(HookRunner, "run_and_wait", spy) + unrecorded = _manager(hooks_dir, _hook("plain", HookEvent.AFTER_TURN)) + await unrecorded.fire(HookEvent.AFTER_TURN, {"turn": 1}) + assert seen == [None] + + recorded = _manager(hooks_dir, _hook("plain", HookEvent.AFTER_TURN)) + log = HookRunLog() + recorded.record_runs(log, session_id=_SESSION) + await recorded.fire(HookEvent.AFTER_TURN, {"turn": 1}) + assert seen[1] is not None + (record,) = log.records() + detail = log.detail(record.run_id) + assert detail is not None and detail.io is not None and detail.io.payload == seen[1] + + +# --- Rows kept ------------------------------------------------------------------ + + +async def test_only_the_latest_ended_runs_are_kept_and_running_ones_do_not_count( + hooks_dir: Path, release: Path +) -> None: + manager = _manager( + hooks_dir, + _hook("long", HookEvent.SESSION_START, mode="async", script=_held(release)), + _hook("quick", HookEvent.AFTER_TURN), + ) + log = HookRunLog(max_ended=3) + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.SESSION_START, {}, scope="session") + await wait_for( + lambda: any(r.hook_id == "long" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the long run running", + ) + run_ids: list[str] = [] + for turn in range(5): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + run_ids.extend(r.run_id for r in _by_id(log, "quick") if r.run_id not in run_ids) + assert len(run_ids) == 5 + kept = _by_id(log, "quick") + assert {record.run_id for record in kept} == set(run_ids[-3:]) + assert [r.hook_id for r in log.records() if r.state != "ended"] == ["long"] + assert log.record(run_ids[0]) is None + assert log.detail(run_ids[0]) is None + # Numbered in registration order from 1; the numbers stay as older runs are dropped. + assert _by_id(log, "long")[0].number == 1 + assert [record.number for record in sorted(kept, key=lambda r: r.number)] == [4, 5, 6] + release.touch() + await asyncio.wait_for(manager.drain_session(), ENGINE_TURN_TIMEOUT) + # Once ended it counts, and the oldest of the quick ones goes. + assert {r.run_id for r in log.records()} == {*run_ids[-2:], _by_id(log, "long")[0].run_id} + + +async def test_dropped_counts_only_ended_runs_dropped_past_the_cap(hooks_dir: Path, release: Path) -> None: + manager = _manager( + hooks_dir, + _hook("long", HookEvent.SESSION_START, mode="async", script=_held(release)), + _hook("quick", HookEvent.AFTER_TURN), + ) + log = HookRunLog(max_ended=3) + assert log.dropped == 0 + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.SESSION_START, {}, scope="session") + await wait_for( + lambda: any(r.hook_id == "long" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the long run running", + ) + for turn in range(3): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + # Three ended at the cap, one running beside them: nothing dropped yet. + assert len(log.records()) == 4 + assert log.dropped == 0 + for turn in range(3, 5): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + assert log.dropped == 2 + release.touch() + await asyncio.wait_for(manager.drain_session(), ENGINE_TURN_TIMEOUT) + # The long run ended and counts now: one more ended run goes. + assert log.dropped == 3 + assert len(log.records()) == 3 + + +async def test_dropped_does_not_count_queued_runs(hooks_dir: Path, release: Path) -> None: + manager = _manager( + hooks_dir, + _hook("holder", HookEvent.AFTER_TOOL_CALL, mode="fire_and_forget", script=_held(release)), + _hook("waiting", HookEvent.BEFORE_TOOL_CALL), + max_parallel_hooks=1, + ) + log = HookRunLog(max_ended=1) + manager.record_runs(log, session_id=_SESSION) + await manager.fire(HookEvent.AFTER_TOOL_CALL, _tool()) + await wait_for( + lambda: any(r.hook_id == "holder" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the holder running", + ) + fires = [asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool())) for _ in range(3)] + try: + await wait_for( + lambda: len([r for r in log.records() if r.state == "queued"]) == 3 or any(f.done() for f in fires), + timeout=ENGINE_TURN_TIMEOUT, + description="three queued", + ) + assert [r.state for r in _by_id(log, "waiting")] == ["queued"] * 3 + assert log.dropped == 0, "queued and running runs are never dropped" + release.touch() + await asyncio.wait_for(asyncio.gather(*fires), ENGINE_TURN_TIMEOUT) + finally: + release.touch() + await asyncio.wait_for(manager.drain_session(), ENGINE_TURN_TIMEOUT) + # Four ended, one kept. + assert len(log.records()) == 1 + assert log.dropped == 3 + + +async def test_records_stay_in_registration_order_as_runs_start_and_end(hooks_dir: Path, tmp_path: Path) -> None: + releases = {name: tmp_path / f"release-{name}" for name in ("a", "b", "c", "d")} + manager = _manager( + hooks_dir, + *( + _hook(name, HookEvent.AFTER_TOOL_CALL, mode="fire_and_forget", script=_held(path), tool_name=name) + for name, path in releases.items() + ), + max_parallel_hooks=3, + ) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + try: + for name in releases: + await manager.fire(HookEvent.AFTER_TOOL_CALL, _tool(name)) + await wait_for( + lambda name=name: bool(_by_id(log, name)), timeout=ENGINE_TURN_TIMEOUT, description=f"{name} in" + ) + # End b first, then a: c, d stay active (d queued behind three slots, then running). + releases["b"].touch() + await wait_for(lambda: _by_id(log, "b")[0].state == "ended", timeout=ENGINE_TURN_TIMEOUT, description="b") + releases["a"].touch() + await wait_for(lambda: _by_id(log, "a")[0].state == "ended", timeout=ENGINE_TURN_TIMEOUT, description="a") + # Oldest first, each where it was registered, however and whenever it ended. + assert [r.hook_id for r in log.records()] == ["a", "b", "c", "d"] + assert [r.number for r in log.records()] == [1, 2, 3, 4] + finally: + for path in releases.values(): + path.touch() + await asyncio.wait_for(manager.drain_session(), ENGINE_TURN_TIMEOUT) + + +# --- Details kept --------------------------------------------------------------- + + +def _printer(size: int) -> str: + return f"import sys\nsys.stdout.write('o' * {size})\n" + + +async def test_details_over_the_cap_are_dropped_from_the_oldest_ended_run(hooks_dir: Path) -> None: + manager = _manager(hooks_dir, _hook("noisy", HookEvent.AFTER_TURN, script=_printer(4000))) + log = HookRunLog(max_detail_chars=10_000) + manager.record_runs(log, session_id=_SESSION) + for turn in range(5): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + records = log.records() + assert len(records) == 5, "every row stays; only details go" + newest_first = [log.detail(record.run_id) for record in reversed(records)] + assert all(detail is not None for detail in newest_first) + kept = [detail.io is not None for detail in newest_first if detail is not None] + # Newest kept, oldest dropped, and nothing older kept once one is dropped. + assert kept[0] is True and kept[-1] is False + assert kept == sorted(kept, reverse=True) + gone = next(detail for detail in newest_first if detail is not None and detail.io is None) + assert gone.record.outcome == HookOutcome.SUCCESS + assert gone.run_argv + + +async def test_details_trimmed_by_size_do_not_count_as_dropped_runs(hooks_dir: Path) -> None: + manager = _manager(hooks_dir, _hook("noisy", HookEvent.AFTER_TURN, script=_printer(4000))) + log = HookRunLog(max_detail_chars=10_000) + manager.record_runs(log, session_id=_SESSION) + for turn in range(5): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + assert any((detail := log.detail(record.run_id)) is not None and detail.io is None for record in log.records()), ( + "some details were trimmed" + ) + assert len(log.records()) == 5 + assert log.dropped == 0 + + +async def test_a_running_runs_detail_is_never_dropped(hooks_dir: Path, release: Path) -> None: + manager = _manager( + hooks_dir, + _hook("long", HookEvent.BEFORE_TOOL_CALL, mode="async", script=_held(release)), + _hook("noisy", HookEvent.AFTER_TURN, script=_printer(4000)), + ) + log = HookRunLog(max_detail_chars=10_000) + manager.record_runs(log, session_id=_SESSION) + # Its payload alone is over the cap. + await manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"content": "y" * 12_000})) + await wait_for( + lambda: any(r.hook_id == "long" and r.state == "running" for r in log.records()), + timeout=ENGINE_TURN_TIMEOUT, + description="the long run running", + ) + for turn in range(4): + await manager.fire(HookEvent.AFTER_TURN, {"turn": turn}) + (long,) = _by_id(log, "long") + detail = log.detail(long.run_id) + assert detail is not None and detail.io is not None + assert "y" * 1000 in detail.io.payload + release.touch() + await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) + + +# --- Outbox retries and sessions ------------------------------------------------ + + +async def test_an_outbox_retry_is_recorded_as_a_retry(hooks_dir: Path) -> None: + hook = _hook("durable", HookEvent.AFTER_TURN, mode="async", delivery="durable") + manager = _manager(hooks_dir, hook, outbox_retry_age_seconds=0.0) + manager._outbox.write_pending(hook_id="durable", event=str(HookEvent.AFTER_TURN), payload={"turn": 1}) + log = HookRunLog() + manager.record_runs(log, session_id=_SESSION) + assert await manager.recover_outbox() == 1 + await wait_for( + lambda: any(r.state == "ended" for r in log.records()), timeout=ENGINE_TURN_TIMEOUT, description="retried" + ) + (record,) = log.records() + assert (record.hook_id, record.retry, record.outcome) == ("durable", True, HookOutcome.SUCCESS) + + await manager.fire(HookEvent.AFTER_TURN, {"turn": 2}) + await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) + fresh = [r for r in log.records() if r.run_id != record.run_id] + assert [r.retry for r in fresh] == [False] + await manager.drain_session() + + +async def test_one_log_holds_the_runs_of_every_session(hooks_dir: Path) -> None: + log = HookRunLog() + first = _manager(hooks_dir, _hook("lint", HookEvent.AFTER_TURN)) + first.record_runs(log, session_id="session-a") + second = _manager(hooks_dir, _hook("lint", HookEvent.AFTER_TURN)) + second.record_runs(log, session_id="session-b") + await first.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await second.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await first.fire(HookEvent.AFTER_TURN, {"turn": 2}) + assert [record.session_id for record in log.records()] == ["session-a", "session-b", "session-a"] diff --git a/tests/service/hooks/test_runner_capture.py b/tests/service/hooks/test_runner_capture.py new file mode 100644 index 000000000..c78a92a3a --- /dev/null +++ b/tests/service/hooks/test_runner_capture.py @@ -0,0 +1,237 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""What the hook runner keeps of a run for the Hooks panel's details. + +Captured output survives a timeout and a stopped process, the bytes cut from +the middle of each stream are counted, a missing result file is told apart from +an invalid one, a detached hand-off returns its log path, and a given +``payload_text`` is what the hook reads, byte for byte. Hooks are real +``python -c`` subprocesses. +""" + +from __future__ import annotations + +import json +import sys +import textwrap +from pathlib import Path + +import pytest + +from chrys.service.hooks import runner as runner_mod +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.runner import HookRunner +from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for + + +@pytest.fixture +def hooks_dir(tmp_path: Path) -> Path: + path = tmp_path / "hooks" + path.mkdir() + return path + + +@pytest.fixture +def runner(hooks_dir: Path) -> HookRunner: + return HookRunner(hooks_dir=hooks_dir) + + +def _hook(script: str, *, timeout: float = ENGINE_TURN_TIMEOUT, hook_id: str = "t") -> HookConfig: + return HookConfig( + id=hook_id, + event=HookEvent.BEFORE_TOOL_CALL, + run=HookRun(type="command", argv=[sys.executable, "-c", script]), + execution=HookExecution(mode="blocking", timeout_seconds=timeout), + match=HookMatch(), + ) + + +def _detached_hook(script: str, *, hook_id: str = "handed-off") -> HookConfig: + return HookConfig( + id=hook_id, + event=HookEvent.SESSION_END, + run=HookRun(type="command", argv=[sys.executable, "-c", script]), + execution=HookExecution(mode="fire_and_forget", detach=True, timeout_seconds=ENGINE_TURN_TIMEOUT), + match=HookMatch(), + ) + + +def _write_result(text: str) -> str: + return f"import os\nopen(os.environ['CHRYS_HOOK_RESULT'], 'w').write({text!r})\n" + + +# --- Output kept when the run does not end by itself (5.3) ---------------------- + + +async def test_a_timed_out_run_keeps_what_it_printed_before_the_timeout(runner: HookRunner) -> None: + script = textwrap.dedent( + """ + import sys, time + sys.stdout.write("stdout before the timeout\\n") + sys.stdout.flush() + sys.stderr.write("stderr before the timeout\\n") + sys.stderr.flush() + time.sleep(30) + """ + ) + result = await runner.run_and_wait(_hook(script, timeout=1.0), {}) + assert result.timed_out is True + assert "stdout before the timeout" in result.stdout + assert "stderr before the timeout" in result.stderr + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups and SIGSTOP only") +async def test_a_run_whose_process_stopped_keeps_what_it_printed(runner: HookRunner, tmp_path: Path) -> None: + pid_file = tmp_path / "child.pid" + script = textwrap.dedent( + f""" + import subprocess, sys + sys.stdout.write("printed before the stop\\n") + sys.stdout.flush() + sys.stderr.write("warned before the stop\\n") + sys.stderr.flush() + subprocess.Popen([ + sys.executable, + "-c", + "import os, pathlib, signal, time; pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid())); " + "os.kill(os.getpid(), signal.SIGSTOP); time.sleep(30)", + ]) + """ + ) + result = await runner.run_and_wait(_hook(script, timeout=10), {}) + assert result.timed_out is True + assert "printed before the stop" in result.stdout + assert "warned before the stop" in result.stderr + + +# --- Bytes dropped from the middle ---------------------------------------------- + + +async def test_the_bytes_cut_from_each_stream_are_counted(runner: HookRunner, monkeypatch: pytest.MonkeyPatch) -> None: + limit = 3000 + monkeypatch.setattr(runner_mod, "HOOK_OUTPUT_CAPTURE_LIMIT_BYTES", limit) + lines = 2000 + out_line = "out %04d " + "o" * 80 + "\n" + err_line = "err %04d " + "e" * 40 + "\n" + script = textwrap.dedent( + f""" + import sys + for i in range({lines}): + sys.stdout.write({out_line!r} % i) + sys.stderr.write({err_line!r} % i) + """ + ) + out_total = sum(len((out_line % i).encode()) for i in range(lines)) + err_total = sum(len((err_line % i).encode()) for i in range(lines)) + + result = await runner.run_and_wait(_hook(script), {}) + + assert result.exit_code == 0 + assert out_total - limit <= result.stdout_dropped < out_total + assert err_total - limit <= result.stderr_dropped < err_total + assert result.stdout_dropped != result.stderr_dropped + + +async def test_nothing_is_counted_as_dropped_when_the_output_fits(runner: HookRunner) -> None: + result = await runner.run_and_wait(_hook("import sys; print('short'); sys.stderr.write('also short')"), {}) + assert "short" in result.stdout + assert (result.stdout_dropped, result.stderr_dropped) == (0, 0) + + +# --- Result file status --------------------------------------------------------- + + +@pytest.mark.parametrize( + ("script", "status", "decision"), + [ + ("pass", "missing", {}), + (_write_result(""), "missing", {}), + (_write_result('{"action": "block", "reason": "no"}'), "ok", {"action": "block", "reason": "no"}), + (_write_result("{}"), "ok", {}), + (_write_result("this is not json"), "invalid", {}), + (_write_result('["an", "array"]'), "invalid", {}), + (_write_result('"a string"'), "invalid", {}), + ], + ids=["absent", "empty", "object", "empty-object", "not-json", "array", "string"], +) +async def test_no_result_file_is_told_apart_from_an_invalid_one( + runner: HookRunner, script: str, status: str, decision: dict[str, object] +) -> None: + result = await runner.run_and_wait(_hook(script), {}) + assert result.exit_code == 0 + assert result.result_file == status + assert result.decision == decision + + +async def test_a_result_file_over_the_size_limit_is_invalid( + runner: HookRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(runner_mod, "HOOK_RESULT_FILE_MAX_BYTES", 100) + text = json.dumps({"action": "block", "reason": "x" * 200}) + result = await runner.run_and_wait(_hook(_write_result(text)), {}) + assert result.result_file == "invalid" + assert result.decision == {} + + +# --- payload_text --------------------------------------------------------------- + + +def _copy_payload_to(target: Path) -> str: + return textwrap.dedent( + f""" + import os + with open(os.environ["CHRYS_HOOK_PAYLOAD_FILE"], "rb") as source: + data = source.read() + with open({str(target)!r}, "wb") as copy: + copy.write(data) + """ + ) + + +async def test_a_given_payload_text_is_written_to_the_payload_file_as_it_is(runner: HookRunner, tmp_path: Path) -> None: + received = tmp_path / "received" + payload = {"event": "before_tool_call", "session_id": "s1", "profile": "Code", "tool": {"args": {"p": "é"}}} + # Spacing and key order no serializer of *payload* would produce, and a non-ASCII character. + text = '{ "tool":{"args": {"p":"é"}} ,"event":"before_tool_call", "session_id":"s1","profile":"Code"}' + result = await runner.run_and_wait(_hook(_copy_payload_to(received)), payload, payload_text=text) + assert result.exit_code == 0 + assert received.read_bytes() == text.encode("utf-8") + + +async def test_without_payload_text_the_payload_is_serialized_as_before(runner: HookRunner, tmp_path: Path) -> None: + received = tmp_path / "received" + payload = {"event": "before_tool_call", "tool": {"args": {"path": Path("/x/y"), "n": 1}}} + result = await runner.run_and_wait(_hook(_copy_payload_to(received)), payload) + assert result.exit_code == 0 + assert received.read_bytes() == json.dumps(payload, default=str).encode("utf-8") + + +# --- Detached hand-off ---------------------------------------------------------- + + +@pytest.mark.skipif(sys.platform == "win32", reason="detached worker timing is flaky on Windows CI") +async def test_a_detached_hand_off_returns_its_log_path(runner: HookRunner, hooks_dir: Path) -> None: + script = "print('written by the detached hook', flush=True)" + log_path = await runner.spawn_detached(_detached_hook(script), {"session_id": "abc", "event": "session_end"}) + assert isinstance(log_path, Path) + assert log_path.parent == hooks_dir / "logs" / "abc" + assert log_path.name.startswith("handed-off.") and log_path.suffix == ".log" + await wait_for( + lambda: log_path.exists() and "written by the detached hook" in log_path.read_text(encoding="utf-8"), + timeout=ENGINE_TURN_TIMEOUT, + description="the detached hook wrote its log", + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="detached worker timing is flaky on Windows CI") +async def test_a_detached_hand_off_writes_the_given_payload_text_as_it_is(runner: HookRunner, tmp_path: Path) -> None: + received = tmp_path / "received" + payload = {"session_id": "abc", "event": "session_end"} + text = '{"event":"session_end", "session_id":"abc"}' + await runner.spawn_detached(_detached_hook(_copy_payload_to(received)), payload, payload_text=text) + await wait_for( + lambda: received.exists() and received.read_bytes() == text.encode("utf-8"), + timeout=ENGINE_TURN_TIMEOUT, + description="the detached hook read the given payload text", + ) From 3c2c0d338515f697490780825b998c6e2a050d47 Mon Sep 17 00:00:00 2001 From: "Jiaqi (0x7c13) Liu" Date: Sat, 24 Oct 2026 03:30:20 +0800 Subject: [PATCH 2/2] fix(hooks): end each recorded run once, and finish the Hooks panel Review fixes for the Hooks panel, made at their causes: - The manager ends a run in one step (`_finish_run`), before anything is awaited: the indicator, the run log and the trajectory span agree, an interrupt while reporting the end no longer logs a finished async run as cancelled, and the span still closes. A detached run interrupted while it is handed off is recorded cancelled. A run releases its payload once it ended. A payload JSON cannot write fails the run, not the caller's tool call. The sub-agent is named from the invocation origin, so it has one name on every event. A cancelled run keeps what the hook printed until then. Changed arguments are compared as the JSON the tool gets whenever a hook returned an override, not as deep copies or by dict equality: arguments nested past the recursion limit no longer strand the hooks after them, and 1 turned to true is a change. Whether the span says the arguments changed is settled before the run ends, so nothing raises between a run's end and its report. Only a string names an action; any other value names none. A pending outbox job whose payload is no object fails as it does unrecorded, and the jobs after it still run. A result the manager cannot take (a reason nested too deep to write as text) ends and reports its run before the error goes on to the caller, as it did before. - The run log counts every text a detail keeps, what a run applied included, toward its budget; changed arguments and block reasons are cut at 64 KB like payloads, so the reasons ended runs keep when their details go always fit the budget; a cut keeps the lone surrogates of a path that is not UTF-8. A result file holding an empty object is kept as `{}`. JSON is indented only while that keeps it within twice its compact size (`indented_json`): a deeply nested value, whose indentation grows with its depth on every line, stays compact in the run log and in the dialog. Recording never raises on what a hook wrote: JSON parses objects nested deeper than it writes (or Python compares), and such a value is recorded as a note while the call goes on as it would without the run log. - The dialog holds the run's text as it is: widgets show it sanitized (controls and lone surrogates made visible), copy takes it raw, so multi-line shell hooks and block reasons keep their lines. A detached log is copied as written, in the lines the Log widget keeps (CR and CRLF end a line there as LF does, and blank lines at its end are kept), and a tail cut into a line skips to the first break of either kind. A CR a read ends on waits for the next read, so a CRLF split between two reads ends one line, not two. The dialog says when the run log dropped the run, stops polling once nothing is left to read, shows how long a run queued (counting up while it still does), and when a detached run was handed off. An open detached run's dialog says its log is shared once another run of the hook writes to it too, keeping the lines it shows. A long block shows its first and last 500 lines (32 KB at most each), plain, around a note of what it leaves out, and a copy takes it whole: laid out whole, 256 KB of short lines held the TUI for 9 seconds, a 1 MB result file for 48. The Input tab keeps the payload it shows, and copies it, when the run log drops the run's details after the dialog opened. A detached log shows a line's first 64 K characters and a mark where the rest goes, which a copy keeps: the Log widget measures a line on every write. - Each row ends with its duration at the right edge, the result before it; a running row shows only its spinner and its time counting up. The list's scrollbar sits on the panel's edge. Rows follow a theme change, painted once a hidden or covered list shows. A list at its bottom stays there when the notice that runs were dropped shows above its rows. The empty state repaints only when its text changes, follows the number of enabled hooks, and the list scrolls to a row below a notice however many lines it wraps to. - Clicking the idle indicator while the sidebar cannot open (an agent loading, the theme editor holding it hidden) shows the configured hooks, as a workflow does. - A stopped hook's block reason says "stopped" ahead of its stderr. - Tests: the secret-leak scan shuts the engine down before reading its files (Windows held them open), detached logs are written as bytes (text mode writes CRLF on Windows), tests that said polling stops count the timer's ticks, and run log tests wait for fire-and-forget runs. --- docs/en/guides/extensions/hooks.md | 4 +- docs/zh-Hans/guides/extensions/hooks.md | 4 +- locales/chrys.pot | 17 +- locales/zh-Hans/LC_MESSAGES/chrys.po | 19 +- .../tui/screens/dialogs/background_task.py | 26 +- src/chrys/app/tui/screens/dialogs/hook_run.py | 280 ++++++-- .../app/tui/screens/main/event_handlers.py | 6 +- .../app/tui/screens/main/runtime_info.py | 13 +- src/chrys/app/tui/screens/main/screen.py | 14 +- .../app/tui/screens/main/session_handlers.py | 6 +- src/chrys/app/tui/widgets/sidebar/hooks.py | 74 ++- .../_catalogs/zh-Hans/LC_MESSAGES/chrys.mo | Bin 203600 -> 203786 bytes src/chrys/foundation/models/hook_runs.py | 12 +- src/chrys/service/hooks/manager.py | 600 ++++++++++-------- src/chrys/service/hooks/run_log.py | 151 +++-- src/chrys/service/hooks/runner.py | 43 +- .../tui/screens/main/test_agent_load_flow.py | 13 + .../tui/screens/main/test_hooks_panel_flow.py | 104 ++- .../screens/main/test_session_ready_events.py | 13 + .../tui/screens/test_background_task_modal.py | 30 + tests/app/tui/screens/test_hook_run_modal.py | 381 ++++++++++- tests/app/tui/widgets/test_hook_run_list.py | 171 ++++- .../trajectory_wait_manifest.json | 40 +- .../i18n/_hook_panel_catalog_oracle_ids.py | 2 + tests/foundation/i18n/test_catalog_oracle.py | 2 +- .../engine/test_trajectory_events_log.py | 9 +- .../engine/build/test_retired_hook_traces.py | 4 +- .../engine/run/test_lifecycle_hooks.py | 6 +- .../engine/test_hook_run_engine.py | 14 +- .../engine/test_hook_run_reporting.py | 3 +- .../engine/test_shutdown_close.py | 6 +- tests/service/hooks/test_manager.py | 63 +- tests/service/hooks/test_run_lifecycle.py | 250 ++++++++ tests/service/hooks/test_run_log.py | 238 ++++++- tests/service/hooks/test_run_log_retention.py | 159 ++++- tests/service/hooks/test_runner.py | 5 +- tests/service/hooks/test_runner_capture.py | 5 +- 37 files changed, 2295 insertions(+), 492 deletions(-) create mode 100644 tests/service/hooks/test_run_lifecycle.py diff --git a/docs/en/guides/extensions/hooks.md b/docs/en/guides/extensions/hooks.md index afbd9c9cd..84886589b 100644 --- a/docs/en/guides/extensions/hooks.md +++ b/docs/en/guides/extensions/hooks.md @@ -173,9 +173,9 @@ An asynchronous hook (`async`) does not delay the event that triggered it or the In a chat, the bottom-right corner shows the hook iCode is waiting for. Select it to open the **Hooks** tab of the sidebar (**Ctrl+G** shows or hides the sidebar) and, when a hook is shown there, that hook's details over it; the list scrolls to that hook's row and marks it. -The **Hooks** tab lists the hooks that ran since iCode started, in every session, numbered in the order they started, the newest at the bottom: those still running or waiting for their turn, and the 100 that ended last. Each row shows its number, the hook, the tool or event that triggered it (and the sub-agent, when one did), how long it ran, and its result, such as `exit 1`, `timed out`, `blocked`, `modified args` or `added context`. A hook that failed is marked `✗`. **Clear ended** removes the runs that ended from the list; running ones stay. Cleared runs can't be shown again, and new runs keep counting from the last number. Once more than 100 runs have ended, the top of the list says that only the latest 100 are kept. +The **Hooks** tab lists the hooks that ran since iCode started, in every chat session (hooks that workflows run aren't listed), numbered in the order they started, the newest at the bottom: those still running or waiting for their turn, and the 100 that ended last. Each row shows its number, the hook, the tool or event that triggered it (and the sub-agent, when one did), its result, such as `exit 1`, `timed out`, `blocked`, `modified args` or `added context`, and at the right end how long it ran. A hook that is still running shows its time counting up, highlighted. A hook that failed is marked `✗`. **Clear ended** removes the runs that ended from the list; running ones stay. Cleared runs can't be shown again, and new runs keep counting from the last number. Once more than 100 runs have ended, the top of the list says that only the latest 100 are kept. -Click a run to see its details. **Input** shows the payload iCode gave the hook and how it was run; **Output** shows its result file, what iCode took from it (or why it took nothing), and what it printed to stdout and stderr. Click a box to copy what it shows, or press **c** to copy the whole tab. The list starts empty each time iCode starts; to see the hooks iCode loaded, enter `/runtime` and open the **Hooks** tab. +Click a run to see its details. **Input** shows the payload iCode gave the hook and how it was run; **Output** shows its result file, what iCode took from it (or why it took nothing), and what it printed to stdout and stderr. Click a box to copy its text, or press **c** to copy the whole tab; very long text shows only in part, but a copy still takes all of it. The list starts empty each time iCode starts; to see the hooks iCode loaded, enter `/runtime` and open the **Hooks** tab. ## Use global hooks diff --git a/docs/zh-Hans/guides/extensions/hooks.md b/docs/zh-Hans/guides/extensions/hooks.md index 93859ccb0..32407d287 100644 --- a/docs/zh-Hans/guides/extensions/hooks.md +++ b/docs/zh-Hans/guides/extensions/hooks.md @@ -173,9 +173,9 @@ with open(os.environ["CHRYS_HOOK_RESULT"], "w", encoding="utf-8") as result_file 在对话中,右下角会显示 iCode 正在等待的钩子。点击它会打开侧栏的“钩子”标签页(按 **Ctrl+G** 显示或隐藏侧栏);右下角显示着某个钩子时,还会在上面打开这个钩子的详情,并且列表会滚动到这一行并标出它。 -“钩子”标签页列出 iCode 启动以来所有会话中运行过的钩子,按开始顺序编号,最新的在最下面:包括正在运行和排队等待的,以及最近结束的 100 次。每行显示编号、钩子、触发它的工具或事件(由子智能体触发时还显示子智能体)、运行时长和结果,例如 `exit 1`、`timed out`、`blocked`、`modified args` 或 `added context`。运行失败的钩子标为 `✗`。点击“清除已结束”可从列表中移除已结束的运行,正在运行的会保留。清除后的运行无法再显示,新的运行接着原来的编号往下数。结束的运行超过 100 次后,列表顶部会提示只保留最近 100 次。 +“钩子”标签页列出 iCode 启动以来所有对话会话中运行过的钩子(工作流运行的钩子不在其中),按开始顺序编号,最新的在最下面:包括正在运行和排队等待的,以及最近结束的 100 次。每行显示编号、钩子、触发它的工具或事件(由子智能体触发时还显示子智能体)和结果,例如“退出码 1”、“已超时”、“已阻止”、“修改了参数”或“追加了上下文”,最右侧是运行时长。仍在运行的钩子,其时长会高亮显示并不断增加。运行失败的钩子标为 `✗`。点击“清除已结束”可从列表中移除已结束的运行,正在运行的会保留。清除后的运行无法再显示,新的运行接着原来的编号往下数。结束的运行超过 100 次后,列表顶部会提示只保留最近 100 次。 -点击一行可查看详情。“输入”显示 iCode 交给钩子的 payload 以及钩子的运行方式;“输出”显示结果文件、iCode 采纳了哪些内容(或为什么没有采纳),以及钩子输出到 stdout 和 stderr 的内容。点击某个方框即可复制其中的内容;按 **c** 复制整个标签页。每次启动 iCode 时列表为空;要查看 iCode 加载了哪些钩子,输入 `/runtime` 并打开“钩子”标签页。 +点击一行可查看详情。“输入”显示 iCode 交给钩子的 payload 以及钩子的运行方式;“输出”显示结果文件、iCode 采纳了哪些内容(或为什么没有采纳),以及钩子输出到 stdout 和 stderr 的内容。点击某个方框即可复制其中的内容,按 **c** 复制整个标签页;很长的内容只显示一部分,但复制时仍会复制全部内容。每次启动 iCode 时列表为空;要查看 iCode 加载了哪些钩子,输入 `/runtime` 并打开“钩子”标签页。 ## 使用全局钩子 diff --git a/locales/chrys.pot b/locales/chrys.pot index d5f7ca10a..3ce3b1124 100644 --- a/locales/chrys.pot +++ b/locales/chrys.pot @@ -6249,8 +6249,8 @@ msgstr "" msgid "tui.hook_run.applied.args" msgstr "" -#. English: Blocked the action: {reason} -#. chrys-meta={"fingerprint":"869c0c47dfbff769b0747bd613dde9f3c6343c4c2b134ff33701c4dbe8768091","multiline":false,"placeholders":["reason"]} +#. English: Blocked the action: +#. chrys-meta={"fingerprint":"2cf87c65acf4bb5aef37e1913778060a63348c11288763c253eda0a13875ff92","multiline":false,"placeholders":[]} #: src/chrys/app/tui/screens/dialogs/hook_run.py msgid "tui.hook_run.applied.blocked" msgstr "" @@ -6273,6 +6273,12 @@ msgstr "" msgid "tui.hook_run.applied.reminder" msgstr "" +#. English: Arguments cut at 64 KB of {size} +#. chrys-meta={"fingerprint":"0cafa06fa41ccdf2c41021edd0e75336bec1bf36b9b9e6932a47fc6904901760","multiline":false,"placeholders":["size"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.args_truncated" +msgstr "" + #. English: Copied log path #. chrys-meta={"fingerprint":"87ede8461a4168c6439cb0814da6124fe28d8193222a81286dfe86a8836bccfb","multiline":false,"placeholders":[]} #: src/chrys/app/tui/screens/dialogs/hook_run.py @@ -6459,6 +6465,13 @@ msgstr "" msgid "tui.hook_run.not_applied.unsupported" msgstr "" +#. English: {chars} characters from the middle are not shown; a copy includes +#. them +#. chrys-meta={"fingerprint":"f1deaf01c71a445b9a1e02513d7681d15eafb80829e02c123adfb8224de83be1","multiline":false,"placeholders":["chars"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_shown" +msgstr "" + #. English: Payload cut at 64 KB of {size} #. chrys-meta={"fingerprint":"95bc340815d859aa5908b17774f108a24218dd554b5853dc114b2c9ee0d2745e","multiline":false,"placeholders":["size"]} #: src/chrys/app/tui/screens/dialogs/hook_run.py diff --git a/locales/zh-Hans/LC_MESSAGES/chrys.po b/locales/zh-Hans/LC_MESSAGES/chrys.po index 8a7b43495..ee770ab94 100644 --- a/locales/zh-Hans/LC_MESSAGES/chrys.po +++ b/locales/zh-Hans/LC_MESSAGES/chrys.po @@ -6292,11 +6292,11 @@ msgstr "钩子" msgid "tui.hook_run.applied.args" msgstr "将工具参数改为:" -#. English: Blocked the action: {reason} -#. chrys-meta={"fingerprint":"869c0c47dfbff769b0747bd613dde9f3c6343c4c2b134ff33701c4dbe8768091","multiline":false,"placeholders":["reason"]} +#. English: Blocked the action: +#. chrys-meta={"fingerprint":"2cf87c65acf4bb5aef37e1913778060a63348c11288763c253eda0a13875ff92","multiline":false,"placeholders":[]} #: src/chrys/app/tui/screens/dialogs/hook_run.py msgid "tui.hook_run.applied.blocked" -msgstr "阻止了此操作:{reason}" +msgstr "阻止了此操作,原因:" #. English: Added to the tool result: #. chrys-meta={"fingerprint":"21c92d105ef15620ffa4504e8f0b7423c5ee3ebe60d066ea8ed900626db30859","multiline":false,"placeholders":[]} @@ -6316,6 +6316,12 @@ msgstr "无" msgid "tui.hook_run.applied.reminder" msgstr "为模型添加了提醒:" +#. English: Arguments cut at 64 KB of {size} +#. chrys-meta={"fingerprint":"0cafa06fa41ccdf2c41021edd0e75336bec1bf36b9b9e6932a47fc6904901760","multiline":false,"placeholders":["size"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.args_truncated" +msgstr "参数共 {size},只保留前 64 KB" + #. English: Copied log path #. chrys-meta={"fingerprint":"87ede8461a4168c6439cb0814da6124fe28d8193222a81286dfe86a8836bccfb","multiline":false,"placeholders":[]} #: src/chrys/app/tui/screens/dialogs/hook_run.py @@ -6502,6 +6508,13 @@ msgstr "async 和 fire_and_forget 钩子不能改变操作" msgid "tui.hook_run.not_applied.unsupported" msgstr "{event} 不支持此操作" +#. English: {chars} characters from the middle are not shown; a copy includes +#. them +#. chrys-meta={"fingerprint":"f1deaf01c71a445b9a1e02513d7681d15eafb80829e02c123adfb8224de83be1","multiline":false,"placeholders":["chars"]} +#: src/chrys/app/tui/screens/dialogs/hook_run.py +msgid "tui.hook_run.not_shown" +msgstr "中间的 {chars} 个字符未显示;复制时会包含它们" + #. English: Payload cut at 64 KB of {size} #. chrys-meta={"fingerprint":"95bc340815d859aa5908b17774f108a24218dd554b5853dc114b2c9ee0d2745e","multiline":false,"placeholders":["size"]} #: src/chrys/app/tui/screens/dialogs/hook_run.py diff --git a/src/chrys/app/tui/screens/dialogs/background_task.py b/src/chrys/app/tui/screens/dialogs/background_task.py index 739d0739f..083351eb1 100644 --- a/src/chrys/app/tui/screens/dialogs/background_task.py +++ b/src/chrys/app/tui/screens/dialogs/background_task.py @@ -14,6 +14,7 @@ import contextlib import dataclasses import os +import re import time from datetime import UTC, datetime from typing import TYPE_CHECKING, ClassVar @@ -62,6 +63,7 @@ _TAIL_BYTES = 256 * 1024 """How much of the log's end the modal shows at most.""" +_LINE_BREAK = re.compile(rb"\r\n|\r|\n") _MAX_LINES = 10_000 _POLL_SECONDS = 1.0 @@ -164,9 +166,16 @@ def __init__(self, path: str) -> None: self._file: tuple[int, int] | None = None """The (device, inode) read last: a rotated log is a new file.""" self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + self._held_cr = "" + """A CR the last read ended on, given with the next read, where an LF may complete it.""" def read(self) -> tuple[str, bool]: - """The text added since the last read, and whether it replaces what was read before; blocks.""" + """The text added since the last read, made safe to show, and whether it replaces what was read before; blocks.""" + text, restart = self.read_raw() + return sanitize_source_text(text, tab_size=8), restart + + def read_raw(self) -> tuple[str, bool]: + """``read``, with the text as the log has it: what a copy takes.""" with open(self._path, "rb") as file: stat = os.fstat(file.fileno()) size = stat.st_size @@ -180,10 +189,19 @@ def read(self) -> tuple[str, bool]: self._offset = start + len(data) if restart: self._decoder.reset() + self._held_cr = "" if start > 0: - data = data.partition(b"\n")[2] - # The log holds whole lines with no CR (TaskLog cleans them), so only a character can split. - return sanitize_source_text(self._decoder.decode(data), tab_size=8), restart + # Cut into a line: it starts after the first break, CR included (a hook's log keeps them). + first = _LINE_BREAK.search(data) + data = data[first.end() :] if first else b"" + # A read can end inside a character: the decoder keeps its first bytes for the next read. + text = self._held_cr + self._decoder.decode(data) + # Or between the CR and the LF of one break, which shown apart would end two lines. + if text.endswith("\r"): + text, self._held_cr = text[:-1], "\r" + else: + self._held_cr = "" + return text, restart def _read_log(path: str) -> str: diff --git a/src/chrys/app/tui/screens/dialogs/hook_run.py b/src/chrys/app/tui/screens/dialogs/hook_run.py index 9f9a21b9f..689d467d6 100644 --- a/src/chrys/app/tui/screens/dialogs/hook_run.py +++ b/src/chrys/app/tui/screens/dialogs/hook_run.py @@ -6,6 +6,10 @@ only. While the run is queued or running, the modal reads it again once a second and fills in the output when it ends; the output is collected when the hook's process exits, so nothing streams before that. + +Every block of a section holds its text as the run has it: the widgets show it +sanitized (``_shown``), and a copy takes it as it is, a long block whole though +its widgets show only its ends (``_ends``). """ from __future__ import annotations @@ -13,10 +17,13 @@ import asyncio import contextlib import json +import re import shlex import time -from dataclasses import dataclass +from collections import deque +from dataclasses import dataclass, replace from datetime import UTC, datetime +from itertools import islice from typing import TYPE_CHECKING, ClassVar from rich.style import Style @@ -42,24 +49,36 @@ copy_and_notify, ) from chrys.app.tui.util.removal import remove_children_shielded +from chrys.app.tui.util.source_text import sanitize_source_text from chrys.app.tui.widgets.chat.tool_call import fmt_duration from chrys.app.tui.widgets.chat.tool_view_builders import build_code_view from chrys.app.tui.widgets.sidebar.hooks import run_duration, run_icon, run_outcome from chrys.foundation.i18n import MessageRef, msg from chrys.foundation.i18n.formatting import sanitize_legacy_scalar from chrys.foundation.platform.files import surrogate_safe_text +from chrys.service.hooks.run_log import indented_json if TYPE_CHECKING: from collections.abc import Callable from textual.app import ComposeResult from textual.events import Click + from textual.timer import Timer from textual.widget import Widget from chrys.foundation.models.hook_runs import HookRunDetail _POLL_SECONDS = 1.0 _MAX_LOG_LINES = 10_000 +_LINE_BREAK = re.compile(r"\r\n|\r|\n") +"""Where the ``Log`` widget ends a line of the sanitized text, found in the text as read.""" +_FINAL_LINE_BREAK = re.compile(r"(?:\r\n|\r|\n)\Z") +_SHOWN_LINES = 500 +"""Lines a long block shows at each end: opening the dialog lays out every line its blocks show.""" +_SHOWN_CHARS = 32 * 1024 +"""Characters a long block shows at most at each end, for lines that wrap.""" +_LOG_LINE_SHOWN_CHARS = 64 * 1024 +"""Characters a line of the detached log shows: the ``Log`` widget measures a line again on each write to it.""" _QUEUED_SHOWN_AFTER_SECONDS = 0.01 """A shorter wait for a slot is no queueing worth naming.""" @@ -92,7 +111,7 @@ _NO_RESULT_FILE = msg("tui.hook_run.no_result_file", fallback="No result file") _INVALID_RESULT_FILE = msg("tui.hook_run.invalid_result_file", fallback="The result file was invalid and was ignored") _APPLIED_NOTHING = msg("tui.hook_run.applied.nothing", fallback="Nothing") -_APPLIED_BLOCKED = msg("tui.hook_run.applied.blocked", fallback="Blocked the action: {reason}") +_APPLIED_BLOCKED = msg("tui.hook_run.applied.blocked", fallback="Blocked the action:") _APPLIED_ARGS = msg("tui.hook_run.applied.args", fallback="Changed the tool arguments to:") _APPLIED_REMINDER = msg("tui.hook_run.applied.reminder", fallback="Added a reminder for the model:") _APPLIED_CONTEXT = msg("tui.hook_run.applied.context", fallback="Added to the tool result:") @@ -107,6 +126,10 @@ ) _TRUNCATED = msg("tui.hook_run.truncated", fallback="{bytes} bytes from the middle were not kept") _PAYLOAD_TRUNCATED = msg("tui.hook_run.payload_truncated", fallback="Payload cut at 64 KB of {size}") +_ARGS_TRUNCATED = msg("tui.hook_run.args_truncated", fallback="Arguments cut at 64 KB of {size}") +_NOT_SHOWN = msg( + "tui.hook_run.not_shown", fallback="{chars} characters from the middle are not shown; a copy includes them" +) _SHARED_LOG = msg( "tui.hook_run.shared_log", fallback="Another run of this hook started in the same second and writes to this log too", @@ -139,20 +162,45 @@ def _size(count: int) -> str: def _pretty_json(text: str) -> str: try: - return json.dumps(json.loads(text), indent=2, ensure_ascii=False) - except ValueError: + return indented_json(json.loads(text)) + except ValueError, RecursionError: return text +def _ends(text: str) -> tuple[str, int, str] | None: + """The start and the end a long *text* shows, and how many characters between them it + leaves out; None for a text shown whole.""" + if len(text) <= 2 * _SHOWN_CHARS and len(_LINE_BREAK.findall(text)) < 2 * _SHOWN_LINES: + return None + head = text[:_SHOWN_CHARS] + if (cut := next(islice(_LINE_BREAK.finditer(head), _SHOWN_LINES - 1, None), None)) is not None: + head = head[: cut.start()] + tail = text[-_SHOWN_CHARS:] + breaks = deque(_LINE_BREAK.finditer(tail), maxlen=_SHOWN_LINES) + if len(breaks) == _SHOWN_LINES: + tail = tail[breaks[0].end() :] + # The ends never overlap: a text long by its characters holds more than both ends take, + # and one long by its lines more breaks than both ends end at. + return head, len(text) - len(head) - len(tail), tail + + +def _shown(text: str) -> Text: + """*text* as a widget shows it: lines and indents kept, controls and lone surrogates made visible.""" + return Text(sanitize_source_text(surrogate_safe_text(text))) + + @dataclass(frozen=True, slots=True) class _Fields: - rows: tuple[tuple[str, Text], ...] + rows: tuple[tuple[str, str], ...] + """Each row's label, and its value as the run has it.""" id: str | None = None @dataclass(frozen=True, slots=True) class _Note: text: str + """Localized prose. The run's own text goes in a ``_Code`` block instead: a note's + arguments are rendered on one line, sanitized, and its copy would not keep them.""" copied: bool = True """False for a remark about the section, left out of what it copies.""" @@ -178,6 +226,50 @@ class _LogTail: pass +class _LogText: + """The detached log as read, as it is, in the lines the ``Log`` widget keeps: what a copy takes. + + Sanitized for the widget, CR and CRLF end a line as LF does, so lines end at each of them here + too, and as many are kept as the widget keeps, the line still open counted. The widget is + given a line's first ``_LOG_LINE_SHOWN_CHARS`` characters, and a mark where the rest goes. + """ + + def __init__(self) -> None: + self._lines: deque[str] = deque(maxlen=_MAX_LOG_LINES) + """Each line with the break that ended it; the last is still open.""" + + def clear(self) -> None: + self._lines.clear() + + def write(self, text: str) -> str: + """Add *text*; what the widget is given of it.""" + if not text: + return "" + if not self._lines: + self._lines.append("") + shown: list[str] = [] + start = 0 + for end in _LINE_BREAK.finditer(text): + shown.extend((self._extend(text[start : end.start()]), end.group())) + self._lines[-1] += end.group() + self._lines.append("") + start = end.end() + shown.append(self._extend(text[start:])) + return "".join(shown) + + def _extend(self, part: str) -> str: + """Add *part* to the open line; what the widget is given of it.""" + room = _LOG_LINE_SHOWN_CHARS - len(self._lines[-1]) + self._lines[-1] += part + if len(part) <= room: + return part + return part[:room] + "…" if room >= 0 else "" + + def text(self) -> str: + # The widget shows the empty line the last break opens; the copy ends at that break. + return _FINAL_LINE_BREAK.sub("", "".join(self._lines), count=1) + + _Block = _Fields | _Line | _Note | _Code | _LogPath | _LogTail @@ -221,7 +313,7 @@ def on_leave(self) -> None: def _fields_plain(fields: _Fields) -> str: lines = [] for label, value in fields.rows: - first, *rest = value.plain.split("\n") + first, *rest = value.split("\n") lines.append(f"{label}: {first}") lines.extend(" " * (len(label) + 2) + line for line in rest) return "\n".join(lines) @@ -241,11 +333,17 @@ class HookRunModal(BaseDialog[None]): def __init__(self, detail: HookRunDetail, *, latest: Callable[[str], HookRunDetail | None]) -> None: super().__init__() self._shown = detail + self._payload_io = detail.io + """The details the Input tab's payload was built from, once: kept when the run log drops them later.""" self._latest = latest """The run as the run log has it now; None once the log no longer keeps it.""" self._tail = LogTail(detail.log_path) if detail.log_path else None + self._log_text = _LogText() self._reading = False self._rebuilding = False + self._timer: Timer | None = None + self._gone = False + """The run log dropped the run before it ended here: nothing more comes of it.""" # ── layout ─────────────────────────────────────────────────────── @@ -277,7 +375,7 @@ def on_mount(self) -> None: if self._shown.record.state == "ended": self.call_after_refresh(self._open_output) if self._shown.record.state != "ended" or self._tail is not None: - self.set_interval(_POLL_SECONDS, self._poll) + self._timer = self.set_interval(_POLL_SECONDS, self._poll) if self._tail is not None: self._read_log() @@ -288,13 +386,8 @@ def _open_output(self) -> None: def _text(self, reference: MessageRef) -> str: return render_str(widget_localizer(self), reference) - def _fields(self, rows: list[tuple[MessageRef, Text | str]], *, id: str | None = None) -> _Fields: - return _Fields( - tuple( - (self._text(label), value if isinstance(value, Text) else Text(value)) for label, value in rows if value - ), - id=id, - ) + def _fields(self, rows: list[tuple[MessageRef, str]], *, id: str | None = None) -> _Fields: + return _Fields(tuple((self._text(label), value) for label, value in rows if value), id=id) def _note(self, label: MessageRef, *, copied: bool = True) -> _Note: return _Note(self._text(label), copied=copied) @@ -305,7 +398,7 @@ def _grid(fields: _Fields) -> Table: grid.add_column(style="dim", no_wrap=True) grid.add_column(overflow="fold") for label, value in fields.rows: - grid.add_row(label, value) + grid.add_row(label, _shown(value)) return grid def _widgets(self, tab: str, sections: list[_Section]) -> list[Widget]: @@ -319,20 +412,32 @@ def _widgets(self, tab: str, sections: list[_Section]) -> list[Widget]: case _Line(): children.append(Static(block.text)) case _Note(): - children.append(Static(Text(block.text), classes="hook-run-note")) + children.append(Static(_shown(block.text), classes="hook-run-note")) case _Code(): - children.append( - build_code_view( - block.language, block.text, dark=self.app.current_theme.dark, render_message=self._text - ) - ) + children.extend(self._code_views(block)) case _LogPath(): - children.append(Static(Text(surrogate_safe_text(block.path)), classes="hook-run-note")) + children.append(Static(_shown(block.path), classes="hook-run-note", id="hook-run-log-path")) case _LogTail(): children.append(Log(max_lines=_MAX_LOG_LINES, id="hook-run-log")) widgets.append(_CopySection(*children, title=section.name, key=f"{tab}:{index}")) return widgets + def _code_views(self, block: _Code) -> list[Widget]: + """*block*'s view, or a long block's two ends around a note of what they leave out.""" + # The view clears controls but not lone surrogates, which no terminal can be sent. + text = surrogate_safe_text(block.text) + dark = self.app.current_theme.dark + if (ends := _ends(text)) is None: + return [build_code_view(block.language, text, dark=dark, render_message=self._text)] + # Plain: a cut can fall inside a token, which would colour everything after it. + head, left_out, tail = ends + note = self._text(_NOT_SHOWN.bind(chars=str(left_out))) + return [ + build_code_view("text", head, dark=dark, render_message=self._text), + Static(_shown(note), classes="hook-run-note"), + build_code_view("text", tail, dark=dark, render_message=self._text), + ] + def _section_text(self, section: _Section) -> str: """What the section shows, in plain text, but its title.""" parts: list[str] = [] @@ -347,11 +452,9 @@ def _section_text(self, section: _Section) -> str: case _Code(): parts.append(block.text) case _LogPath(): - parts.append(surrogate_safe_text(block.path)) + parts.append(block.path) case _LogTail(): - with contextlib.suppress(NoMatches): - # The log keeps the empty line its last newline opens. - parts.append("\n".join(self.query_one("#hook-run-log", Log).lines).rstrip("\n")) + parts.append(self._log_text.text()) return "\n".join(part for part in parts if part) def _plain(self, sections: list[_Section]) -> str: @@ -375,31 +478,36 @@ def _input_fields(self) -> _Fields: mode.append(f"on_error: {detail.on_error}") if detail.delivery == "durable": mode.append("delivery: durable") - triggered = [sanitize_legacy_scalar(record.sub_agent) if record.sub_agent else self._text(_MAIN_AGENT.bind())] + triggered = [record.sub_agent or self._text(_MAIN_AGENT.bind())] if record.tool_name: - tool = sanitize_legacy_scalar(record.tool_name) - triggered.append(f"{tool} ({sanitize_legacy_scalar(detail.tool_call_id)})" if detail.tool_call_id else tool) + tool = record.tool_name + triggered.append(f"{tool} ({detail.tool_call_id})" if detail.tool_call_id else tool) if record.retry: triggered.append(self._text(_RETRY.bind())) started = queued = "" - now = time.monotonic() if record.started_at is not None: started = _wall_time(record.registered_wall + (record.started_at - record.registered_at)) waited = record.started_at - record.registered_at + elif record.outcome == "detached": + # Recorded once handed off, when its worker starts it; nothing of it is heard after. + started = _wall_time(record.registered_wall) + waited = 0.0 else: - waited = (now if record.state == "queued" else record.registered_at) - record.registered_at + # Never took a slot: queued until now, or until it ended there. + end = record.ended_at if record.ended_at is not None else time.monotonic() + waited = end - record.registered_at if waited >= _QUEUED_SHOWN_AFTER_SECONDS: queued = fmt_duration(int(waited * 1000)) - source = " · ".join(part for part in (detail.source, surrogate_safe_text(detail.source_path)) if part) + source = " · ".join(part for part in (detail.source, detail.source_path) if part) if detail.run_type == "shell": command = detail.run_argv[0] if detail.run_argv else "" else: command = shlex.join(detail.run_argv) - run_lines = [f"{detail.run_type}: {sanitize_legacy_scalar(command)}"] + run_lines = [f"{detail.run_type}: {command}"] if detail.run_cwd: - run_lines.append(f"cwd: {surrogate_safe_text(detail.run_cwd)}") + run_lines.append(f"cwd: {detail.run_cwd}") if detail.run_env_names: - run_lines.append(f"env: {', '.join(sanitize_legacy_scalar(name) for name in detail.run_env_names)}") + run_lines.append(f"env: {', '.join(detail.run_env_names)}") return self._fields( [ (_EVENT.bind(), record.hook_event), @@ -416,10 +524,11 @@ def _input_fields(self) -> _Fields: def _payload_section(self) -> _Section: name = self._text(_PAYLOAD.bind()) - io = self._shown.io + io = self._payload_io if io is None: return _Section(name, (self._note(_DETAILS_GONE.bind()),)) - if io.payload_size > len(io.payload.encode("utf-8")): + # The payload is ASCII JSON: its characters are its bytes. + if io.payload_size > len(io.payload): return _Section( name, ( @@ -444,6 +553,8 @@ def _result_text(self) -> Text: def _output_sections(self) -> list[_Section]: detail = self._shown result = self._text(_RESULT.bind()) + if self._gone: + return [_Section(result, (self._note(_DETAILS_GONE.bind()),))] if detail.record.state != "ended": return [_Section(result, (self._note(_RUNNING.bind()),))] io = detail.io @@ -479,17 +590,27 @@ def _applied_blocks(self) -> list[_Block]: if applied is None: return [self._note(_APPLIED_NOTHING.bind())] widgets: list[_Block] = [] + # The texts go with the rest of the details, once those are no longer kept. + gone = detail.io is None if effect == "blocked": - widgets.append(self._note(_APPLIED_BLOCKED.bind(reason=sanitize_legacy_scalar(applied.block_reason)))) + # Kept when the other details go: a run that blocked an action still says why. + widgets.append(self._note(_APPLIED_BLOCKED.bind())) + widgets.append(_Code("text", applied.block_reason)) elif effect == "modified_args": widgets.append(self._note(_APPLIED_ARGS.bind())) - widgets.append(_Code("json", applied.args)) - elif effect == "added_reminder": - widgets.append(self._note(_APPLIED_REMINDER.bind())) - widgets.append(_Code("text", applied.reminder)) - elif effect == "added_context": - widgets.append(self._note(_APPLIED_CONTEXT.bind())) - widgets.append(_Code("text", applied.extra_context)) + if gone: + widgets.append(self._note(_DETAILS_GONE.bind(), copied=False)) + elif applied.args_size: + widgets.append(self._note(_ARGS_TRUNCATED.bind(size=_size(applied.args_size)), copied=False)) + widgets.append(_Code("text", applied.args)) + else: + widgets.append(_Code("json", applied.args)) + elif effect in ("added_reminder", "added_context"): + widgets.append(self._note((_APPLIED_REMINDER if effect == "added_reminder" else _APPLIED_CONTEXT).bind())) + if gone: + widgets.append(self._note(_DETAILS_GONE.bind(), copied=False)) + else: + widgets.append(_Code("text", applied.reminder if effect == "added_reminder" else applied.extra_context)) reason = { "nonblocking": _NOT_APPLIED_NONBLOCKING.bind(), "unsupported": _NOT_APPLIED_UNSUPPORTED.bind(event=detail.record.hook_event), @@ -511,39 +632,71 @@ def _stream_section(self, label: MessageRef, text: str, dropped: int) -> _Sectio def _show_state(self) -> None: with contextlib.suppress(NoMatches): - self.query_one("#hook-run-container").border_subtitle = self._result_text() + # Dropped before it ended here: how it ended is not known. + self.query_one("#hook-run-container").border_subtitle = "" if self._gone else self._result_text() async def _poll(self) -> None: if self._tail is not None and not self._reading: self._read_log() - if self._shown.record.state == "ended": + if self._rebuilding: + return + if self._shown.record.state == "ended" or self._gone: + if self._tail is not None and not self._shown.shared_log: + await self._note_shared_log() return latest = self._latest(self._shown.record.run_id) if latest is None: + # Ended, then dropped from the run log past its cap, between two reads. + self._gone = True + elif latest.record == self._shown.record: + self._shown = latest + self._show_state() + if latest.record.state == "queued": + # Queued until now: the time counts up, as the border's result does. + self._show_input_fields() return - changed = latest.record != self._shown.record - self._shown = latest + else: + self._shown = latest + self._show_input_fields() self._show_state() - if not changed or self._rebuilding: - return - with contextlib.suppress(NoMatches): - self.query_one("#hook-run-input-fields", Static).update(self._grid(self._input_fields())) - if latest.record.state != "ended": + if not self._gone and self._shown.record.state != "ended": return - # Ended: its output arrived. The tab stays where the user is. + # Nothing more to read, without a log to tail. Paused, not stopped: this + # runs on the timer's own task, which stopping cancels. + if self._tail is None and not self._shown.log_path and self._timer is not None: + self._timer.pause() + # Ended, or dropped: its output arrived, or never will. The tab stays where the user is. self._rebuilding = True try: body = self.query_one("#hook-run-output-body", ScrollableContainer) - await remove_children_shielded(body) - await body.mount(*self._widgets("output", self._output_sections())) + with self.app.batch_update(): + await remove_children_shielded(body) + await body.mount(*self._widgets("output", self._output_sections())) except NoMatches: return finally: self._rebuilding = False - if latest.log_path and self._tail is None: - self._tail = LogTail(latest.log_path) + if self._shown.log_path and self._tail is None: + self._tail = LogTail(self._shown.log_path) self._read_log() + def _show_input_fields(self) -> None: + with contextlib.suppress(NoMatches): + self.query_one("#hook-run-input-fields", Static).update(self._grid(self._input_fields())) + + async def _note_shared_log(self) -> None: + """Say the log is shared once another run of the hook writes to it too, which can follow this run's end.""" + latest = self._latest(self._shown.record.run_id) + if latest is None or not latest.shared_log: + return + # Only the flag: the run log may have dropped details this dialog still shows. + self._shown = replace(self._shown, shared_log=True) + with contextlib.suppress(NoMatches): + path = self.query_one("#hook-run-log-path", Static) + if isinstance(section := path.parent, _CopySection): + # Beside the path, not by a rebuild, which would empty the log shown. + await section.mount(Static(_shown(self._text(_SHARED_LOG.bind())), classes="hook-run-note"), after=path) + def _read_log(self) -> None: self._reading = True self.run_worker(self._read_more(), exit_on_error=False) @@ -554,17 +707,20 @@ async def _read_more(self) -> None: self._reading = False return try: - text, restart = await asyncio.to_thread(tail.read) + text, restart = await asyncio.to_thread(tail.read_raw) except OSError: return finally: self._reading = False + if restart: + self._log_text.clear() + shown = self._log_text.write(text) with contextlib.suppress(NoMatches): log = self.query_one("#hook-run-log", Log) if restart: log.clear() - if text: - log.write(text, scroll_end=log.is_vertical_scroll_end) + if shown: + log.write(sanitize_source_text(shown, tab_size=8), scroll_end=log.is_vertical_scroll_end) # ── actions ────────────────────────────────────────────────────── diff --git a/src/chrys/app/tui/screens/main/event_handlers.py b/src/chrys/app/tui/screens/main/event_handlers.py index 464c07f76..ce886c0e9 100644 --- a/src/chrys/app/tui/screens/main/event_handlers.py +++ b/src/chrys/app/tui/screens/main/event_handlers.py @@ -33,7 +33,7 @@ StatusMessage, StatusTrail, ) -from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider +from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider, enabled_hook_count from chrys.app.tui.screens.main.session_title import QUESTION_MARK, RETRY_MARK, AttentionMark from chrys.app.tui.screens.main.state import MainScreenServices, MainScreenState from chrys.app.tui.support.gc_freeze import ( @@ -423,7 +423,11 @@ def runtime_metadata(self) -> AgentRuntimeDetails: @runtime_metadata.setter def runtime_metadata(self, value: AgentRuntimeDetails) -> None: + configured = enabled_hook_count(self._state.runtime.details) self._state.runtime.details = value + # The Hooks tab's empty state says whether any hook is configured. + if enabled_hook_count(value) != configured: + self._ui().hook_runs_changed() @property def approval_mode(self) -> ApprovalMode: diff --git a/src/chrys/app/tui/screens/main/runtime_info.py b/src/chrys/app/tui/screens/main/runtime_info.py index d74b1b29b..68b0e0971 100644 --- a/src/chrys/app/tui/screens/main/runtime_info.py +++ b/src/chrys/app/tui/screens/main/runtime_info.py @@ -53,6 +53,13 @@ def with_background_count(trail: StatusTrail, running: int) -> StatusTrail: ) +def enabled_hook_count(details: AgentRuntimeDetails | None) -> int: + """The hooks *details* names that are enabled: what the status trail and the Hooks tab count.""" + if details is None: + return 0 + return sum(hook.enabled for source in details.hook_sources for hook in source.hooks) + + class RegistryRuntimeInfoProvider: """Format runtime info and profile descriptions from configured registries.""" @@ -73,11 +80,7 @@ def format_tool_info( parts.append(_RUNTIME_TOOLS.bind(count=len(tool_names))) if skill_names: parts.append(_RUNTIME_SKILLS.bind(count=len(skill_names))) - hook_count = ( - sum(hook.enabled for source in runtime_details.hook_sources for hook in source.hooks) - if runtime_details is not None - else 0 - ) + hook_count = enabled_hook_count(runtime_details) if hook_count: parts.append(_RUNTIME_HOOKS.bind(count=hook_count)) if memory_files: diff --git a/src/chrys/app/tui/screens/main/screen.py b/src/chrys/app/tui/screens/main/screen.py index acd765919..e5411091e 100644 --- a/src/chrys/app/tui/screens/main/screen.py +++ b/src/chrys/app/tui/screens/main/screen.py @@ -50,7 +50,7 @@ from chrys.app.tui.screens.main.navigation import MainNavigationController from chrys.app.tui.screens.main.ports import StatusMessage from chrys.app.tui.screens.main.rollback_controller import RollbackController -from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider +from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider, enabled_hook_count from chrys.app.tui.screens.main.session_handlers import RestoreRequest, SessionCallbacks, SessionHandler from chrys.app.tui.screens.main.session_title import SessionTitleController from chrys.app.tui.screens.main.settings_coordinator import ( @@ -1797,10 +1797,12 @@ def on_hook_indicator_clicked(self, event: HookIndicator.Clicked) -> None: """Show the sidebar's Hooks tab, and over it the run the indicator shows, if any. The tab opens the sidebar if it is closed, so closing the run's details - lands on the list, scrolled to that run's row and marking it. A workflow hides that tab: there, with no run shown, - the configured hooks show instead. + lands on the list, scrolled to that run's row and marking it. A workflow hides that tab, and the sidebar + stays closed while an agent loads or the theme editor holds it hidden: there, with no run shown, the + configured hooks show instead. """ detail = self._hook_run_detail(event.run_id) if event.run_id else None + listed = False if not self._workflow.workflow_mode: sidebar = self.query_one(SidebarPanel) if not sidebar.is_visible: @@ -1808,11 +1810,12 @@ def on_hook_indicator_clicked(self, event: HookIndicator.Clicked) -> None: sidebar.focus_tab(SIDEBAR_HOOKS_TAB) if detail is not None: sidebar.hooks_panel.select(event.run_id) + listed = sidebar.is_visible if detail is not None: from chrys.app.tui.screens.dialogs.hook_run import HookRunModal self.app.push_screen(HookRunModal(detail, latest=self._hook_run_detail)) - elif self._workflow.workflow_mode: + elif not listed: from chrys.app.tui.screens.dialogs.runtime_details import RUNTIME_DETAILS_HOOKS_TAB self._config_actions.open_runtime_details(initial_tab=RUNTIME_DETAILS_HOOKS_TAB) @@ -1830,8 +1833,7 @@ def _hook_run_detail(self, run_id: str) -> HookRunDetail | None: return EngineReadModel(provider()).hook_run_detail(run_id) if provider is not None else None def _configured_hook_count(self) -> int: - details = self._state.runtime.details - return sum(hook.enabled for source in details.hook_sources for hook in source.hooks) + return enabled_hook_count(self._state.runtime.details) @on(HookRunList.Opened) def _open_hook_run(self, event: HookRunList.Opened) -> None: diff --git a/src/chrys/app/tui/screens/main/session_handlers.py b/src/chrys/app/tui/screens/main/session_handlers.py index e6bd6a8d4..45247870c 100644 --- a/src/chrys/app/tui/screens/main/session_handlers.py +++ b/src/chrys/app/tui/screens/main/session_handlers.py @@ -25,7 +25,7 @@ StatusMessage, StatusTrail, ) -from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider +from chrys.app.tui.screens.main.runtime_info import RegistryRuntimeInfoProvider, enabled_hook_count from chrys.app.tui.screens.main.state import MainScreenServices, MainScreenState from chrys.app.tui.screens.sessions import WorkflowSessionPick from chrys.app.tui.support.gc_freeze import ( @@ -296,7 +296,11 @@ def runtime_metadata(self) -> AgentRuntimeDetails: @runtime_metadata.setter def runtime_metadata(self, value: AgentRuntimeDetails) -> None: + configured = enabled_hook_count(self._state.runtime.details) self._state.runtime.details = value + # The Hooks tab's empty state says whether any hook is configured. + if enabled_hook_count(value) != configured: + self._ui().hook_runs_changed() @property def main_usage_source_id(self) -> str: diff --git a/src/chrys/app/tui/widgets/sidebar/hooks.py b/src/chrys/app/tui/widgets/sidebar/hooks.py index 1a02cb66f..b1e9df80a 100644 --- a/src/chrys/app/tui/widgets/sidebar/hooks.py +++ b/src/chrys/app/tui/widgets/sidebar/hooks.py @@ -175,7 +175,9 @@ def __init__(self, run_id: str) -> None: HookRunList { height: 100%; width: 100%; - padding: 0 1; + /* Padding lies outside the scrollbar, so the right side has none: the one-cell bar sits + on the panel's edge, and its always-reserved column mirrors the left padding. */ + padding: 0 0 0 1; } HookRunList > .hook-runs--running { color: $warning; @@ -188,6 +190,7 @@ def __init__(self, run_id: str) -> None: } HookRunList > Horizontal { height: 1; + padding-right: 1; } HookRunList #hook-runs-title { width: 1fr; @@ -204,6 +207,7 @@ def __init__(self, run_id: str) -> None: HookRunList > #hook-runs-box { height: 1fr; scrollbar-size: 1 1; + scrollbar-gutter: stable; overflow-x: hidden; display: none; } @@ -218,6 +222,7 @@ def __init__(self, run_id: str) -> None: } HookRunList > #hook-runs-empty { height: 1fr; + padding-right: 1; color: $text-muted; content-align: center middle; } @@ -249,6 +254,10 @@ def __init__(self, *, locale_controller: LocaleController | None = None) -> None """The run whose details were opened last: its row stays marked.""" self._reveal = False """Scroll the selected run's row into view at the next paint that lists it.""" + self._empty_text = "" + """What the empty state says, as last painted.""" + self._row_styles: tuple[Style, Style, Style] | None = None + """The running, hover and selected styles the rows were last painted in.""" def compose(self) -> ComposeResult: with Horizontal(): @@ -268,9 +277,22 @@ def on_mount(self) -> None: self._timer = self.set_interval(1, self._tick, pause=True) # Scrolled, another row is under the mouse: the next move finds it. self.watch(self.query_one("#hook-runs-box", VerticalScroll), "scroll_y", self.unhover, init=False) + # The row colors are resolved into the painted table, so a theme change repaints it. + self.app.theme_changed_signal.subscribe(self, self._theme_changed) self._localize() self._paint() + def on_unmount(self) -> None: + self.app.theme_changed_signal.unsubscribe(self) + + def _theme_changed(self, _theme: object) -> None: + # Hidden or covered, it is painted in the new colors when it shows or its screen + # resumes; a repaint before then is work nobody sees. + if not self._composited or not is_widget_shown_on_active_screen(self): + return + if self._row_styles is not None and self._resolve_row_styles() != self._row_styles: + self.query_one("#hook-runs-rows", Static).update(self._rows(self._records), layout=False) + def on_show(self) -> None: self._composited = True if self._stale: @@ -385,18 +407,22 @@ def _paint(self) -> None: box = self.query_one("#hook-runs-box", VerticalScroll) grown = len(records) > self._painted_rows reveal = self._reveal and any(record.run_id == self._selected for record in records) - # Newest at the bottom: a list read to its end follows the runs that join it, as the chat - # does; one scrolled up to an older run stays there. - follow = grown and not reveal and (self._painted_rows <= 0 or box.scroll_y >= box.max_scroll_y) - rows.update(self._rows(records), layout=len(records) != self._painted_rows) - self._painted_rows = len(records) - if follow: - box.scroll_end(animate=False) # Only runs dropped past the log's cap: clearing is the user's own doing. unlisted = not self._kept_all notice = self.query_one("#hook-runs-unlisted", Static) + # Newest at the bottom: a list read to its end follows the runs that join it, and the + # notice that shows above them, as the chat does; one scrolled up to an older run stays there. + follow = ( + (grown or (unlisted and not notice.display)) + and not reveal + and (self._painted_rows <= 0 or box.scroll_y >= box.max_scroll_y) + ) + rows.update(self._rows(records), layout=len(records) != self._painted_rows) + self._painted_rows = len(records) if unlisted != notice.display: notice.display = unlisted + if follow: + box.scroll_end(animate=False) empty = self.query_one("#hook-runs-empty", Static) if not records: configured = self._configured() if self._configured is not None else 0 @@ -406,7 +432,11 @@ def _paint(self) -> None: reference = _EMPTY.bind(app=APP_DISPLAY_NAME) else: reference = _EMPTY_UNCONFIGURED.bind() - empty.update(Text(self._text(reference))) + text = self._text(reference) + # Painted again on every resume of the screen: an update that changes nothing would lay it out. + if text != self._empty_text: + self._empty_text = text + empty.update(Text(text)) if box.display != bool(records): box.display = bool(records) empty.display = not records @@ -433,14 +463,19 @@ def _scroll_to_selected(self) -> None: if row is None: return box = self.query_one("#hook-runs-box", VerticalScroll) - top = 1 if self.query_one("#hook-runs-unlisted", Static).display else 0 + # Below the notice, however many lines it wraps to. + top = self.query_one("#hook-runs-rows", Static).virtual_region.y box.scroll_to_region(Region(0, top + row, box.size.width, 1), center=True, animate=False, immediate=True) - def _rows(self, records: tuple[HookRunRecord, ...]) -> Table: + def _resolve_row_styles(self) -> tuple[Style, Style, Style]: + """The running, hover and selected styles in the current theme.""" resolved = self.get_component_rich_style("hook-runs--running", default=Style(color="yellow")) - running_style = Style(color=resolved.color) - hover = Style(bgcolor=self.get_component_rich_style("hook-runs--hover").bgcolor) - selected = Style(bgcolor=self.get_component_rich_style("hook-runs--selected").bgcolor) + hover = self.get_component_rich_style("hook-runs--hover") + selected = self.get_component_rich_style("hook-runs--selected") + return Style(color=resolved.color), Style(bgcolor=hover.bgcolor), Style(bgcolor=selected.bgcolor) + + def _rows(self, records: tuple[HookRunRecord, ...]) -> Table: + self._row_styles = running_style, hover, selected = self._resolve_row_styles() now = time.monotonic() still = self.app.animation_level == "none" grid = Table.grid(padding=(0, 1), expand=True) @@ -448,23 +483,26 @@ def _rows(self, records: tuple[HookRunRecord, ...]) -> Table: grid.add_column(justify="right", no_wrap=True, style="dim") grid.add_column(width=1, no_wrap=True) grid.add_column(ratio=1, no_wrap=True, overflow="ellipsis") - grid.add_column(justify="right", no_wrap=True) # The name matters more: a long result gives way to it past a third of the row. width = self.content_size.width grid.add_column( justify="right", no_wrap=True, overflow="ellipsis", max_width=max(8, width // 3) if width else None ) + # Last, so every row's time ends at the right edge, whatever results the rows show. + grid.add_column(justify="right", no_wrap=True) for row, record in enumerate(records): glyph = REST_GLYPH if still else SPINNER_FRAMES[self._frame % len(SPINNER_FRAMES)] icon = run_icon(record, running_style=running_style, glyph=glyph) - detail = running_style if record.state == "running" else "dim" - outcome = " · ".join(self._text(part) for part in run_outcome(record)) + running = record.state == "running" + detail = running_style if running else "dim" + # Its time, ticking in the running color, already says it runs. + outcome = "" if running else " · ".join(self._text(part) for part in run_outcome(record)) grid.add_row( str(record.number), icon, Text(run_label(record), no_wrap=True, overflow="ellipsis"), - Text(run_duration(record, now), style=detail), Text(outcome, style=detail, no_wrap=True, overflow="ellipsis"), + Text(run_duration(record, now), style=detail), style=hover if row == self._hovered else selected if record.run_id == self._selected else None, ) return grid diff --git a/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo b/src/chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo index cd1c48aeead48aca5501d981654417fd64f8c673..463e2bd9936a7ad2e17413c82990871549fb792f 100644 GIT binary patch delta 38906 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 G^8Wx$cNX9P delta 38773 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 a_4{z None: self.hook = hook self.task = task self.scope = scope # "turn" | "session" | "background" self.job = job self.operation_id = operation_id - self.execution_key = execution_key - self.trace = trace self.run = run + self.trace = trace class _Run: - """One run's identity and what the run log keeps of how it was started.""" + """One run: its entry in the runs in flight, its entry in the run log, and how it was started. + + The manager moves it through three steps, each changing both entries + together and saying what changed: ``_queue_run``, ``_take_slot`` and + ``_finish_run``. The last is called the moment the run's outcome is known, + before anything else is awaited, and takes effect once. + """ - __slots__ = ("payload_text", "retry", "run_id", "sub_agent", "tool_call_id", "tool_name") + __slots__ = ( + "key", + "output", + "payload_text", + "recorded", + "retry", + "run_id", + "sub_agent", + "tool_call_id", + "tool_name", + ) def __init__( self, run_id: str, *, + recorded: bool = False, payload_text: str | None = None, tool_name: str = "", tool_call_id: str = "", @@ -108,12 +123,31 @@ def __init__( retry: bool = False, ) -> None: self.run_id = run_id + self.key: int | None = None + """Its entry in the runs in flight; None until queued, and for a detached run.""" + self.recorded = recorded + """Whether the run log records it.""" self.payload_text = payload_text - """The payload file's text, serialized once when the run is registered; None when unrecorded.""" + """The payload file's text, serialized once when the run is made; None when the + runner serializes it, and once the run ended (it is the largest thing a run holds).""" self.tool_name = tool_name self.tool_call_id = tool_call_id self.sub_agent = sub_agent self.retry = retry + self.output: HookResult | None = None + """The result the runner fills while the hook runs; None until it starts, and + once the run ended. A cancelled run keeps what the hook printed from it.""" + + +@dataclass(frozen=True, slots=True) +class _Changed: + """What a step of a run changed: the runs in flight, the run log, or both.""" + + executions: bool = False + runs: bool = False + + def __or__(self, other: _Changed) -> _Changed: + return _Changed(executions=self.executions or other.executions, runs=self.runs or other.runs) # --------------------------------------------------------------------------- @@ -319,8 +353,7 @@ async def fire( except asyncio.CancelledError: if trace is not None: trace.finished_soon(outcome=HookOutcome.CANCELLED) - if self._end_run(run, outcome=HookOutcome.CANCELLED): - await self._notify_change(executions=False) + await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED)) raise except Exception as exc: logger.exception("Blocking hook %r failed before producing a result", hook.id) @@ -329,35 +362,56 @@ async def fire( override_before = decision.args_override reminders_before = len(decision.system_reminders) contexts_before = len(decision.extra_context) - _merge_blocking_result(decision, hook, result, event=event) - # Compared by value: a hook returning the arguments as they were changed nothing. - args_before: Any = None - if not decision.blocked and decision.args_override is not None: - if self._run_log is not None and decision.args_override != override_before: - args_before = copy.deepcopy(_tool_args(envelope)) - _apply_args_override_to_envelope(envelope, decision.args_override) - effect, applied = _blocking_effect( - event, - result, - decision, - blocked=decision.blocked and not blocked_before, - args_before=args_before, - args_after=_tool_args(envelope) if args_before is not None else None, - reminders_before=reminders_before, - contexts_before=contexts_before, - ) - recorded = self._end_run(run, result=result, effect=effect, applied=applied) try: - if trace is not None: - await trace.finished( - outcome=_hook_outcome(result, blocked=decision.blocked and not blocked_before), - arguments_modified=decision.args_override != override_before, - exit_code=result.exit_code, - timed_out=result.timed_out, - ) - finally: - if recorded: - await self._notify_change(executions=False) + _merge_blocking_result(decision, hook, result, event=event) + # Compared as JSON, what the tool gets: a hook returning the arguments as they were + # changed nothing, while 1 turned to true did, though Python finds them equal. + args_changed = False + if not decision.blocked and decision.args_override is not None: + returned = self._run_log is not None and decision.args_override is not override_before + args_before = _args_key(_tool_args(envelope)) if returned else None + _apply_args_override_to_envelope(envelope, decision.args_override) + # Either side too deep to write as JSON: counted as changed, since the hook + # returned an override. + args_changed = returned and (args_before is None or args_before != _args_key(_tool_args(envelope))) + blocked = decision.blocked and not blocked_before + try: + arguments_modified = decision.args_override != override_before + except RecursionError: + # Too deep to compare: the hook returned an override, all that can be told. + arguments_modified = decision.args_override is not override_before + effect, applied = _blocking_effect( + event, + result, + decision, + blocked=blocked, + args_changed=args_changed, + args_after=_tool_args(envelope), + reminders_before=reminders_before, + contexts_before=contexts_before, + ) + except Exception: + # The hook's result, written by the hook, could not be taken: its run ended all + # the same, and is ended and reported before the error goes on to the caller. + outcome = _hook_outcome(result, blocked=False) + await self._report_end( + self._finish_run(run, outcome=outcome, result=result), + trace, + outcome=outcome, + exit_code=result.exit_code, + timed_out=result.timed_out, + ) + raise + await self._report_end( + self._finish_run( + run, outcome=_hook_outcome(result, blocked=False), result=result, effect=effect, applied=applied + ), + trace, + outcome=_hook_outcome(result, blocked=blocked), + arguments_modified=arguments_modified, + exit_code=result.exit_code, + timed_out=result.timed_out, + ) if decision.blocked: # Cancel any planned non-blocking hooks for this event — # nothing else will execute on this dispatch. @@ -382,132 +436,193 @@ async def fire( return decision - # ── runs in flight ─────────────────────────────────────────────── - - def _add_execution(self, hook: HookConfig, *, waited_at: HookWaitPoint, run_id: str) -> int: - key = next(self._execution_keys) - self._executions[key] = HookExecutionView( - hook_id=hook.id, waited_at=waited_at, queued=True, started_at=time.monotonic(), run_id=run_id - ) - return key - - def _mark_running(self, key: int) -> bool: - """Mark run *key* as holding a slot; False when the manager closed and cleared it.""" - view = self._executions.get(key) - if view is None: - return False - self._executions[key] = replace(view, queued=False) - return True - - async def _notify_change(self, *, executions: bool = True, runs: bool = True) -> None: - """Report the runs in flight (*executions*), then that the run log changed (*runs*). - - Separate conditions: a run ending after the manager closed changes the - log but publishes no snapshot. A failing subscriber never touches the hook. - """ - if executions and self.on_change is not None: - self._reported_runs = bool(self._executions) - try: - await self.on_change(self) - except Exception: - logger.exception("Hook execution change notification failed") - if runs and self._run_log is not None and self._on_runs_change is not None: - try: - await self._on_runs_change() - except Exception: - logger.exception("Hook run log change notification failed") - - def _open_trace( - self, - target_operation_id: str | None, - *, - context: TrajectoryContext | None = None, - operation_id: str | None = None, - ) -> HookOperationTrace | None: - return HookOperationTrace.open( - target_operation_id=target_operation_id, - context=context, - provider=self.trajectory_context_provider, - operation_id=operation_id, - ) - - # ── run log ────────────────────────────────────────────────────── + # ── runs ───────────────────────────────────────────────────────── def _new_run(self, payload: dict[str, Any], *, origin: InvocationOrigin | None = None, retry: bool = False) -> _Run: """A run's id, and, when runs are recorded, the payload it will be handed as it is now. Serialized here, not kept by reference: a later blocking hook's - argument override rewrites the same dict in place. + argument override rewrites the same dict in place. Never raises: a + payload that cannot be serialized is left to the runner, whose own + attempt fails the run, as is one that is no object, which a pending + outbox job read back can hold. """ run_id = new_analytics_id() if self._run_log is None: return _Run(run_id) - tool = payload.get("tool") + fields = payload if isinstance(payload, dict) else {} + tool = fields.get("tool") tool_name = tool_call_id = "" if isinstance(tool, dict): - tool_name = str(tool.get("name") or "") - tool_call_id = str(tool.get("call_id") or "") - sub_agent = "" - if origin is not None and origin.kind == "sub_agent": - named = payload.get("sub_agent") - name = named.get("name") if isinstance(named, dict) else None - sub_agent = str(name or payload.get("profile") or "") + tool_name = _text_field(tool, "name") + tool_call_id = _text_field(tool, "call_id") + # Every event a sub-agent fires carries its profile name; only some carry its display name. + sub_agent = _text_field(fields, "profile") if origin is not None and origin.kind == "sub_agent" else "" + try: + payload_text: str | None = json.dumps(payload, default=str) + except TypeError, ValueError, RecursionError: + payload_text = None return _Run( run_id, - payload_text=json.dumps(payload, default=str), + recorded=True, + payload_text=payload_text, tool_name=tool_name, tool_call_id=tool_call_id, sub_agent=sub_agent, retry=retry, ) - def _register_run(self, hook: HookConfig, run: _Run) -> None: - if self._run_log is None or run.payload_text is None: - return + def _register_run(self, hook: HookConfig, run: _Run) -> _Changed: + """Add *run* to the run log, queued for a slot.""" + if self._run_log is None or not run.recorded: + return _Changed() self._run_log.register( run_id=run.run_id, session_id=self._run_log_session_id, hook=hook, source=self._hook_sources.get(id(hook), ""), source_path=self._hook_source_paths.get(id(hook), ""), - payload_text=run.payload_text, + payload_text=run.payload_text or "", tool_name=run.tool_name, tool_call_id=run.tool_call_id, sub_agent=run.sub_agent, retry=run.retry, ) + return _Changed(runs=True) + + def _queue_run(self, hook: HookConfig, run: _Run, *, waited_at: HookWaitPoint) -> _Changed: + """Add *run* to the runs in flight and to the run log, queued for a slot.""" + run.key = next(self._execution_keys) + self._executions[run.key] = HookExecutionView( + hook_id=hook.id, waited_at=waited_at, queued=True, started_at=time.monotonic(), run_id=run.run_id + ) + return _Changed(executions=True) | self._register_run(hook, run) + + def _take_slot(self, run: _Run) -> _Changed: + """Mark *run* as holding its slot. - def _start_run(self, run: _Run) -> bool: - return self._run_log is not None and self._run_log.mark_running(run.run_id) + Not in the runs in flight once the manager closed and cleared them, + and then reported no more there; the run log records it still. + """ + view = self._executions.get(run.key) if run.key is not None else None + if view is not None and run.key is not None: + self._executions[run.key] = replace(view, queued=False) + return _Changed( + executions=view is not None, runs=self._run_log is not None and self._run_log.mark_running(run.run_id) + ) - def _end_run( + def _finish_run( self, run: _Run, *, - outcome: str = HookOutcome.CANCELLED, + outcome: str, result: HookResult | None = None, effect: HookRunEffect | None = None, applied: HookRunApplied | None = None, log_path: str = "", error: str = "", - ) -> bool: - """End *run* in the run log; whether it was open there.""" - if self._run_log is None: - return False + ) -> _Changed: + """End *run*: drop it from the runs in flight, and end it in the run log. + + *outcome* is how the hook's process ended, as its span records it but + for a block: the run log keeps that as the *effect* of a run that + succeeded. Takes effect once; nothing changes when called again. + Without *result*, the run log keeps what the hook printed before it + was cancelled, or *error* as its stderr. + """ + run.payload_text = None + output, run.output = run.output, None + removed = run.key is not None and self._executions.pop(run.key, None) is not None + if self._run_log is None or not run.recorded: + return _Changed(executions=removed) if result is None: - return self._run_log.finish(run.run_id, outcome=outcome, stderr=error, log_path=log_path) - return self._run_log.finish( - run.run_id, - outcome=_hook_outcome(result, blocked=False), - exit_code=result.exit_code, - effect=effect, - applied=applied, - result_file=result.result_file, - decision=json.dumps(result.decision, indent=2, ensure_ascii=False, default=str) if result.decision else "", - stdout=result.stdout, - stderr=result.stderr, - stdout_dropped=result.stdout_dropped, - stderr_dropped=result.stderr_dropped, + ended = self._run_log.finish( + run.run_id, + outcome=outcome, + stdout=output.stdout if output is not None else "", + stderr=error or (output.stderr if output is not None else ""), + stdout_dropped=output.stdout_dropped if output is not None else 0, + stderr_dropped=output.stderr_dropped if output is not None else 0, + log_path=log_path, + ) + else: + ended = self._run_log.finish( + run.run_id, + outcome=outcome, + exit_code=result.exit_code, + effect=effect, + applied=applied, + result_file=result.result_file, + # An empty object is still what the hook wrote. + decision=indented_json(result.decision) if result.result_file == "ok" else "", + stdout=result.stdout, + stderr=result.stderr, + stdout_dropped=result.stdout_dropped, + stderr_dropped=result.stderr_dropped, + log_path=log_path, + ) + return _Changed(executions=removed, runs=ended) + + async def _report(self, changed: _Changed) -> None: + if changed.executions or changed.runs: + await self._notify_change(executions=changed.executions, runs=changed.runs) + + async def _report_end( + self, + changed: _Changed, + trace: HookOperationTrace | None, + *, + outcome: str, + arguments_modified: bool = False, + exit_code: int | None = None, + timed_out: bool = False, + ) -> None: + """Report what a run's end changed, then close its span with *outcome*. + + In that order: the panel and the indicator never wait on the + trajectory's write ack. A cancel landing in the report still closes the span. + """ + try: + await self._report(changed) + except asyncio.CancelledError: + if trace is not None: + trace.finished_soon(outcome=outcome) + raise + if trace is not None: + await trace.finished( + outcome=outcome, arguments_modified=arguments_modified, exit_code=exit_code, timed_out=timed_out + ) + + async def _notify_change(self, *, executions: bool = True, runs: bool = True) -> None: + """Report the runs in flight (*executions*), then that the run log changed (*runs*). + + Separate conditions: a run ending after the manager closed changes the + log but publishes no snapshot. A failing subscriber never touches the hook. + """ + if executions and self.on_change is not None: + self._reported_runs = bool(self._executions) + try: + await self.on_change(self) + except Exception: + logger.exception("Hook execution change notification failed") + if runs and self._run_log is not None and self._on_runs_change is not None: + try: + await self._on_runs_change() + except Exception: + logger.exception("Hook run log change notification failed") + + def _open_trace( + self, + target_operation_id: str | None, + *, + context: TrajectoryContext | None = None, + operation_id: str | None = None, + ) -> HookOperationTrace | None: + return HookOperationTrace.open( + target_operation_id=target_operation_id, + context=context, + provider=self.trajectory_context_provider, + operation_id=operation_id, ) # ── drains ─────────────────────────────────────────────────────── @@ -593,13 +708,10 @@ async def _drain(self, *, scopes: set[str], timeout: float | None) -> None: # Also when this drain is cancelled (an interrupt during # ``drain_turn``): ``gather`` passes the cancel on to the runs and # raises only once every one of them is done. - dropped = ended = False + changed = _Changed() for tracked_task in tracked: - was_dropped, was_ended = self._settle_unstarted(tracked_task) - dropped |= was_dropped - ended |= was_ended - if dropped or ended: - await self._notify_change(executions=dropped, runs=ended) + changed |= self._settle_unstarted(tracked_task) + await self._report(changed) for tracked_task in tracked: if not tracked_task.task.done(): @@ -720,31 +832,25 @@ async def _run_blocking( waited_at: HookWaitPoint, run: _Run, ) -> HookResult: - # Registered as queued before the slot is taken: probing the semaphore - # first would race other runs taking or releasing it. - key = self._add_execution(hook, waited_at=waited_at, run_id=run.run_id) - self._register_run(hook, run) - try: - await self._notify_change() - async with self._sem: - # Published while holding the slot: ``on_change`` must stay a - # quick store (the TUI defers its render), or it delays every - # run queued behind this one. A run that queued past the - # manager's close publishes no snapshot, as at its end, but - # still changes the run log. - running = self._mark_running(key) - await self._notify_change(executions=running, runs=self._start_run(run)) - return await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text) - finally: - # Every removal is reported, a cancelled run's too: the report - # runs on the run's own task and reads the set when it is stored, - # so the last stored snapshot stays the current one. A run ending - # after the manager closed (an undrained ``user_interrupt`` - # observer) was already cleared and reported: publishing again - # would overwrite the snapshot of the manager that replaced this one. - # The caller ends the run in the run log, with what it applied. - removed = self._executions.pop(key, None) is not None - await self._notify_change(executions=removed, runs=False) + """Queue *run*, take a slot and run it; the caller ends the run, however this ends. + + Every end is reported, a cancelled run's too: the report runs on the + run's own task and reads the set when it is stored, so the last stored + snapshot stays the current one. A run ending after the manager closed + (an undrained ``user_interrupt`` observer) was already cleared and + reported: publishing again would overwrite the snapshot of the manager + that replaced this one. + """ + # Queued before the slot is taken: probing the semaphore first would + # race other runs taking or releasing it. + await self._report(self._queue_run(hook, run, waited_at=waited_at)) + async with self._sem: + # Published while holding the slot: ``on_change`` must stay a + # quick store (the TUI defers its render), or it delays every + # run queued behind this one. + await self._report(self._take_slot(run)) + run.output = HookResult(hook_id=hook.id, exit_code=None) + return await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text, result=run.output) async def _spawn_nonblocking( self, @@ -821,6 +927,9 @@ async def _spawn_nonblocking( except asyncio.CancelledError: if trace is not None: trace.finished_soon(outcome=HookOutcome.CANCELLED) + # Recorded as its span is: interrupted while it was handed off. + self._register_run(hook, run) + await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED)) raise except Exception as exc: _log_nonblocking_spawn_failure(hook, exc) @@ -829,24 +938,14 @@ async def _spawn_nonblocking( self._outbox.mark_failed(job, exit_code=None, error=str(exc)) # Recorded ended at once: nobody observes a detached run. self._register_run(hook, run) - recorded = self._end_run(run, outcome=HookOutcome.SPAWN_FAILED, error=str(exc)) - try: - if trace is not None: - await trace.finished(outcome=HookOutcome.SPAWN_FAILED) - finally: - if recorded: - await self._notify_change(executions=False) + changed = self._finish_run(run, outcome=HookOutcome.SPAWN_FAILED, error=str(exc)) + await self._report_end(changed, trace, outcome=HookOutcome.SPAWN_FAILED) return + # Handed to the detached worker: the parent never observes its + # exit, so the run, and its recorded span, end at the hand-off. self._register_run(hook, run) - recorded = self._end_run(run, outcome=HookOutcome.DETACHED, log_path=str(log_path)) - try: - if trace is not None: - # Handed to the detached worker: the parent never observes - # its exit, so the recorded span ends at the hand-off. - await trace.finished(outcome=HookOutcome.DETACHED) - finally: - if recorded: - await self._notify_change(executions=False) + changed = self._finish_run(run, outcome=HookOutcome.DETACHED, log_path=str(log_path)) + await self._report_end(changed, trace, outcome=HookOutcome.DETACHED) return # Observed to its end like any other run, though nobody waits on it. @@ -864,53 +963,38 @@ def _start_tracked( scope: str, waited_at: HookWaitPoint, ) -> None: - """Register a non-blocking run, start its task and track it, all without suspending. + """Queue a non-blocking run, start its task and track it, all without suspending. The caller checked that the manager is open and reports the run only after this: a drain or close that runs during the report already sees - the task, which owns the run's span, snapshot entry and run log entry - from here on. + the task, which owns the run and its span from here on. """ - key = self._add_execution(hook, waited_at=waited_at, run_id=run.run_id) - self._register_run(hook, run) - task = asyncio.create_task(self._run_async_tracked(hook, payload, job, trace, execution_key=key, run=run)) + self._queue_run(hook, run, waited_at=waited_at) + task = asyncio.create_task(self._run_async_tracked(hook, payload, job, trace, run=run)) operation_id = trace.operation_id if trace is not None and trace.start_committed else None - tracked = _Tracked( - hook=hook, - task=task, - scope=scope, - job=job, - operation_id=operation_id, - execution_key=key, - trace=trace, - run=run, - ) + tracked = _Tracked(hook=hook, task=task, scope=scope, job=job, operation_id=operation_id, run=run, trace=trace) self._inflight.append(tracked) if scope in ("detached", "background"): task.add_done_callback(lambda _task, tracked=tracked: self._reap_background(tracked)) - def _settle_unstarted(self, tracked: _Tracked) -> tuple[bool, bool]: - """Settle a run cancelled before its first step. + def _settle_unstarted(self, tracked: _Tracked) -> _Changed: + """Settle a run cancelled before its first step, and say what that changed. - Returns whether its snapshot entry was dropped and whether its run log - entry was ended. Such a task never ran the body that closes the run's - span, drops its entry and ends its run. A no-op for a run that has not - ended, or whose body ran: that body closed the span (closing twice - keeps the first), dropped the entry and ended the run. + Such a task never ran the body that closes the run's span and ends + the run. A no-op for a run that has not ended, or whose body ran: that + body closed the span (closing twice keeps the first) and ended the run. """ if not tracked.task.cancelled(): - return False, False + return _Changed() if tracked.trace is not None: tracked.trace.finished_soon(outcome=HookOutcome.CANCELLED) - dropped = tracked.execution_key is not None and self._executions.pop(tracked.execution_key, None) is not None - ended = tracked.run is not None and self._end_run(tracked.run, outcome=HookOutcome.CANCELLED) - return dropped, ended + return self._finish_run(tracked.run, outcome=HookOutcome.CANCELLED) def _reap_background(self, tracked: _Tracked) -> None: with contextlib.suppress(ValueError): self._inflight.remove(tracked) - # Nothing here cancels these runs (loop teardown does), so a dropped - # entry or ended run goes unreported: the next report leaves it out. + # Nothing here cancels these runs (loop teardown does), so a run ended + # here goes unreported: the next report leaves it out. self._settle_unstarted(tracked) try: tracked.task.result() @@ -926,26 +1010,19 @@ async def _run_async_tracked( job: OutboxJob | None, trace: HookOperationTrace | None = None, *, - execution_key: int | None = None, run: _Run | None = None, ) -> HookResult: """Wrap ``runner.run_and_wait`` with outbox + semaphore + error logging.""" run = run if run is not None else _Run(new_analytics_id()) - ended = False try: - result = await self._run_async_tracked_inner(hook, payload, job, trace, execution_key, run) - ended = self._end_run(run, result=result, applied=_nonblocking_applied(result)) - return result - except asyncio.CancelledError: - ended = self._end_run(run, outcome=HookOutcome.CANCELLED) - raise + return await self._run_async_tracked_inner(hook, payload, job, trace, run) finally: - # Reported however it ends, as for a blocking run; no snapshot + # A run ended once its outcome was known; this ends one cancelled + # before then, and reports it, as for a blocking run. No snapshot # after the manager closed first: ``background`` runs are not # drained and may end long after the close. The run log still # records them. - removed = execution_key is not None and self._executions.pop(execution_key, None) is not None - await self._notify_change(executions=removed, runs=ended) + await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED)) async def _run_async_tracked_inner( self, @@ -953,7 +1030,6 @@ async def _run_async_tracked_inner( payload: dict[str, Any], job: OutboxJob | None, trace: HookOperationTrace | None, - execution_key: int | None, run: _Run, ) -> HookResult: try: @@ -962,11 +1038,13 @@ async def _run_async_tracked_inner( # span was opened before the task was created, and once the task # took a step only this handler closes it. async with self._sem: - running = execution_key is not None and self._mark_running(execution_key) - await self._notify_change(executions=running, runs=self._start_run(run)) + await self._report(self._take_slot(run)) if job is not None: self._outbox.mark_started(job) - result = await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text) + run.output = HookResult(hook_id=hook.id, exit_code=None) + result = await self._runner.run_and_wait( + hook, payload, payload_text=run.payload_text, result=run.output + ) except asyncio.CancelledError: if job is not None and hook.execution.delivery == "durable": logger.info( @@ -979,37 +1057,31 @@ async def _run_async_tracked_inner( raise except Exception as exc: logger.exception("Hook %r raised", hook.id) - if job is not None: - with contextlib.suppress(OSError): - self._outbox.mark_failed(job, exit_code=None, error=str(exc)) - if trace is not None: - await trace.finished(outcome=HookOutcome.LAUNCH_ERROR) - return HookResult(hook_id=hook.id, exit_code=None, stderr=str(exc)) - # Before the span is closed: the hook has already run, and the outbox - # is what decides whether it runs again. An interrupt landing on the - # awaited marker below must not leave a finished durable job pending - # for the next runtime to replay. + result = HookResult(hook_id=hook.id, exit_code=None, stderr=str(exc)) + launch_error: str | None = str(exc) + else: + launch_error = None + # The hook has run: what it did is settled before anything is awaited, + # so an interrupt landing on an await below neither logs it cancelled + # nor leaves a finished durable job pending for the next runtime to + # replay (the outbox decides whether it runs again). + outcome = _hook_outcome(result, blocked=False) + changed = self._finish_run(run, outcome=outcome, result=result, applied=_nonblocking_applied(result)) if job is not None: - if _result_failed(result): - with contextlib.suppress(OSError): + with contextlib.suppress(OSError): + if launch_error is not None: + self._outbox.mark_failed(job, exit_code=None, error=launch_error) + elif _result_failed(result): self._outbox.mark_failed( job, exit_code=result.exit_code, error=_result_failure_label(result), stderr_tail=result.stderr, ) - else: - with contextlib.suppress(OSError): + else: self._outbox.mark_done(job, exit_code=result.exit_code or 0) - - if trace is not None: - await trace.finished( - outcome=_hook_outcome(result, blocked=False), - exit_code=result.exit_code, - timed_out=result.timed_out, - ) - - if _result_failed(result): + await self._report_end(changed, trace, outcome=outcome, exit_code=result.exit_code, timed_out=result.timed_out) + if launch_error is None and _result_failed(result): _log_hook_failure(hook, result) return result @@ -1083,9 +1155,33 @@ def _tool_args(envelope: dict[str, Any]) -> Any: return tool.get("args") if isinstance(tool, dict) else None +def _args_key(args: Any) -> str | None: + """*args* as JSON with sorted keys, which two equal argument sets share. + + None when they nest deeper than the encoder writes, which parsed JSON can: + the decoder takes deeper objects than the encoder (and dict equality) does. + """ + try: + return json.dumps(args, sort_keys=True, ensure_ascii=False, default=str) + except RecursionError: + return None + + +def _text_field(fields: dict[str, Any], key: str) -> str: + """*fields*' *key* when it is a string, else empty.""" + value = fields.get(key) + return value if isinstance(value, str) else "" + + +def _decision_action(decision: dict[str, Any]) -> str: + """The ``action`` a result file names, lowercased; ``allow`` unless it names one as a string.""" + action = decision.get("action") + return action.lower() if isinstance(action, str) and action else "allow" + + def _asks_unsupported(event: HookEvent, decision: dict[str, Any]) -> bool: """Whether a result file asks for something *event* does not take.""" - action = str(decision.get("action") or "allow").lower() + action = _decision_action(decision) return ( (action == "block" and event not in GATED_EVENTS) or (action == "modify" and event not in _ARGS_OVERRIDE_EVENTS) @@ -1095,7 +1191,7 @@ def _asks_unsupported(event: HookEvent, decision: dict[str, Any]) -> bool: def _asks_anything(decision: dict[str, Any]) -> bool: - action = str(decision.get("action") or "allow").lower() + action = _decision_action(decision) return action in ("block", "modify") or bool(decision.get("system_reminder") or decision.get("extra_context")) @@ -1105,14 +1201,15 @@ def _blocking_effect( decision: HookDecision, *, blocked: bool, - args_before: Any, + args_changed: bool, args_after: Any, reminders_before: int, contexts_before: int, ) -> tuple[HookRunEffect | None, HookRunApplied]: """What iCode took from one blocking run, folded into *decision*; at most one effect. - *blocked* when this run blocked; *args_before* is None unless it changed the override. + *blocked* when this run blocked; *args_changed* when its override changed the + arguments, *args_after*. """ failed = _result_failed(result) if blocked: @@ -1121,8 +1218,8 @@ def _blocking_effect( ) if failed: return None, HookRunApplied(not_applied="failed") - if args_before is not None and args_before != args_after: - return "modified_args", HookRunApplied(args=json.dumps(args_after, indent=2, ensure_ascii=False, default=str)) + if args_changed: + return "modified_args", HookRunApplied(args=indented_json(args_after)) if len(decision.system_reminders) > reminders_before: return "added_reminder", HookRunApplied(reminder=decision.system_reminders[-1]) if len(decision.extra_context) > contexts_before: @@ -1183,7 +1280,7 @@ def _merge_blocking_result( # only the configured error policy and ignore any action payload. return - action = str(result.decision.get("action") or "allow").lower() + action = _decision_action(result.decision) if action == "block": if event in GATED_EVENTS: decision.blocked = True @@ -1237,6 +1334,9 @@ def _hook_outcome(result: HookResult, *, blocked: bool) -> str: def _result_failure_label(result: HookResult) -> str: + # Named apart from a timeout: stderr only ends with the note, which a cut may drop. + if result.stopped: + return "stopped" if result.timed_out: return "timeout" if result.exit_code is None: diff --git a/src/chrys/service/hooks/run_log.py b/src/chrys/service/hooks/run_log.py index d254a7e72..3f660c273 100644 --- a/src/chrys/service/hooks/run_log.py +++ b/src/chrys/service/hooks/run_log.py @@ -11,6 +11,7 @@ from __future__ import annotations +import json import time from dataclasses import dataclass, replace from typing import TYPE_CHECKING @@ -33,8 +34,19 @@ HOOK_RUN_LOG_MAX_DETAIL_CHARS = 16 * 1024 * 1024 """What the kept details may hold together; the oldest ended runs' details go first.""" -HOOK_RUN_PAYLOAD_MAX_CHARS = 64 * 1024 -"""Payload text kept per run (payloads are ASCII JSON, so characters are bytes).""" +HOOK_RUN_TEXT_MAX_BYTES = 64 * 1024 +"""What a run keeps of its payload, of the tool arguments it changed and of why it blocked, in UTF-8 bytes. + +The block reason outlives the other details (``_without_details``), so the +ended runs' reasons always fit the detail budget. Every other text a run keeps +is bounded where it is made: the output by its capture, the result file and +what iCode took from it by the result file's limit, its JSON by +``indented_json``. +""" + +_INDENT_SLACK_CHARS = 4096 +UNWRITABLE_JSON = "(nested too deeply to write as JSON)" +"""What ``indented_json`` gives for a value nested deeper than the encoder writes.""" @dataclass(slots=True) @@ -43,10 +55,71 @@ class _Entry: detail: HookRunDetail -def _io_size(io: HookRunIO | None) -> int: - if io is None: - return 0 - return len(io.payload) + len(io.decision) + len(io.stdout) + len(io.stderr) +def _detail_size(detail: HookRunDetail) -> int: + """Every text *detail* keeps, in characters: what the detail budget counts.""" + size = 0 + io = detail.io + if io is not None: + size += len(io.payload) + len(io.decision) + len(io.stdout) + len(io.stderr) + applied = detail.applied + if applied is not None: + size += len(applied.block_reason) + len(applied.args) + len(applied.reminder) + len(applied.extra_context) + return size + + +def _without_details(detail: HookRunDetail) -> HookRunDetail: + """*detail* without what it read and wrote, and without the texts iCode took from it. + + The block reason stays: a run that blocked an action still says why. + """ + applied = detail.applied + if applied is not None: + applied = replace(applied, args="", args_size=0, reminder="", extra_context="") + return replace(detail, io=None, applied=applied) + + +def indented_json(value: object) -> str: + """*value* as JSON, indented unless that would swell it past twice its compact size, then compact. + + Indenting grows with the nesting depth on every line, so a small deeply + nested value would indent into a huge text. Never raises: parsed JSON can + nest deeper than the encoder writes, and reads ``UNWRITABLE_JSON`` then. + """ + try: + compact = json.dumps(value, ensure_ascii=False, default=str) + except RecursionError: + return UNWRITABLE_JSON + limit = 2 * len(compact) + _INDENT_SLACK_CHARS + parts: list[str] = [] + size = 0 + try: + # The pure-Python encoder streams, so a swelling text is given up before it is built. + for part in json.JSONEncoder(ensure_ascii=False, default=str, indent=2).iterencode(value): + size += len(part) + if size > limit: + return compact + parts.append(part) + except RecursionError: + return compact + return "".join(parts) + + +def _clip_text(text: str, limit: int = HOOK_RUN_TEXT_MAX_BYTES) -> tuple[str, int]: + """*text* cut to at most *limit* UTF-8 bytes, and its full size in them. + + A cut drops the character it splits; lone surrogates, which a path that is not + UTF-8 holds, are kept as they are. + """ + if text.isascii(): + return text[:limit], len(text) + data = text.encode("utf-8", "surrogatepass") + if len(data) <= limit: + return text, len(data) + cut = limit + # Back to the first byte of the character the limit splits: its others are 10xxxxxx. + while cut and data[cut] & 0xC0 == 0x80: + cut -= 1 + return data[:cut].decode("utf-8", "surrogatepass"), len(data) class HookRunLog: @@ -64,6 +137,7 @@ def __init__( self._ended: list[str] = [] """Ended run ids, oldest end first.""" self._detail_chars = 0 + """``_detail_size`` of every kept detail; only ``_set_detail`` and ``_trim_ended`` change it.""" self._registered = 0 """Runs registered so far: the next run's number is one more.""" self._dropped = 0 @@ -135,9 +209,10 @@ def register( argv = (*run.argv, *run.args) else: argv = (run.shell,) + payload, payload_size = _clip_text(payload_text) io = HookRunIO( - payload=payload_text[:HOOK_RUN_PAYLOAD_MAX_CHARS], - payload_size=len(payload_text.encode("utf-8")), + payload=payload, + payload_size=payload_size, result_file=None, decision="", stdout="", @@ -162,8 +237,9 @@ def register( io=io, applied=None, ) - self._entries[run_id] = _Entry(record, detail) - self._detail_chars += _io_size(io) + entry = _Entry(record, detail) + self._entries[run_id] = entry + self._detail_chars += _detail_size(detail) self._trim_details() def mark_running(self, run_id: str) -> bool: @@ -190,7 +266,10 @@ def finish( stderr_dropped: int = 0, log_path: str = "", ) -> bool: - """End a queued or running run; False when it is not open here.""" + """End a queued or running run; False when it is not open here. + + Changed arguments and a block reason past ``HOOK_RUN_TEXT_MAX_BYTES`` are cut, as the payload is. + """ entry = self._entries.get(run_id) if entry is None or entry.record.state == "ended": return False @@ -204,7 +283,7 @@ def finish( ) io = entry.detail.io if io is not None: - new_io = replace( + io = replace( io, result_file=result_file, decision=decision, @@ -213,23 +292,22 @@ def finish( stdout_dropped=stdout_dropped, stderr_dropped=stderr_dropped, ) - self._detail_chars += _io_size(new_io) - _io_size(io) - io = new_io - shared = bool(log_path) and any( - other.detail.log_path == log_path for key, other in self._entries.items() if key != run_id - ) - if shared: + applied = applied if applied is not None else HookRunApplied() + if applied.args: + args, size = _clip_text(applied.args) + applied = replace(applied, args=args, args_size=size if args != applied.args else 0) + if applied.block_reason: + applied = replace(applied, block_reason=_clip_text(applied.block_reason)[0]) + shared = False + if log_path: for key, other in self._entries.items(): - if key != run_id and other.detail.log_path == log_path and not other.detail.shared_log: - other.detail = replace(other.detail, shared_log=True) + if key != run_id and other.detail.log_path == log_path: + shared = True + if not other.detail.shared_log: + self._set_detail(other, replace(other.detail, shared_log=True)) entry.record = record - entry.detail = replace( - entry.detail, - record=record, - io=io, - applied=applied if applied is not None else HookRunApplied(), - log_path=log_path, - shared_log=shared, + self._set_detail( + entry, replace(entry.detail, record=record, io=io, applied=applied, log_path=log_path, shared_log=shared) ) self._ended.append(run_id) self._trim_ended() @@ -238,23 +316,24 @@ def finish( def _set_record(self, entry: _Entry, record: HookRunRecord) -> None: entry.record = record - entry.detail = replace(entry.detail, record=record) + self._set_detail(entry, replace(entry.detail, record=record)) + + def _set_detail(self, entry: _Entry, detail: HookRunDetail) -> None: + """Every change to a kept detail goes through here, which keeps the budget's count exact.""" + self._detail_chars += _detail_size(detail) - _detail_size(entry.detail) + entry.detail = detail def _trim_ended(self) -> None: while len(self._ended) > self._max_ended: entry = self._entries.pop(self._ended.pop(0)) - self._detail_chars -= _io_size(entry.detail.io) + self._detail_chars -= _detail_size(entry.detail) self._dropped += 1 def _trim_details(self) -> None: """Drop the oldest ended runs' details until the rest fit; queued and running runs keep theirs.""" - if self._detail_chars <= self._max_detail_chars: - return for run_id in self._ended: - entry = self._entries[run_id] - if entry.detail.io is None: - continue - self._detail_chars -= _io_size(entry.detail.io) - entry.detail = replace(entry.detail, io=None) if self._detail_chars <= self._max_detail_chars: return + entry = self._entries[run_id] + if entry.detail.io is not None: + self._set_detail(entry, _without_details(entry.detail)) diff --git a/src/chrys/service/hooks/runner.py b/src/chrys/service/hooks/runner.py index b094a2e93..750c731f0 100644 --- a/src/chrys/service/hooks/runner.py +++ b/src/chrys/service/hooks/runner.py @@ -43,8 +43,9 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Final, Literal +from typing import TYPE_CHECKING, Any, BinaryIO, Final +from chrys.foundation.models.hook_runs import HookResultFileStatus from chrys.foundation.platform.output_capture import BoundedCapture, drain_process_pipes from chrys.foundation.platform.process import ( SubprocessStoppedError, @@ -69,8 +70,6 @@ HOOK_OUTPUT_CAPTURE_LIMIT_BYTES = 256 * 1024 """Bytes kept of a waited-for hook's stdout and of its stderr; decisions come from the result file.""" -ResultFileStatus = Literal["ok", "missing", "invalid"] - # --------------------------------------------------------------------------- # Public types # --------------------------------------------------------------------------- @@ -90,6 +89,8 @@ class HookResult: and for timeouts that hit the SIGKILL fallback. """ timed_out: bool = False + stopped: bool = False + """The process entered job-control stopped state and was terminated; ``timed_out`` is set too.""" decision: dict[str, Any] = field(default_factory=dict) """Parsed JSON from ``CHRYS_HOOK_RESULT``; empty dict if the file was absent / empty / malformed. @@ -99,7 +100,7 @@ class HookResult: stdout_dropped: int = 0 stderr_dropped: int = 0 """Bytes cut from the middle of each stream past ``HOOK_OUTPUT_CAPTURE_LIMIT_BYTES``.""" - result_file: ResultFileStatus = "missing" + result_file: HookResultFileStatus = "missing" """Whether the result file was read (``ok``), absent or empty, or ignored as invalid.""" duration_ms: int = 0 @@ -164,7 +165,12 @@ def hooks_dir(self) -> Path: return self._hooks_dir async def run_and_wait( - self, hook: HookConfig, payload: dict[str, Any], *, payload_text: str | None = None + self, + hook: HookConfig, + payload: dict[str, Any], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: """Spawn *hook* with stdio pipes and await it (with timeout). @@ -172,13 +178,16 @@ async def run_and_wait( only differs in *who* awaits — the manager does, after dispatch returns — so this entry point is shared. *payload_text*, when given, is written to the payload file as it is, in place of - serializing *payload*. + serializing *payload*. *result*, when given, is filled and returned + in place of a new one: a caller whose wait is cancelled keeps what + the hook printed until then. """ start = datetime.now(UTC) - result = HookResult(hook_id=hook.id, exit_code=None) + if result is None: + result = HookResult(hook_id=hook.id, exit_code=None) invocation: HookInvocation | None = None - # Outside the subprocess block: a run that times out or is stopped - # keeps what it printed until then. + # Outside the subprocess block: a run that times out, is stopped or + # is cancelled keeps what it printed until then. stdout = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) stderr = BoundedCapture(HOOK_OUTPUT_CAPTURE_LIMIT_BYTES) try: @@ -209,18 +218,21 @@ async def run_and_wait( ) return result except SubprocessStoppedError: - result.timed_out = True + result.timed_out = result.stopped = True _take_output(result, stdout, stderr) note = "Hook process entered stopped state and was terminated." result.stderr = f"{result.stderr.rstrip()}\n{note}" if result.stderr.strip() else note logger.warning("Hook %r entered stopped state and was terminated", hook.id) return result + except asyncio.CancelledError: + _take_output(result, stdout, stderr) + raise except (FileNotFoundError, OSError, ValueError) as exc: result.exit_code = None result.stderr = str(exc) finally: if invocation is not None: - result.decision, result.result_file = _read_result_file_with_status(invocation.result_path) + result.decision, result.result_file = _read_result_file(invocation.result_path) _cleanup_invocation_files(invocation) result.duration_ms = int((datetime.now(UTC) - start).total_seconds() * 1000) return result @@ -458,13 +470,8 @@ def _log_path_for(self, hook: HookConfig, payload: dict[str, Any]) -> Path: # --------------------------------------------------------------------------- -def _read_result_file(path: Path) -> dict[str, Any]: - """Parse ``CHRYS_HOOK_RESULT``; return ``{}`` on absent/empty/invalid.""" - return _read_result_file_with_status(path)[0] - - -def _read_result_file_with_status(path: Path) -> tuple[dict[str, Any], ResultFileStatus]: - """Parse ``CHRYS_HOOK_RESULT``, and say whether it was read, absent or empty, or invalid.""" +def _read_result_file(path: Path) -> tuple[dict[str, Any], HookResultFileStatus]: + """Parse ``CHRYS_HOOK_RESULT`` (``{}`` when absent, empty or invalid), and say which it was.""" try: with path.open("rb") as result_file: raw = result_file.read(HOOK_RESULT_FILE_MAX_BYTES + 1) diff --git a/tests/app/tui/screens/main/test_agent_load_flow.py b/tests/app/tui/screens/main/test_agent_load_flow.py index cbe7fa952..4382b55d2 100644 --- a/tests/app/tui/screens/main/test_agent_load_flow.py +++ b/tests/app/tui/screens/main/test_agent_load_flow.py @@ -648,6 +648,9 @@ def flash(self, message: str, **_kwargs: object) -> None: panel = _FakePanel() input_bar = _FakeInputBar() status = _FakeStatusBar() + # The Hooks tab is told when the number of configured hooks changes. + hook_runs: list[None] = [] + sidebar = SimpleNamespace(hooks_panel=SimpleNamespace(runs_changed=lambda: hook_runs.append(None))) def _query_one(cls): if cls.__name__ == "ChatPanel": @@ -656,6 +659,8 @@ def _query_one(cls): return input_bar if cls.__name__ == "StatusBar": return status + if cls.__name__ == "SidebarPanel": + return sidebar raise AssertionError(f"unexpected query_one({cls.__name__})") state = main_screen_state_at("/repo/current") @@ -694,6 +699,7 @@ def _query_one(cls): assert status_clears == [None] assert finish_messages == ["Profile switched: QA Agent -> Code Agent"] assert [status_trail(trail) for trail in tool_trails] == ["1 hook"] + assert hook_runs == [None] assert welcome_updates == [("Code Agent", "/repo/current")] assert len(screen._gc_messages) == 1 assert isinstance(screen._gc_messages[0], GcAbsorbRequested) @@ -736,9 +742,15 @@ def clear_status(self) -> None: def flash(self, _message: str, **_kwargs: object) -> None: return + # The Hooks tab is told when the number of configured hooks changes. + hook_runs: list[None] = [] + sidebar = SimpleNamespace(hooks_panel=SimpleNamespace(runs_changed=lambda: hook_runs.append(None))) + def query_one(cls: type): if cls.__name__ == "StatusBar": return _FakeStatusBar() + if cls.__name__ == "SidebarPanel": + return sidebar raise AssertionError(f"unexpected query_one({cls.__name__})") services = MainScreenServices(bus=EventBus(), active_model_profile_id="old-model") @@ -773,3 +785,4 @@ def query_one(cls: type): assert services.active_model_profile_id == expected assert [status_trail(trail) for trail in tool_trails] == ["1 hook"] + assert hook_runs == [None] diff --git a/tests/app/tui/screens/main/test_hooks_panel_flow.py b/tests/app/tui/screens/main/test_hooks_panel_flow.py index 4eae927bf..9558868a3 100644 --- a/tests/app/tui/screens/main/test_hooks_panel_flow.py +++ b/tests/app/tui/screens/main/test_hooks_panel_flow.py @@ -38,7 +38,9 @@ from chrys.foundation.events.bus import EventBus from chrys.foundation.events.types import ( AgentRuntimeDetails, + AgentRuntimeUpdated, HookRunsChanged, + ProfileSwitched, RuntimeHookDetails, RuntimeHookSourceDetails, SessionReady, @@ -137,6 +139,12 @@ async def _show_hooks_tab(pilot: Pilot, main: MainScreen) -> HookRunList: sidebar.focus_tab(SIDEBAR_HOOKS_TAB) hooks = sidebar.hooks_panel await wait_for(lambda: hooks.is_on_screen and hooks.size.width > 0, pilot=pilot, description="Hooks tab shown") + # Showing the tab scrolls the tab strip to it on a timer, which lays the screen out once more: + # a spy armed before it stops would count that. + strip = sidebar.query_one("#tabs-scroll") + await wait_for( + lambda: not main.app.animator.is_being_animated(strip, "scroll_x"), pilot=pilot, description="tab strip still" + ) return hooks @@ -150,6 +158,11 @@ def _rows(main: MainScreen) -> list[str]: return [re.sub(r"^\s*\d+ ", "", line) for line in lines] +def _is_running(row: str) -> bool: + """A running row starts with a spinner frame or its resting glyph, all Braille cells.""" + return 0x2800 <= ord(row[0]) < 0x2900 + + def _empty(main: MainScreen) -> str: empty = main.query_one("#hook-runs-empty", Static) return str(empty.render()) if empty.display else "" @@ -202,7 +215,7 @@ async def test_real_runs_appear_running_then_ended(tmp_path: Path) -> None: fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload())) try: await wait_for( - lambda: bool(_rows(main)) and _rows(main)[0].endswith("running"), + lambda: bool(_rows(main)) and _is_running(_rows(main)[0]), timeout=ENGINE_TURN_TIMEOUT, pilot=pilot, description="the running row", @@ -234,7 +247,7 @@ async def test_a_cancelled_run_shows_cancelled_without_waiting_for_idle(tmp_path fire = asyncio.create_task(manager.fire(HookEvent.BEFORE_TOOL_CALL, _payload())) try: await wait_for( - lambda: bool(_rows(main)) and _rows(main)[0].endswith("running"), + lambda: bool(_rows(main)) and _is_running(_rows(main)[0]), timeout=ENGINE_TURN_TIMEOUT, pilot=pilot, description="running", @@ -244,7 +257,7 @@ async def test_a_cancelled_run_shows_cancelled_without_waiting_for_idle(tmp_path with contextlib.suppress(asyncio.CancelledError): await fire await wait_for( - lambda: bool(_rows(main)) and _rows(main)[0].endswith("cancelled"), + lambda: bool(_rows(main)) and re.search(r" cancelled +\d+m?s$", _rows(main)[0]) is not None, pilot=pilot, description="shown cancelled", ) @@ -316,6 +329,28 @@ async def test_no_configured_hook_shows_no_hooks_configured(tmp_path: Path) -> N await wait_for(lambda: _empty(main) == "No hooks configured", pilot=pilot, description="unconfigured") +def _runtime_changed(kind: str, details: AgentRuntimeDetails) -> AgentRuntimeUpdated | ProfileSwitched: + if kind == "runtime-updated": + return AgentRuntimeUpdated(session_id=_SESSION, runtime_details=details) + # A settings reload: the same profile, rebuilt. + return ProfileSwitched(session_id=_SESSION, from_profile="Code", to_profile="Code", runtime_details=details) + + +@pytest.mark.parametrize("kind", ["runtime-updated", "profile-reloaded"]) +async def test_the_empty_state_follows_the_hooks_the_runtime_details_name(tmp_path: Path, kind: str) -> None: + bus = EventBus() + app = _make_app(tmp_path, bus, HookRunLog(), configured=0) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _show_hooks_tab(pilot, main) + await wait_for(lambda: _empty(main) == "No hooks configured", pilot=pilot, description="unconfigured") + # A hook was configured since, and nothing has run. + await bus.publish(_runtime_changed(kind, _runtime(1)), raise_handler_errors=True) + await wait_for( + lambda: _empty(main) == "No hook has run since iCode started", pilot=pilot, description="configured" + ) + + # --- Entry and workflow mode (section 2) ---------------------------------------- @@ -340,6 +375,29 @@ async def test_clicking_the_indicator_opens_a_closed_sidebar_on_the_hooks_tab(tm ) +async def test_clicking_the_idle_indicator_while_the_sidebar_is_held_closed_shows_the_configured_hooks( + tmp_path: Path, +) -> None: + from chrys.app.tui.screens.dialogs.runtime_details import RUNTIME_DETAILS_HOOKS_TAB, RuntimeDetailsDialog + + bus = EventBus() + app = _make_app(tmp_path, bus, HookRunLog()) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + sidebar = main.query_one(SidebarPanel) + # As the theme editor holds it while it is open in a narrow terminal: no toggle shows it. + sidebar.suppress_visibility("theme-editor", True) + await wait_for(lambda: not sidebar.is_visible, pilot=pilot, description="held closed") + main.post_message(HookIndicator.Clicked("")) + await wait_for( + lambda: isinstance(app.screen, RuntimeDetailsDialog) and app.screen.is_mounted, + pilot=pilot, + description="the configured hooks", + ) + assert app.screen.query_one("#runtime-details-tabs", TabbedContent).active == RUNTIME_DETAILS_HOOKS_TAB + assert not sidebar.is_visible + + async def test_clicking_the_indicator_while_it_shows_a_run_opens_that_runs_details(tmp_path: Path) -> None: release = tmp_path / "release" manager = _manager( @@ -536,12 +594,12 @@ async def test_a_hidden_tab_neither_reads_nor_renders_on_a_change(tmp_path: Path # --- Under a modal (section 6) -------------------------------------------------- -_ELAPSED_RUNNING = re.compile(r"(\d+)s\s+running$") +_ELAPSED = re.compile(r" (\d+)s$") def _running_seconds(main: MainScreen) -> int | None: rows = _rows(main) - match = _ELAPSED_RUNNING.search(rows[0]) if rows else None + match = _ELAPSED.search(rows[0]) if rows and _is_running(rows[0]) else None return int(match.group(1)) if match else None @@ -621,6 +679,42 @@ async def test_a_change_under_a_modal_is_read_once_it_closes(tmp_path: Path) -> assert log.reads == reads + 1 +async def test_an_empty_list_on_a_long_transcript_does_not_relayout_when_a_modal_closes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + app = _make_app(tmp_path, EventBus(), _FixedLog(())) + async with app.run_test(size=(160, 40)) as pilot: + main = await _ready(pilot) + await _populate(pilot, main) + hooks = await _show_hooks_tab(pilot, main) + await wait_for(lambda: _empty(main) == "No hook has run since iCode started", pilot=pilot, description="empty") + await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="settled underlay") + seen: dict[str, list[object]] = {"styles": [], "layout": [], "footer": []} + monkeypatch.setattr(main, "update_node_styles", lambda animate=True: seen["styles"].append(animate)) + monkeypatch.setattr(main, "_refresh_layout", lambda *_a, **_k: seen["layout"].append(None)) + + async def footer_recompose() -> None: + seen["footer"].append(None) + + monkeypatch.setattr(main.query_one(ChrysFooter), "recompose", footer_recompose) + paints: list[None] = [] + paint = hooks._paint + + def counted_paint() -> None: + paints.append(None) + paint() + + monkeypatch.setattr(hooks, "_paint", counted_paint) + # Every close resumes MainScreen, which paints the shown list again. + for closes in (1, 2): + await _open_modal(pilot, app, main) + await _close_modal(pilot, app, main) + await wait_for(lambda closes=closes: len(paints) >= closes, pilot=pilot, description="painted on resume") + await wait_for(lambda: screen_is_settled(app, main), pilot=pilot, description="settled after") + assert _empty(main) == "No hook has run since iCode started" + assert seen == {"styles": [], "layout": [], "footer": []} + + # --- Where the list stands ------------------------------------------------------ diff --git a/tests/app/tui/screens/main/test_session_ready_events.py b/tests/app/tui/screens/main/test_session_ready_events.py index 01c48b8ae..390dea38b 100644 --- a/tests/app/tui/screens/main/test_session_ready_events.py +++ b/tests/app/tui/screens/main/test_session_ready_events.py @@ -84,6 +84,9 @@ def flash(self, text: str, **_kwargs) -> None: panel = _FakePanel() input_bar = _FakeInputBar() status = _FakeStatusBar() + # The Hooks tab is told when the number of configured hooks changes. + hook_runs: list[None] = [] + sidebar = SimpleNamespace(hooks_panel=SimpleNamespace(runs_changed=lambda: hook_runs.append(None))) def _query_one(cls): if cls.__name__ == "ChatPanel": @@ -92,6 +95,8 @@ def _query_one(cls): return input_bar if cls.__name__ == "StatusBar": return status + if cls.__name__ == "SidebarPanel": + return sidebar raise AssertionError(f"unexpected query_one({cls.__name__})") services = MainScreenServices(bus=EventBus(), active_model_profile_id="old-model") @@ -129,6 +134,7 @@ def _query_one(cls): assert "tooltip" not in tool_info assert flash_calls == [] assert services.active_model_profile_id == "ready-model" + assert hook_runs == [None] def _run_existing_session_ready( @@ -289,9 +295,15 @@ class _FakeStatusBar: def set_tool_info(self, trail: str) -> None: tool_info["trail"] = trail + # The Hooks tab is told when the number of configured hooks changes. + hook_runs: list[None] = [] + sidebar = SimpleNamespace(hooks_panel=SimpleNamespace(runs_changed=lambda: hook_runs.append(None))) + def _query_one(cls): if cls.__name__ == "StatusBar": return _FakeStatusBar() + if cls.__name__ == "SidebarPanel": + return sidebar raise AssertionError(f"unexpected query_one({cls.__name__})") screen = SimpleNamespace( @@ -315,6 +327,7 @@ def _query_one(cls): assert handler._state.runtime.details is runtime_details trail = status_trail(tool_info["trail"]) assert trail == "2 tools · 1 skill · 1 hook · 1 file" + assert hook_runs == [None], "the disabled hook is not counted, the enabled one is" assert "DeepSeek-V4-Flash" not in trail assert "deepseek-v4-flash" not in trail assert "1m" not in trail diff --git a/tests/app/tui/screens/test_background_task_modal.py b/tests/app/tui/screens/test_background_task_modal.py index b677d1d65..69641db8e 100644 --- a/tests/app/tui/screens/test_background_task_modal.py +++ b/tests/app/tui/screens/test_background_task_modal.py @@ -89,6 +89,36 @@ def test_log_tail_skips_ahead_from_a_whole_line(tmp_path: Path, monkeypatch: pyt assert tail.read() == ("fourth\n", False) +def test_log_tail_skips_ahead_from_a_line_a_carriage_return_ended( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(background_task, "_TAIL_BYTES", 10) + log = tmp_path / "x.log" + # Progress a hook rewrites in place, as a detached hook's log keeps it. + log.write_bytes(b"10%\r20%\r30%\r40%\r50%\r") + tail = LogTail(str(log)) + # The last 10 bytes start inside "30%": the rest is kept as written, but for the CR + # it ends on, which waits for the LF that may follow it. + assert tail.read_raw() == ("40%\r50%", True) + + +def test_log_tail_joins_a_crlf_split_between_reads(tmp_path: Path) -> None: + log = tmp_path / "x.log" + log.write_bytes(b"first\r") + tail = LogTail(str(log)) + assert tail.read_raw() == ("first", False) + with log.open("ab") as file: + file.write(b"\nsecond\r\n") + assert tail.read_raw() == ("\r\nsecond\r\n", False) + # A lone CR still ends its line once more is read. + with log.open("ab") as file: + file.write(b"50%\r") + assert tail.read() == ("50%", False) + with log.open("ab") as file: + file.write(b"60%") + assert tail.read() == ("\n60%", False) + + def test_log_tail_joins_a_character_split_between_reads(tmp_path: Path) -> None: log = tmp_path / "x.log" tail = LogTail(str(log)) diff --git a/tests/app/tui/screens/test_hook_run_modal.py b/tests/app/tui/screens/test_hook_run_modal.py index 64577a31d..981d37092 100644 --- a/tests/app/tui/screens/test_hook_run_modal.py +++ b/tests/app/tui/screens/test_hook_run_modal.py @@ -5,6 +5,7 @@ from __future__ import annotations import dataclasses +import inspect import json import time from pathlib import Path @@ -16,6 +17,7 @@ from textual.widgets import Button, Log, Static, TabbedContent from chrys.app.tui.screens.dialogs.hook_run import HookRunModal, _CopySection +from chrys.app.tui.widgets.chat.tool_call import fmt_duration from chrys.foundation.models.hook_runs import HookRunApplied, HookRunDetail, HookRunIO, HookRunRecord from chrys.service.trajectory.hooks import HookOutcome from tests.support.tui_helpers import click_when_settled, rich_plain @@ -88,12 +90,24 @@ def _detail(record: HookRunRecord | None = None, **fields: object) -> HookRunDet return HookRunDetail(**base) # type: ignore[arg-type] +class _CountedModal(HookRunModal): + """Counts the ticks of its poll timer: they stop only once the timer is parked.""" + + # A relative CSS_PATH resolves beside the class that names it: this module's directory. + CSS_PATH = Path(inspect.getfile(HookRunModal)).with_name(str(HookRunModal.CSS_PATH)) + polls = 0 + + async def _poll(self) -> None: + self.polls += 1 + await super()._poll() + + class _ModalApp(App): def __init__(self, detail: HookRunDetail) -> None: super().__init__() self.current: HookRunDetail | None = detail self.asked: list[str] = [] - self.modal = HookRunModal(detail, latest=self._latest) + self.modal = _CountedModal(detail, latest=self._latest) def _latest(self, run_id: str) -> HookRunDetail | None: self.asked.append(run_id) @@ -225,6 +239,23 @@ async def test_the_input_tab_names_a_sub_agent_and_a_retry_and_hides_start_and_q assert "Started" not in text, "still queued: no start time" +async def test_the_queue_time_of_a_run_still_queued_counts_up() -> None: + record = _record(registered_at=time.monotonic() - 2.0, started_at=None, state="queued") + record = dataclasses.replace(record, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + + def queued() -> list[list[str]]: + lines = _pane_text(modal, "hook-run-input").splitlines() + return [line.split() for line in lines if line.startswith("Queued")] + + opened = queued() + assert len(opened) == 1, opened + await wait_for(lambda: queued() != opened, pilot=pilot, description="the queue time counted up") + assert queued()[0][0] == "Queued" + + async def test_a_run_that_never_queued_shows_no_queue_time() -> None: record = _record() app = _ModalApp(_detail(record)) @@ -235,6 +266,34 @@ async def test_a_run_that_never_queued_shows_no_queue_time() -> None: assert "Queued" not in text +async def test_a_detached_run_shows_when_it_was_handed_off_and_no_queue_time() -> None: + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + text = _pane_text(modal, "hook-run-input") + assert "Started" in text + assert "Queued" not in text + + +async def test_a_run_that_ended_before_it_took_a_slot_shows_how_long_it_queued() -> None: + registered = time.monotonic() - 5.0 + record = _record( + registered_at=registered, + started_at=None, + ended_at=registered + 2.5, + outcome=HookOutcome.CANCELLED, + exit_code=None, + ) + app = _ModalApp(_detail(record)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + lines = _pane_text(modal, "hook-run-input").splitlines() + # Until it ended, not until now, and not nothing. + assert [line.split() for line in lines if line.startswith("Queued")] == [["Queued", fmt_duration(2500)]], lines + assert not any(line.startswith("Started") for line in lines), "it never started" + + async def test_a_cut_payload_says_so() -> None: io = _io(payload=_PAYLOAD[:40], payload_size=200_000) app = _ModalApp(_detail(io=io)) @@ -324,6 +383,56 @@ async def test_applied_names_what_was_taken_or_why_not(applied: HookRunApplied, assert expected in _pane_text(modal, "hook-run-output") +def _section_texts(modal: HookRunModal, pane: str, title: str) -> list[str]: + """What each widget of the section titled *title* shows, in order.""" + (section,) = [section for section in _sections(modal, pane) if _plain(section.border_title) == title] + return [_plain(widget.content) for widget in section.query(Static)] + + +_GONE = "Details are no longer kept for this run" + + +@pytest.mark.parametrize( + ("effect", "label"), + [ + ("modified_args", "Changed the tool arguments to:"), + ("added_reminder", "Added a reminder for the model:"), + ("added_context", "Added to the tool result:"), + ], + ids=["args", "reminder", "context"], +) +async def test_applied_text_whose_details_are_gone_says_so_instead_of_showing_an_empty_block( + effect: str, label: str +) -> None: + # As the run log trims a run: its texts go with the rest of its details. + record = _record(effect=effect) + app = _ModalApp(_detail(record, io=None, applied=HookRunApplied())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + assert _section_texts(modal, "hook-run-output", "Applied") == [label, _GONE] + + +@pytest.mark.parametrize( + ("args", "args_size", "shown"), + [ + ( + '{"path": "/safe", "content": "aaaa', + 200_000, + ["Arguments cut at 64 KB of 195.3 KB", '{"path": "/safe", "content": "aaaa'], + ), + ('{"path": "/safe"}', 0, ['{"path": "/safe"}']), + ], + ids=["cut", "whole"], +) +async def test_cut_arguments_say_how_much_was_kept_above_what_was(args: str, args_size: int, shown: list[str]) -> None: + record = _record(effect="modified_args") + applied = HookRunApplied(args=args, args_size=args_size) + app = _ModalApp(_detail(record, applied=applied, io=_io(result_file="ok", decision="{}"))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + assert _section_texts(modal, "hook-run-output", "Applied") == ["Changed the tool arguments to:", *shown] + + async def test_a_timed_out_run_shows_its_partial_output() -> None: record = _record(outcome=HookOutcome.TIMED_OUT, exit_code=None) app = _ModalApp(_detail(record, io=_io(stdout="half done"), applied=HookRunApplied(not_applied="failed"))) @@ -367,8 +476,56 @@ async def test_a_running_run_fills_its_output_once_it_ends_without_switching_tab ) assert "Running…" not in _pane_text(modal, "hook-run-output") assert _active(modal) == "hook-run-input", "the user may be reading the payload" - polled = len(app.asked) - assert not await wait_until(lambda: len(app.asked) > polled, timeout=2.5, pilot=pilot), "polling stops" + # Not only the reads: a timer left running would wake the dialog every second. + polled = app.modal.polls + assert not await wait_until(lambda: app.modal.polls > polled, timeout=2.5, pilot=pilot), "polling stops" + + +async def test_a_run_the_log_drops_while_its_dialog_polls_says_its_details_are_gone_and_stops_polling() -> None: + now = time.monotonic() + record = _record(state="running", started_at=now - 1.0, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + await wait_for(lambda: app.asked, timeout=3.0, pilot=pilot, description="latest() polled") + assert "running" in _subtitle(modal) + # It ended and was dropped past the log's cap between two reads. + app.current = None + await wait_for( + lambda: _GONE in _pane_text(modal, "hook-run-output"), + timeout=4.0, + pilot=pilot, + description="the output says the details are gone", + ) + assert "Running…" not in _pane_text(modal, "hook-run-output") + assert _subtitle(modal) == "", "how it ended is not known" + assert _active(modal) == "hook-run-input", "the tab stays where the user is" + # Not only the reads: a timer left running would wake the dialog every second. + polled = app.modal.polls + assert not await wait_until(lambda: app.modal.polls > polled, timeout=2.5, pilot=pilot), "polling stops" + + +async def test_a_payload_shown_before_the_log_dropped_the_runs_details_still_copies(copied: list[str]) -> None: + now = time.monotonic() + record = _record(state="running", started_at=now - 1.0, ended_at=None, outcome=None, exit_code=None) + app = _ModalApp(_detail(record, applied=None)) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _open(pilot, app) + await wait_for(lambda: app.asked, timeout=3.0, pilot=pilot, description="latest() polled") + # It ended, and the log dropped its details for newer runs' between two reads. + ended = dataclasses.replace(record, state="ended", ended_at=time.monotonic(), outcome=HookOutcome.SUCCESS) + app.current = _detail(ended, io=None, applied=None) + await wait_for( + lambda: _GONE in _pane_text(modal, "hook-run-output"), + timeout=4.0, + pilot=pilot, + description="the output says the details are gone", + ) + assert _active(modal) == "hook-run-input" + assert '"rm -rf build"' in _pane_text(modal, "hook-run-input") + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="input copied") + assert copied[0].endswith("\n\n## Payload\n" + json.dumps(json.loads(_PAYLOAD), indent=2)), copied[0][-300:] # --- Detached runs -------------------------------------------------------------- @@ -487,6 +644,31 @@ async def test_the_mouse_over_any_part_of_a_section_lights_the_whole_section() - ) +async def test_a_detached_log_another_run_shares_after_the_dialog_opened_says_so_and_keeps_its_lines( + tmp_path: Path, +) -> None: + log = tmp_path / "handed-off.20261021T093000Z.log" + log.write_bytes(b"first run\n") + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + detail = _detail(record, log_path=str(log), io=_io()) + app = _ModalApp(detail) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + view = modal.query_one("#hook-run-log", Log) + await wait_for(lambda: "first run" in view.lines, timeout=4.0, pilot=pilot, description="log tail") + assert "writes to this log too" not in _pane_text(modal, "hook-run-output") + # The same hook ran again in the same second: the run log marks this run's log shared. + app.current = dataclasses.replace(detail, shared_log=True) + await wait_for( + lambda: "writes to this log too" in _pane_text(modal, "hook-run-output"), + timeout=4.0, + pilot=pilot, + description="the shared log noted", + ) + assert modal.query_one("#hook-run-log", Log) is view + assert "first run" in view.lines + + async def test_a_detached_run_copies_its_log_path_and_its_log_apart(copied: list[str], tmp_path: Path) -> None: log = tmp_path / "x.log" log.write_text("worker started\n", encoding="utf-8") @@ -529,3 +711,196 @@ async def test_c_copies_the_whole_open_tab(copied: list[str]) -> None: fields, payload = copied[1].split("\n\n## Payload\n") assert fields.splitlines()[:2] == ["## Overview", "Event: before_tool_call"] assert json.loads(payload) == json.loads(_PAYLOAD) + + +# --- The run's own text: shown sanitized, copied as it is ----------------------- + + +async def test_control_characters_in_the_runs_paths_show_neutralized_and_copy_as_they_are(copied: list[str]) -> None: + detail = _detail( + source_path="/config/hooks\x1b[31m/hooks.yaml", + run_cwd="/repo\x1b]0;owned\x07", + log_path="/logs/guard\x1b[2J.log", + ) + app = _ModalApp(detail) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + output = _pane_text(modal, "hook-run-output") + assert "/logs/guard" in output and "[2J.log" in output, output + assert "\x1b" not in output, output + modal.query_one(TabbedContent).active = "hook-run-input" + await wait_for(lambda: _active(modal) == "hook-run-input", pilot=pilot, description="input tab") + shown = _pane_text(modal, "hook-run-input") + assert "[31m/hooks.yaml" in shown and "]0;owned" in shown, shown + assert "\x1b" not in shown and "\x07" not in shown, shown + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="input copied") + assert "/config/hooks\x1b[31m/hooks.yaml" in copied[0] and "cwd: /repo\x1b]0;owned\x07" in copied[0] + + +async def test_a_multi_line_shell_hook_shows_and_copies_its_script_line_by_line(copied: list[str]) -> None: + script = "set -e\n\techo one\necho two" + app = _ModalApp(_detail(run_type="shell", run_argv=(script,))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + modal.query_one(TabbedContent).active = "hook-run-input" + await wait_for(lambda: _active(modal) == "hook-run-input", pilot=pilot, description="input tab") + fields = rich_plain(modal.query_one("#hook-run-input-fields", Static).content) + assert "�" not in fields, fields + lines = fields.splitlines() + (run,) = [index for index, line in enumerate(lines) if line.endswith("shell: set -e")] + column = lines[run].index("shell:") + # Each line of the script under the first, its tab an indent. + assert lines[run + 1 : run + 3] == [" " * column + " echo one", " " * column + "echo two"], lines + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="input copied") + # The script as it is, each line under its first as the copied fields put them. + indent = " " * len("Run: ") + assert f"Run: shell: set -e\n{indent}\techo one\n{indent}echo two\n" in copied[0], copied[0] + + +async def test_a_multi_line_block_reason_shows_line_by_line_and_copies_as_it_is(copied: list[str]) -> None: + reason = "hook 'guard' failed (exit 1): denied\n\x1b[31mrm -rf\x1b[0m is not allowed" + record = _record(effect="blocked") + app = _ModalApp( + _detail(record, io=_io(result_file="ok", decision="{}"), applied=HookRunApplied(block_reason=reason)) + ) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + label, shown = _section_texts(modal, "hook-run-output", "Applied") + assert label == "Blocked the action:" + first, second = shown.splitlines() + assert first == "hook 'guard' failed (exit 1): denied" + assert "\x1b" not in second and "rm -rf" in second and second.endswith(" is not allowed"), second + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="output copied") + assert f"Blocked the action:\n{reason}" in copied[0], copied[0] + + +async def test_a_payload_path_that_is_not_utf8_shows_without_a_lone_surrogate() -> None: + # A POSIX path with a byte that is not UTF-8, as JSON writes it: no terminal can be sent it unescaped. + payload = json.dumps({"tool": {"name": "read_file", "args": {"path": "/tmp/caf\udce9"}}}) + app = _ModalApp(_detail(io=_io(payload=payload, payload_size=len(payload)))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + modal.query_one(TabbedContent).active = "hook-run-input" + await wait_for(lambda: _active(modal) == "hook-run-input", pilot=pilot, description="input tab") + shown = _pane_text(modal, "hook-run-input") + assert "/tmp/caf" in shown, shown + assert not any(0xD800 <= ord(char) <= 0xDFFF for char in shown), shown + + +async def test_a_payload_that_indenting_would_swell_shows_compact(copied: list[str]) -> None: + # Indenting adds the depth to every line: 200 levels would grow from some 400 characters to 80,000. + payload = json.dumps({"tool": {"name": "write", "args": {"x": json.loads("[" * 200 + "]" * 200)}}}) + app = _ModalApp(_detail(io=_io(payload=payload, payload_size=len(payload)))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + modal.query_one(TabbedContent).active = "hook-run-input" + await wait_for(lambda: _active(modal) == "hook-run-input", pilot=pilot, description="input tab") + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="input copied") + assert copied[0].endswith("\n\n## Payload\n" + payload), copied[0][-200:] + + +@pytest.mark.parametrize( + ("stdout", "first", "last"), + [ + ("".join(f"line {number}\n" for number in range(3_000)), "line 0\n", "line 2999\n"), + ("a" * 40_000 + "z" * 40_000, "a", "z"), + ], + ids=["many-lines", "one-long-line"], +) +async def test_a_long_block_shows_its_ends_and_copies_whole( + copied: list[str], stdout: str, first: str, last: str +) -> None: + """Laid out whole, 256 KB of short lines held the dialog for seconds before it showed.""" + app = _ModalApp(_detail(io=_io(stdout=stdout))) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + head, note, tail = _section_texts(modal, "hook-run-output", "stdout") + assert head.startswith(first) and tail.endswith(last) + assert stdout.startswith(head) and stdout.endswith(tail) + assert max(len(head), len(tail)) <= 32 * 1024 + assert max(len(head.splitlines()), len(tail.splitlines())) <= 500 + left_out = len(stdout) - len(head) - len(tail) + assert left_out > 0 + assert note == f"{left_out} characters from the middle are not shown; a copy includes them" + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="output copied") + assert f"## stdout\n{stdout}\n\n## stderr" in copied[0] + + +async def test_a_detached_log_shows_sanitized_and_copies_as_it_was_written(tmp_path: Path, copied: list[str]) -> None: + log = tmp_path / "handed-off.20261021T093000Z.log" + written = "col1\tcol2\n\x1b[31mred\x1b[0m\n" + # Bytes, as a hook writes them: text mode would write CRLF on Windows. + log.write_bytes(written.encode()) + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, log_path=str(log), io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + await wait_for( + lambda: "red" in "\n".join(modal.query_one("#hook-run-log", Log).lines), + timeout=4.0, + pilot=pilot, + description="log tail", + ) + shown = "\n".join(modal.query_one("#hook-run-log", Log).lines) + assert "\t" not in shown and "\x1b" not in shown, shown + (section,) = [ + section for section in _sections(modal, "hook-run-output") if _plain(section.border_title) == "Log" + ] + await click_when_settled(pilot, section.children[-1]) + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="log copied") + assert copied[0] == written.rstrip("\n") + + +async def test_a_detached_log_line_too_long_to_show_ends_in_a_mark_and_copies_whole( + tmp_path: Path, copied: list[str] +) -> None: + """Measured again on every write, a line growing into megabytes held the dialog longer at each read.""" + log = tmp_path / "handed-off.20261021T093000Z.log" + written = "x" * 100_000 + "\nnext\n" + log.write_bytes(written.encode()) + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, log_path=str(log), io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + widget = modal.query_one("#hook-run-log", Log) + await wait_for(lambda: "next" in widget.lines, timeout=4.0, pilot=pilot, description="log tail") + assert widget.lines[:2] == ["x" * 64 * 1024 + "…", "next"] + await pilot.press("c") + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="output copied") + assert "\n## Log\n" + written.rstrip("\n") in copied[0] + + +# Five lines kept: the copy holds the lines the widget shows, however the log ends them. +@pytest.mark.parametrize( + ("written", "kept"), + [ + ("".join(f"{percent}%\r" for percent in range(12)) + "done\n", "9%\r10%\r11%\rdone"), + ("a\nb\nc\nd\ne\nf\n", "c\nd\ne\nf"), + ("a\nb\n\n\n", "a\nb\n\n"), + ], + ids=["carriage-returns", "newlines", "blank-lines"], +) +async def test_a_detached_logs_copy_keeps_the_lines_its_view_keeps( + tmp_path: Path, copied: list[str], monkeypatch: pytest.MonkeyPatch, written: str, kept: str +) -> None: + monkeypatch.setattr("chrys.app.tui.screens.dialogs.hook_run._MAX_LOG_LINES", 5) + log = tmp_path / "progress.20261021T093000Z.log" + log.write_bytes(written.encode()) + record = _record(detach=True, outcome=HookOutcome.DETACHED, exit_code=None, started_at=None) + app = _ModalApp(_detail(record, log_path=str(log), io=_io())) + async with app.run_test(size=(140, 50)) as pilot: + modal = await _ended_with_output(pilot) + view = modal.query_one("#hook-run-log", Log) + await wait_for(lambda: len(view.lines) == 5, timeout=4.0, pilot=pilot, description="log tail") + (section,) = [ + section for section in _sections(modal, "hook-run-output") if _plain(section.border_title) == "Log" + ] + await click_when_settled(pilot, section.children[-1]) + await wait_for(lambda: len(copied) == 1, pilot=pilot, description="log copied") + assert copied[0] == kept + assert [*kept.replace("\r", "\n").split("\n"), ""] == list(view.lines) diff --git a/tests/app/tui/widgets/test_hook_run_list.py b/tests/app/tui/widgets/test_hook_run_list.py index 2c7cd5b85..f0c7d6178 100644 --- a/tests/app/tui/widgets/test_hook_run_list.py +++ b/tests/app/tui/widgets/test_hook_run_list.py @@ -138,7 +138,7 @@ async def _shown(pilot, count: int) -> list[str]: return _rows(pilot.app) -# --- Each row: icon, label, duration, outcome ----------------------------------- +# --- Each row: icon, label, outcome, duration ----------------------------------- @pytest.mark.parametrize( @@ -187,20 +187,56 @@ async def test_a_rows_icon_and_outcome_follow_how_it_ended_and_what_was_applied( async with app.run_test(size=(60, 20)) as pilot: (row,) = await _shown(pilot, 1) assert row.startswith(f"{icon} check · zsh"), row - if outcome: - assert row.endswith(outcome), row - else: + if not outcome: # Nothing applied: only the duration follows the label. assert row.split("zsh", 1)[1].split() == ["40ms"], row + elif fields.get("ran_for", 0.0) is None: + # Handed off, it has no duration of its own: the outcome ends the row. + assert row.endswith(outcome), row + else: + # The outcome, then the duration, which ends the row. + assert re.search(rf"{re.escape(outcome)} +\d+m?s$", row), row -async def test_a_running_row_shows_a_spinner_and_running() -> None: +async def test_a_running_row_shows_a_spinner_and_its_duration_alone() -> None: app = _ListApp(_Source(_record("r1", hook_id="slow", state="running", ran_for=6.0))) async with app.run_test(size=(60, 20)) as pilot: (row,) = await _shown(pilot, 1) assert ord(row[0]) in _SPINNER, row - assert row.endswith("running") - assert "slow · zsh" in row + # Its duration ticks in the running color, which says it runs. + assert re.fullmatch(r"\d+s", row.split("slow · zsh", 1)[1].strip()), row + + +async def test_every_rows_duration_ends_at_the_right_edge_whatever_the_others_show() -> None: + records = ( + _record("a", hook_id="quick", number=1), + _record("b", hook_id="gate", outcome=HookOutcome.FAILED, exit_code=1, effect="blocked", number=2), + _record("c", hook_id="slow", state="running", ran_for=6.0, number=3), + ) + app = _ListApp(_Source(*records)) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 3) + rows = pilot.app.query_one("#hook-runs-rows", Static) + width = rows.size.width + lines = [strip.text for strip in rows.render_lines(Region(0, 0, width, 3))] + assert [len(line.rstrip()) for line in lines] == [width] * 3, lines + assert lines[0].endswith(" 40ms") and lines[1].endswith("exit 1 · blocked 40ms"), lines + assert re.search(r" \d+s$", lines[2]), lines + + +async def test_the_scrollbar_sits_on_the_panels_edge_and_the_rows_end_with_the_title() -> None: + app = _ListApp(_Source(*_ended(40))) + async with app.run_test(size=(60, 20)) as pilot: + await wait_for(lambda: len(_rows(pilot.app)) > 5, pilot=pilot, description="rows") + hooks = pilot.app.query_one(HookRunList) + box = pilot.app.query_one("#hook-runs-box", VerticalScroll) + assert box.show_vertical_scrollbar, "40 rows overflow the box" + assert box.region.right == hooks.region.right, "no padding outside the bar" + rows = pilot.app.query_one("#hook-runs-rows", Static).region + # The bar's column, reserved with or without the bar, keeps the rows off the edge. + assert rows.right == hooks.region.right - 1 + clear = pilot.app.query_one("#hook-runs-clear", Static).region + assert clear.right == rows.right, "the title's link ends where the rows do" def test_the_label_names_the_tool_or_the_event_and_the_sub_agent() -> None: @@ -290,14 +326,14 @@ async def test_a_long_result_gives_way_to_the_name_past_a_third_of_the_row() -> async with app.run_test(size=(42, 10)) as pilot: (row,) = await _shown(pilot, 1) width = pilot.app.query_one("#hook-runs-rows", Static).size.width - outcome = row[row.index("40ms") + len("40ms") :].strip() + outcome = row[row.index("timed out") : row.index("40ms")].strip() assert outcome.endswith("…") and len(outcome) <= width // 3, row - name = row[2 : row.index("40ms")].strip() + name = row[2 : row.index("timed out")].strip() assert len(name) > len(outcome), row # Wider, the whole result shows: the cap follows the width. await resize_when_settled(pilot, 90, 10) await wait_for( - lambda: _rows(pilot.app)[0].endswith("timed out · blocked"), pilot=pilot, description="whole result" + lambda: _rows(pilot.app)[0].endswith("timed out · blocked 40ms"), pilot=pilot, description="whole result" ) @@ -324,6 +360,31 @@ async def test_the_list_opens_on_the_newest_run_and_follows_new_ones_while_at_th ) +async def test_a_list_at_the_bottom_stays_there_when_the_notice_shows_above_its_rows() -> None: + source = _Source(*_ended(30)) + app = _ListApp(source) + async with app.run_test(size=(60, 12)) as pilot: + await _shown(pilot, 30) + box = pilot.app.query_one("#hook-runs-box", VerticalScroll) + await wait_for(lambda: box.scroll_y == box.max_scroll_y > 0, pilot=pilot, description="at the newest") + + # A run that ends at once (a detached one) drops the oldest: as many rows, and the notice. + source.records = _ended(30, start=2) + source.dropped = 1 + pilot.app.query_one(HookRunList).runs_changed() + await wait_for(lambda: _unlisted(pilot.app) == _ONLY_LATEST, pilot=pilot, description="notice") + await wait_for(lambda: box.scroll_y == box.max_scroll_y, pilot=pilot, description="still at the newest") + + source.records = _ended(32, start=2) + pilot.app.query_one(HookRunList).runs_changed() + await _shown(pilot, 32) + await wait_for( + lambda: box.scroll_y == box.max_scroll_y and _numbered_rows(pilot.app)[-1].lstrip().startswith("33 "), + pilot=pilot, + description="still following the new runs", + ) + + async def test_a_list_scrolled_up_to_an_older_run_stays_there_as_new_runs_join() -> None: source = _Source(*_ended(30)) app = _ListApp(source) @@ -456,21 +517,35 @@ async def test_clearing_ended_runs_never_shows_the_notice_and_numbers_carry_on() assert _unlisted(pilot.app) == "" -async def _scroll_when_selecting_the_20th_row(start: int, *, dropped: int = 0) -> float: +async def _scroll_when_selecting_the_20th_row(start: int, *, dropped: int = 0, width: int = 60) -> tuple[float, int]: + """Where the list scrolls to once the 20th row is selected, and how many lines the notice takes above the rows.""" app = _ListApp(_Source(*_ended(60, start=start), dropped=dropped)) - async with app.run_test(size=(60, 12)) as pilot: + async with app.run_test(size=(width, 12)) as pilot: await _shown(pilot, 60) - box = pilot.app.query_one("#hook-runs-box") + box = pilot.app.query_one("#hook-runs-box", VerticalScroll) await wait_for(lambda: box.scroll_y == box.max_scroll_y > 0, pilot=pilot, description="at the end") pilot.app.query_one(HookRunList).select(f"e{start + 19}") await wait_for(lambda: box.scroll_y < box.max_scroll_y, pilot=pilot, description="scrolled to it") - return box.scroll_y + notice = pilot.app.query_one("#hook-runs-unlisted", Static) + above = notice.outer_size.height if notice.display else 0 + # In view: the rows start right under the notice, one line each. + assert box.scroll_y <= above + 19 < box.scroll_y + box.scrollable_content_region.height, (box.scroll_y, above) + return box.scroll_y, above async def test_a_selected_row_scrolls_into_view_below_the_notice() -> None: - plain = await _scroll_when_selecting_the_20th_row(start=1) + plain, _ = await _scroll_when_selecting_the_20th_row(start=1) # Once the log dropped runs, the notice takes the first line: the same row sits one line lower. - assert await _scroll_when_selecting_the_20th_row(start=41, dropped=40) == plain + 1 + assert await _scroll_when_selecting_the_20th_row(start=41, dropped=40) == (plain + 1, 1) + + +async def test_a_selected_row_scrolls_into_view_below_a_notice_that_wraps() -> None: + # A narrow sidebar: the notice wraps. + plain, _ = await _scroll_when_selecting_the_20th_row(start=1, width=24) + scrolled, above = await _scroll_when_selecting_the_20th_row(start=41, dropped=40, width=24) + assert above >= 2 + # The row sits as far below where it sits without the notice as the notice is tall: as centered. + assert scrolled == plain + above async def test_clicking_a_row_opens_its_run() -> None: @@ -551,3 +626,67 @@ def counting(*args: object, **kwargs: object) -> None: def test_the_module_exposes_its_row_helpers() -> None: assert hooks_module.run_state(_record("q", state="queued")) == "queued" assert hooks_module.run_state(_record("r", state="running")) == "running" + + +def _row_backgrounds(app: App, index: int) -> set[object]: + rows = app.query_one("#hook-runs-rows", Static) + width, height = rows.size + lines = [strip for strip in rows.render_lines(Region(0, 0, width, height)) if strip.text.strip()] + return {segment.style.bgcolor for segment in lines[index] if segment.style is not None and segment.text.strip()} + + +async def test_a_selected_row_follows_a_theme_change_with_nothing_over_the_list() -> None: + app = _ListApp(_Source(_record("a", number=1), _record("b", number=2))) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 2) + hooks = pilot.app.query_one(HookRunList) + + def selected() -> object: + return hooks.get_component_rich_style("hook-runs--selected").bgcolor + + hooks.select("a") + await wait_for(lambda: _row_backgrounds(pilot.app, 0) == {selected()}, pilot=pilot, description="marked") + before = selected() + # Every run ended: no tick repaints the rows, and no overlay closes over them. + pilot.app.theme = "textual-dark" if pilot.app.theme == "textual-light" else "textual-light" + await wait_for(lambda: selected() != before, pilot=pilot, description="the theme's color resolved") + await wait_for( + lambda: _row_backgrounds(pilot.app, 0) == {selected()}, pilot=pilot, description="the row follows the theme" + ) + + +async def test_a_theme_change_while_the_list_is_hidden_paints_it_only_once_it_shows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = _ListApp(_Source(_record("a", number=1), _record("b", number=2))) + async with app.run_test(size=(60, 20)) as pilot: + await _shown(pilot, 2) + hooks = pilot.app.query_one(HookRunList) + + def selected() -> object: + return hooks.get_component_rich_style("hook-runs--selected").bgcolor + + hooks.select("a") + await wait_for(lambda: _row_backgrounds(pilot.app, 0) == {selected()}, pilot=pilot, description="marked") + rows = pilot.app.query_one("#hook-runs-rows", Static) + updates: list[object] = [] + original: Callable[..., None] = rows.update + + def counting(*args: object, **kwargs: object) -> None: + updates.append(args) + original(*args, **kwargs) + + monkeypatch.setattr(rows, "update", counting) + holder = pilot.app.query_one("#holder") + holder.display = False + await wait_for(lambda: not hooks.is_on_screen, pilot=pilot, description="hidden") + before = selected() + pilot.app.theme = "textual-dark" if pilot.app.theme == "textual-light" else "textual-light" + await wait_for(lambda: selected() != before, pilot=pilot, description="the theme's color resolved") + # A widget the screen cannot show, repainted, makes it lay the whole screen out again. + assert not await wait_until(lambda: bool(updates), timeout=0.5, pilot=pilot), "painted while hidden" + holder.display = True + await _shown(pilot, 2) + await wait_for( + lambda: _row_backgrounds(pilot.app, 0) == {selected()}, pilot=pilot, description="painted once shown" + ) diff --git a/tests/architecture/trajectory_wait_manifest.json b/tests/architecture/trajectory_wait_manifest.json index 8df363d37..7a67b977d 100644 --- a/tests/architecture/trajectory_wait_manifest.json +++ b/tests/architecture/trajectory_wait_manifest.json @@ -3984,38 +3984,42 @@ ["timeout", "await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=timeout)", "unresolved"], ["gather", "asyncio.gather(*tasks, return_exceptions=True)", "unresolved"], ["gather", "await asyncio.gather(*tasks, return_exceptions=True)", "unresolved"], - ["wrapper", "await self._notify_change(executions=dropped, runs=ended)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._report(changed)", "unresolved", "chrys.service.hooks.manager.HookManager._report"] ], "HookManager._notify_change": [ ["awaitable", "await self.on_change(self)", "unresolved"], ["awaitable", "await self._on_runs_change()", "unresolved"] ], + "HookManager._report": [ + ["wrapper", "await self._notify_change(executions=changed.executions, runs=changed.runs)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ], + "HookManager._report_end": [ + ["wrapper", "await self._report(changed)", "unresolved", "chrys.service.hooks.manager.HookManager._report"], + ["awaitable", "await trace.finished(outcome=outcome, arguments_modified=arguments_modified, exit_code=exit_code, timed_out=timed_out)", "unresolved"] + ], "HookManager._run_async_tracked": [ - ["wrapper", "await self._run_async_tracked_inner(hook, payload, job, trace, execution_key, run)", "unresolved", "chrys.service.hooks.manager.HookManager._run_async_tracked_inner"], - ["wrapper", "await self._notify_change(executions=removed, runs=ended)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._run_async_tracked_inner(hook, payload, job, trace, run)", "unresolved", "chrys.service.hooks.manager.HookManager._run_async_tracked_inner"], + ["wrapper", "await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED))", "unresolved", "chrys.service.hooks.manager.HookManager._report"] ], "HookManager._run_async_tracked_inner": [ ["async_context", "async with self._sem", "unresolved"], - ["wrapper", "await self._notify_change(executions=running, runs=self._start_run(run))", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text)", "unresolved"], - ["awaitable", "await trace.finished(outcome=HookOutcome.LAUNCH_ERROR)", "unresolved"], - ["awaitable", "await trace.finished(outcome=_hook_outcome(result, blocked=False), exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved"] + ["wrapper", "await self._report(self._take_slot(run))", "unresolved", "chrys.service.hooks.manager.HookManager._report"], + ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text, result=run.output)", "unresolved"], + ["wrapper", "await self._report_end(changed, trace, outcome=outcome, exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved", "chrys.service.hooks.manager.HookManager._report_end"] ], "HookManager._run_blocking": [ - ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["wrapper", "await self._report(self._queue_run(hook, run, waited_at=waited_at))", "unresolved", "chrys.service.hooks.manager.HookManager._report"], ["async_context", "async with self._sem", "unresolved"], - ["wrapper", "await self._notify_change(executions=running, runs=self._start_run(run))", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text)", "unresolved"], - ["wrapper", "await self._notify_change(executions=removed, runs=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] + ["wrapper", "await self._report(self._take_slot(run))", "unresolved", "chrys.service.hooks.manager.HookManager._report"], + ["awaitable", "await self._runner.run_and_wait(hook, payload, payload_text=run.payload_text, result=run.output)", "unresolved"] ], "HookManager._spawn_nonblocking": [ ["awaitable", "await trace.started(hook_id=hook.id, hook_event=str(hook.event), execution_mode=hook.execution.mode, detach=hook.execution.detach, delivery=hook.execution.delivery, scope=scope, drain_scope=drain_scope)", "unresolved"], ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], ["awaitable", "await self._runner.spawn_detached(hook, payload, job=job, payload_text=run.payload_text)", "unresolved"], - ["awaitable", "await trace.finished(outcome=HookOutcome.SPAWN_FAILED)", "unresolved"], - ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await trace.finished(outcome=HookOutcome.DETACHED)", "unresolved"], - ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["wrapper", "await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED))", "unresolved", "chrys.service.hooks.manager.HookManager._report"], + ["wrapper", "await self._report_end(changed, trace, outcome=HookOutcome.SPAWN_FAILED)", "unresolved", "chrys.service.hooks.manager.HookManager._report_end"], + ["wrapper", "await self._report_end(changed, trace, outcome=HookOutcome.DETACHED)", "unresolved", "chrys.service.hooks.manager.HookManager._report_end"], ["wrapper", "await self._notify_change()", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"] ], "HookManager.drain_session": [ @@ -4028,9 +4032,9 @@ "HookManager.fire": [ ["awaitable", "await trace.started(hook_id=hook.id, hook_event=str(event), execution_mode=hook.execution.mode, detach=hook.execution.detach, delivery=hook.execution.delivery, scope=scope, drain_scope=scope)", "unresolved"], ["wrapper", "await self._run_blocking(hook, envelope, waited_at='none' if scope == 'detached' else 'call', run=run)", "unresolved", "chrys.service.hooks.manager.HookManager._run_blocking"], - ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], - ["awaitable", "await trace.finished(outcome=_hook_outcome(result, blocked=decision.blocked and (not blocked_before)), arguments_modified=decision.args_override != override_before, exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved"], - ["wrapper", "await self._notify_change(executions=False)", "unresolved", "chrys.service.hooks.manager.HookManager._notify_change"], + ["wrapper", "await self._report(self._finish_run(run, outcome=HookOutcome.CANCELLED))", "unresolved", "chrys.service.hooks.manager.HookManager._report"], + ["wrapper", "await self._report_end(self._finish_run(run, outcome=outcome, result=result), trace, outcome=outcome, exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved", "chrys.service.hooks.manager.HookManager._report_end"], + ["wrapper", "await self._report_end(self._finish_run(run, outcome=_hook_outcome(result, blocked=False), result=result, effect=effect, applied=applied), trace, outcome=_hook_outcome(result, blocked=blocked), arguments_modified=arguments_modified, exit_code=result.exit_code, timed_out=result.timed_out)", "unresolved", "chrys.service.hooks.manager.HookManager._report_end"], ["wrapper", "await self._spawn_nonblocking(hook, envelope, scope=scope, session_drain_generation=session_drain_generation, target_operation_id=target_operation_id, origin=origin)", "unresolved", "chrys.service.hooks.manager.HookManager._spawn_nonblocking"] ], "HookManager.recover_outbox": [ diff --git a/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py b/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py index 3f6977c12..edcb1a613 100644 --- a/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py +++ b/tests/foundation/i18n/_hook_panel_catalog_oracle_ids.py @@ -11,6 +11,7 @@ "tui.hook_run.applied.context", "tui.hook_run.applied.nothing", "tui.hook_run.applied.reminder", + "tui.hook_run.args_truncated", "tui.hook_run.copy.log_path", "tui.hook_run.copy.section", "tui.hook_run.copy.section_too_large", @@ -42,6 +43,7 @@ "tui.hook_run.not_applied.failed_blocked", "tui.hook_run.not_applied.nonblocking", "tui.hook_run.not_applied.unsupported", + "tui.hook_run.not_shown", "tui.hook_run.payload_truncated", "tui.hook_run.retry", "tui.hook_run.running", diff --git a/tests/foundation/i18n/test_catalog_oracle.py b/tests/foundation/i18n/test_catalog_oracle.py index af3385cca..10e514c09 100644 --- a/tests/foundation/i18n/test_catalog_oracle.py +++ b/tests/foundation/i18n/test_catalog_oracle.py @@ -23,7 +23,7 @@ def test_live_catalog_artifacts_contain_effective_translations_and_are_loadable( pot = _read_catalog(i18n.POT_PATH) po = _read_catalog(i18n.ZH_PO_PATH) - assert len(EXPECTED_MESSAGE_IDS) == 2399 + assert len(EXPECTED_MESSAGE_IDS) == 2401 assert {_entry_id(message) for message in pot if message.id} == EXPECTED_MESSAGE_IDS assert {_entry_id(message) for message in po if message.id} == EXPECTED_MESSAGE_IDS assert set(_effective_catalog_entries(po)) == EXPECTED_MESSAGE_IDS diff --git a/tests/integration/engine/test_trajectory_events_log.py b/tests/integration/engine/test_trajectory_events_log.py index e7e5997f3..3e6803d91 100644 --- a/tests/integration/engine/test_trajectory_events_log.py +++ b/tests/integration/engine/test_trajectory_events_log.py @@ -1059,9 +1059,14 @@ async def test_a_rollback_that_opens_the_inherited_log_still_records_the_branch( original_run = HookRunner.run_and_wait async def held_result( - self: HookRunner, hook: HookConfig, payload: dict[str, Any], *, payload_text: str | None = None + self: HookRunner, + hook: HookConfig, + payload: dict[str, Any], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: - result = await original_run(self, hook, payload, payload_text=payload_text) + result = await original_run(self, hook, payload, payload_text=payload_text, result=result) background_result_ready.set() await release_background_result.wait() return result diff --git a/tests/orchestration/engine/build/test_retired_hook_traces.py b/tests/orchestration/engine/build/test_retired_hook_traces.py index ae81172b9..f868993dd 100644 --- a/tests/orchestration/engine/build/test_retired_hook_traces.py +++ b/tests/orchestration/engine/build/test_retired_hook_traces.py @@ -48,7 +48,9 @@ async def test_replaced_managers_report_results_until_the_original_recorder_clos tasks: list[asyncio.Task[HookResult]] = [] managers: list[HookManager] = [] - async def held_result(hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None) -> HookResult: + async def held_result( + hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None, result: HookResult | None = None + ) -> HookResult: await release.wait() return HookResult(hook_id=hook.id, exit_code=exit_code) diff --git a/tests/orchestration/engine/run/test_lifecycle_hooks.py b/tests/orchestration/engine/run/test_lifecycle_hooks.py index c6f244430..573ee714b 100644 --- a/tests/orchestration/engine/run/test_lifecycle_hooks.py +++ b/tests/orchestration/engine/run/test_lifecycle_hooks.py @@ -467,7 +467,11 @@ async def test_user_interrupt_hook_is_detached_from_turn_drain(tmp_path: Path) - release = asyncio.Event() async def _run_and_wait( - _hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + _hook: HookConfig, + _payload: dict[str, Any], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: entered.set() await release.wait() diff --git a/tests/orchestration/engine/test_hook_run_engine.py b/tests/orchestration/engine/test_hook_run_engine.py index 8b2d4c40f..4093592a8 100644 --- a/tests/orchestration/engine/test_hook_run_engine.py +++ b/tests/orchestration/engine/test_hook_run_engine.py @@ -335,23 +335,19 @@ def assembled(secret: str) -> str: detail = _log(engine).detail(record.run_id) assert detail is not None and detail.io is not None assert stdout_secret in detail.io.stdout, "kept in memory for the panel" + # Shutdown writes the last of the session and trajectory, and releases the + # session's active lock and trajectory writer lease, which on Windows are + # byte-range locks that refuse every other handle's read while held. + await engine.shutdown() leaks: list[str] = [] - unreadable: list[Path] = [] for root in (tmp_path, get_platform().config_dir): for path in root.rglob("*"): if not path.is_file() or path.suffix == ".py": continue - try: - data = path.read_bytes() - except PermissionError: - # Windows refuses reads of a byte-range-locked file (the - # session's active lock); only lock files may be skipped. - unreadable.append(path) - continue + data = path.read_bytes() secrets = (stdout_secret, stderr_secret, result_secret) leaks.extend(f"{secret} in {path}" for secret in secrets if secret.encode() in data) assert leaks == [] - assert all(path.suffix == ".lock" for path in unreadable), unreadable for secret in (stdout_secret, stderr_secret, result_secret): assert secret not in caplog.text diff --git a/tests/orchestration/engine/test_hook_run_reporting.py b/tests/orchestration/engine/test_hook_run_reporting.py index 848587aea..d41a0ca99 100644 --- a/tests/orchestration/engine/test_hook_run_reporting.py +++ b/tests/orchestration/engine/test_hook_run_reporting.py @@ -24,7 +24,7 @@ from chrys.service.hooks.run_log import HookRunLog from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HookSettings, HooksFile from chrys.service.trajectory.hooks import HookOutcome -from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for, wait_until +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for _SESSION = "s1" @@ -267,7 +267,6 @@ async def explode(_event: HookRunsChanged) -> None: assert decision.blocked is True (record,) = log.records() assert (record.state, record.effect) == ("ended", "blocked") - assert not await wait_until(lambda: len(log.records()) != 1, timeout=0.2) def test_the_notice_does_not_reach_headless_or_acp_output() -> None: diff --git a/tests/orchestration/engine/test_shutdown_close.py b/tests/orchestration/engine/test_shutdown_close.py index c22060087..390d8ad95 100644 --- a/tests/orchestration/engine/test_shutdown_close.py +++ b/tests/orchestration/engine/test_shutdown_close.py @@ -547,7 +547,11 @@ async def test_session_transition_settles_active_injection_and_blocking_hook_at_ hook_release = asyncio.Event() async def _run_and_wait( - _hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + _hook: HookConfig, + _payload: dict[str, Any], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: hook_entered.set() await hook_release.wait() diff --git a/tests/service/hooks/test_manager.py b/tests/service/hooks/test_manager.py index ddb648f51..9e9a6708b 100644 --- a/tests/service/hooks/test_manager.py +++ b/tests/service/hooks/test_manager.py @@ -292,6 +292,33 @@ async def test_blocking_hook_timeout_with_on_error_block(hooks_dir: Path) -> Non assert "timeout" in decision.block_reason +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups and SIGSTOP only") +@pytest.mark.asyncio +async def test_a_block_by_a_stopped_hook_says_it_stopped_past_a_long_stderr(hooks_dir: Path, tmp_path: Path) -> None: + """The reason is cut at 200 characters of stderr, which the stopped note only ends.""" + pid_file = tmp_path / "child.pid" + script = textwrap.dedent( + f""" + import subprocess, sys + sys.stderr.write("x" * 500) + sys.stderr.flush() + subprocess.Popen([ + sys.executable, + "-c", + "import os, pathlib, signal, time; pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid())); " + "os.kill(os.getpid(), signal.SIGSTOP); time.sleep(30)", + ]) + """ + ) + hook = _hook(hook_id="stops", event=HookEvent.BEFORE_TOOL_CALL, script=script, timeout=10, on_error="block") + mgr = HookManager(file=_file(hook), hooks_dir=hooks_dir) + + decision = await mgr.fire(HookEvent.BEFORE_TOOL_CALL, {}) + + assert decision.blocked is True + assert decision.block_reason.startswith("hook 'stops' failed (stopped): xxx") + + @pytest.mark.asyncio async def test_blocking_hook_timeout_with_on_error_warn(hooks_dir: Path) -> None: mgr = HookManager( @@ -401,7 +428,11 @@ async def test_explicit_trajectory_context_applies_only_to_blocking_hooks(hooks_ release_async = asyncio.Event() async def _run_and_wait( - hook: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + hook: HookConfig, + _payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: if hook is asynchronous: await release_async.wait() @@ -781,7 +812,11 @@ async def test_session_drain_preserves_background_result_while_log_stays_open( release = asyncio.Event() async def run_and_wait( - config: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None + config: HookConfig, + _payload: dict[str, Any], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: entered.set() await release.wait() @@ -904,7 +939,11 @@ async def test_drain_session_fences_inflight_blocking_hook_dispatch(hooks_dir: P executed: list[str] = [] async def _run_and_wait( - hook: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + hook: HookConfig, + _payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: executed.append(hook.id) if hook is blocking_one: @@ -953,7 +992,11 @@ async def test_drain_session_fences_blocking_dispatch_during_start_ack(hooks_dir executed: list[str] = [] async def _run_and_wait( - config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + config: HookConfig, + _payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: executed.append(config.id) return HookResult(hook_id=config.id, exit_code=0) @@ -1003,7 +1046,11 @@ async def test_drain_session_fences_durable_async_dispatch_during_start_ack(hook executed: list[str] = [] async def _run_and_wait( - config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + config: HookConfig, + _payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: executed.append(config.id) return HookResult(hook_id=config.id, exit_code=0) @@ -1080,7 +1127,11 @@ async def test_detached_scope_async_hook_is_reaped_only_after_completion(hooks_d release = asyncio.Event() async def _run_and_wait( - config: HookConfig, _payload: dict[str, object], *, payload_text: str | None = None + config: HookConfig, + _payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, ) -> HookResult: entered.set() await release.wait() diff --git a/tests/service/hooks/test_run_lifecycle.py b/tests/service/hooks/test_run_lifecycle.py new file mode 100644 index 000000000..27773e318 --- /dev/null +++ b/tests/service/hooks/test_run_lifecycle.py @@ -0,0 +1,250 @@ +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. + +"""When a recorded run ends: once, at how its process ended, before anything is awaited. + +A run's end is settled the moment its outcome is known, and only then reported +and written to the trajectory: an interrupt landing on those awaits never logs a +finished run as cancelled, and still closes its span. Whatever the run is +handed, it fails as a run, never as the caller, and lets go of its payload once +it ended. Hooks are real ``python -c`` subprocesses. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import sys +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +import pytest + +from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.foundation.models.invocations import InvocationOrigin +from chrys.foundation.trajectory.context import trajectory_scope +from chrys.foundation.trajectory.envelope import EventDraft +from chrys.foundation.trajectory.event_types import EventType +from chrys.foundation.trajectory.writer import EmitResult +from chrys.service.hooks.events import HookEvent +from chrys.service.hooks.manager import HookManager +from chrys.service.hooks.run_log import HookRunLog +from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HooksFile +from chrys.service.trajectory.hooks import HookOutcome +from tests.service.trajectory._fakes import FakeSink, make_context +from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for + +_PRINTS = "import sys\nsys.stdout.write('printed')\n" + + +def _hook(hook_id: str, event: HookEvent, *, mode: str, script: str = _PRINTS) -> HookConfig: + return HookConfig( + id=hook_id, + event=event, + run=HookRun(type="command", argv=[sys.executable, "-c", script]), + execution=HookExecution(mode=mode, timeout_seconds=30.0), + match=HookMatch(), + ) + + +def _recorded( + hooks_dir: Path, *hooks: HookConfig, on_change: Callable[[], Any] | None = None +) -> tuple[HookManager, HookRunLog]: + manager = HookManager(file=HooksFile(hooks=list(hooks)), hooks_dir=hooks_dir) + log = HookRunLog() + + async def changed() -> None: + if on_change is not None: + on_change() + + manager.record_runs(log, session_id="s1", on_change=changed) + return manager, log + + +def _only(log: HookRunLog) -> HookRunRecord: + (record,) = log.records() + return record + + +class _CancelOnFinishedSink(FakeSink): + """A sink whose terminal hook write is interrupted, the way an interrupt during its ack would.""" + + async def emit( + self, draft: EventDraft, *, payload_factory: Callable[[int], Mapping[str, Any]] | None = None + ) -> EmitResult: + if draft.event_type == EventType.HOOK_OPERATION_FINISHED: + raise asyncio.CancelledError + return await super().emit(draft, payload_factory=payload_factory) + + +@pytest.fixture +def hooks_dir(tmp_path: Path) -> Path: + path = tmp_path / "hooks" + path.mkdir() + return path + + +# --- Interrupted after the run ended --------------------------------------------- + + +@pytest.mark.parametrize("mode", ["blocking", "async"]) +async def test_a_run_interrupted_on_its_span_marker_keeps_how_it_ended(hooks_dir: Path, mode: str) -> None: + manager, log = _recorded(hooks_dir, _hook("lint", HookEvent.AFTER_TURN, mode=mode)) + + with trajectory_scope(make_context(_CancelOnFinishedSink())): + if mode == "blocking": + with pytest.raises(asyncio.CancelledError): + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + else: + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) + + record = _only(log) + assert (record.state, record.outcome, record.exit_code) == ("ended", HookOutcome.SUCCESS, 0) + detail = log.detail(record.run_id) + assert detail is not None and detail.io is not None + assert detail.io.stdout == "printed" + assert manager.executions() == () + + +@pytest.mark.parametrize("mode", ["blocking", "async"]) +async def test_an_interrupt_while_reporting_the_end_still_closes_the_span_as_it_ended( + hooks_dir: Path, mode: str +) -> None: + interrupted: list[str] = [] + log_box: list[HookRunLog] = [] + + def on_change() -> None: + # The end's report: the run log already says how it ended. + if not interrupted and any(record.state == "ended" for record in log_box[0].records()): + interrupted.append("once") + raise asyncio.CancelledError + + manager, log = _recorded(hooks_dir, _hook("lint", HookEvent.AFTER_TURN, mode=mode), on_change=on_change) + log_box.append(log) + sink = FakeSink() + + with trajectory_scope(make_context(sink)): + if mode == "blocking": + with pytest.raises(asyncio.CancelledError): + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + else: + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) + await wait_for( + lambda: sink.of_type(EventType.HOOK_OPERATION_FINISHED), + timeout=ENGINE_TURN_TIMEOUT, + description="the span closed", + ) + + assert interrupted == ["once"] + (finished,) = sink.of_type(EventType.HOOK_OPERATION_FINISHED) + assert finished.payload["outcome"] == HookOutcome.SUCCESS + assert _only(log).outcome == HookOutcome.SUCCESS + assert manager.executions() == () + + +async def test_a_detached_run_interrupted_while_handed_off_is_recorded_as_cancelled( + hooks_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + changes: list[None] = [] + hook = _hook("worker", HookEvent.AFTER_TURN, mode="fire_and_forget") + hook = dataclasses.replace(hook, execution=dataclasses.replace(hook.execution, detach=True)) + manager, log = _recorded(hooks_dir, hook, on_change=lambda: changes.append(None)) + handing_off = asyncio.Event() + + async def spawn_detached( + hook: HookConfig, payload: dict[str, Any], *, job: object = None, payload_text: str | None = None + ) -> Path: + handing_off.set() + await asyncio.Event().wait() + raise AssertionError("never handed off") + + monkeypatch.setattr(manager._runner, "spawn_detached", spawn_detached) + sink = FakeSink() + with trajectory_scope(make_context(sink)): + fire = asyncio.create_task(manager.fire(HookEvent.AFTER_TURN, {"turn": 1})) + await wait_for(lambda: handing_off.is_set() or fire.done(), description="the hand-off started") + assert handing_off.is_set() and not fire.done() + fire.cancel() + with pytest.raises(asyncio.CancelledError): + await fire + await wait_for( + lambda: sink.of_type(EventType.HOOK_OPERATION_FINISHED), + timeout=ENGINE_TURN_TIMEOUT, + description="the span closed", + ) + + (finished,) = sink.of_type(EventType.HOOK_OPERATION_FINISHED) + assert finished.payload["outcome"] == HookOutcome.CANCELLED + record = _only(log) + assert (record.state, record.outcome, record.detach) == ("ended", HookOutcome.CANCELLED, True) + assert changes, "the Hooks tab is told" + assert manager.executions() == () + + +# --- What a run holds ------------------------------------------------------------ + + +async def test_a_payload_that_cannot_be_serialized_fails_the_run_not_the_caller(hooks_dir: Path) -> None: + manager, log = _recorded(hooks_dir, _hook("gate", HookEvent.BEFORE_TOOL_CALL, mode="blocking")) + # A key JSON cannot write, whatever ``default=`` says. + payload = {"tool": {"name": "zsh", "call_id": "c1", "args": {("not", "a", "str"): 1}}} + + decision = await manager.fire(HookEvent.BEFORE_TOOL_CALL, payload) + + assert decision.blocked is False + record = _only(log) + assert (record.state, record.outcome, record.tool_name) == ("ended", HookOutcome.LAUNCH_ERROR, "zsh") + detail = log.detail(record.run_id) + assert detail is not None and detail.io is not None + assert "keys must be" in detail.io.stderr + assert manager.executions() == () + + +async def test_an_ended_run_awaiting_its_turn_drain_no_longer_holds_its_payload(hooks_dir: Path) -> None: + manager, log = _recorded(hooks_dir, _hook("lint", HookEvent.AFTER_TURN, mode="async")) + + await manager.fire(HookEvent.AFTER_TURN, {"turn": 1, "transcript": "t" * 100_000}) + await wait_for( + lambda: _only(log).state == "ended", timeout=ENGINE_TURN_TIMEOUT, description="the run ended undrained" + ) + # Kept until the turn's drain, as the drain reports what it waited on. + (tracked,) = manager._inflight + assert tracked.task.done() + assert tracked.run.payload_text is None + detail = log.detail(_only(log).run_id) + assert detail is not None and detail.io is not None and "t" * 1000 in detail.io.payload, "the log keeps its copy" + await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) + + +# --- Who fired it ---------------------------------------------------------------- + + +async def test_a_sub_agent_has_one_name_on_every_event_it_fires(hooks_dir: Path) -> None: + manager, log = _recorded( + hooks_dir, + _hook("gate", HookEvent.BEFORE_TOOL_CALL, mode="blocking", script="pass"), + _hook("compact", HookEvent.PRE_COMPACT, mode="blocking", script="pass"), + ) + turn = InvocationOrigin(kind="turn", session_id="s1", invocation_id="turn-1", parent=None) + child = InvocationOrigin(kind="sub_agent", session_id="s1", invocation_id="child-1", parent=turn) + + # A tool payload carries the sub-agent's profile name; pre_compact its display name too. + await manager.fire( + HookEvent.BEFORE_TOOL_CALL, + {"profile": "Explore", "tool": {"name": "read_file", "call_id": "r1", "args": {}}}, + origin=child, + ) + await manager.fire( + HookEvent.PRE_COMPACT, + {"profile": "Explore", "sub_agent": {"name": "Explore Agent", "tool_name": "Explore"}}, + origin=child, + ) + await manager.fire(HookEvent.BEFORE_TOOL_CALL, {"profile": "Code", "tool": {"name": "Explore"}}, origin=turn) + + assert [(record.hook_id, record.sub_agent) for record in log.records()] == [ + ("gate", "Explore"), + ("compact", "Explore"), + ("gate", ""), + ] diff --git a/tests/service/hooks/test_run_log.py b/tests/service/hooks/test_run_log.py index c6ab08510..a3860d966 100644 --- a/tests/service/hooks/test_run_log.py +++ b/tests/service/hooks/test_run_log.py @@ -16,16 +16,21 @@ import time from collections.abc import AsyncIterator from pathlib import Path +from typing import Any import pytest -from chrys.foundation.models.hook_runs import HookRunDetail, HookRunRecord +from chrys.foundation.models.hook_runs import HookRunApplied, HookRunDetail, HookRunRecord +from chrys.foundation.platform.output_capture import BoundedCapture from chrys.foundation.trajectory.context import trajectory_scope from chrys.foundation.trajectory.event_types import EventType +from chrys.service.hooks import manager as manager_module +from chrys.service.hooks import runner as runner_module from chrys.service.hooks.events import HookEvent from chrys.service.hooks.manager import HookManager -from chrys.service.hooks.run_log import HookRunLog -from chrys.service.hooks.runner import HookRunner +from chrys.service.hooks.outbox import DONE, FAILED, Outbox +from chrys.service.hooks.run_log import UNWRITABLE_JSON, HookRunLog +from chrys.service.hooks.runner import HookResult, HookRunner from chrys.service.hooks.schema import ( HookConfig, HookExecution, @@ -53,6 +58,28 @@ def _result(decision: dict[str, object], *, exit_code: int = 0) -> str: ) +def _nested_override(depth: int) -> str: + """A hook overriding ``x`` with *depth* nested lists, deeper than a Python literal may nest.""" + return textwrap.dedent( + f""" + import os + with open(os.environ["CHRYS_HOOK_RESULT"], "w") as f: + f.write('{{"action": "modify", "args_override": {{"x": ' + "[" * {depth} + "]" * {depth} + "}}}}") + """ + ) + + +def _deep(depth: int) -> dict[str, object]: + """Objects nested *depth* deep, deeper than any stack lets JSON write or Python compare them.""" + top: dict[str, object] = {} + node = top + for _ in range(depth): + child: dict[str, object] = {} + node["a"] = child + node = child + return top + + def _held(release: Path) -> str: return textwrap.dedent( f""" @@ -129,6 +156,20 @@ def hooks_dir(tmp_path: Path) -> Path: return path +@pytest.fixture +def fed(monkeypatch: pytest.MonkeyPatch) -> list[bytes]: + """Every chunk the runner reads from a hook's stdout and stderr, as it reads them.""" + chunks: list[bytes] = [] + + class _Seen(BoundedCapture): + def feed(self, chunk: bytes) -> None: + chunks.append(chunk) + super().feed(chunk) + + monkeypatch.setattr(runner_module, "BoundedCapture", _Seen) + return chunks + + @pytest.fixture async def release(tmp_path: Path) -> AsyncIterator[Path]: path = tmp_path / "release" @@ -163,6 +204,14 @@ async def test_a_successful_run_with_no_decision_is_recorded_ended_with_no_effec assert logged.changes[-1] == logged.log.records() +async def test_an_empty_result_object_is_kept_as_written(hooks_dir: Path) -> None: + logged = _logged(hooks_dir, _hook("nothing-to-say", HookEvent.BEFORE_TOOL_CALL, script=_result({}))) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + detail = logged.detail("nothing-to-say") + assert detail.io is not None + assert (detail.io.result_file, detail.io.decision) == ("ok", "{}") + + async def test_a_block_is_recorded_as_success_with_the_blocked_effect(hooks_dir: Path) -> None: decision = {"action": "block", "reason": "rm denied", "extra_context": "ignored"} logged = _logged(hooks_dir, _hook("guard", HookEvent.BEFORE_TOOL_CALL, script=_result(decision))) @@ -211,6 +260,134 @@ async def test_a_later_hook_returning_the_already_merged_arguments_did_not_modif assert logged.only("second").effect is None +async def test_an_argument_turned_from_1_to_true_was_modified(hooks_dir: Path) -> None: + """Python finds ``{"n": 1}`` and ``{"n": True}`` equal; the tool gets ``true`` all the same.""" + logged = _logged( + hooks_dir, + _hook("first", HookEvent.BEFORE_TOOL_CALL, script=_result({"action": "modify", "args_override": {"n": 1}})), + _hook("second", HookEvent.BEFORE_TOOL_CALL, script=_result({"action": "modify", "args_override": {"n": True}})), + ) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"n": 0})) + assert result.args_override is not None and result.args_override["n"] is True + assert logged.only("second").effect == "modified_args" + applied = logged.detail("second").applied + assert applied is not None + assert json.loads(applied.args) == {"n": True} + + +async def test_arguments_nested_past_the_recursion_limit_still_end_every_run(hooks_dir: Path) -> None: + plain = {"action": "modify", "args_override": {"y": 1}} + logged = _logged( + hooks_dir, + _hook("deep", HookEvent.BEFORE_TOOL_CALL, script=_nested_override(3_000)), + _hook("plain", HookEvent.BEFORE_TOOL_CALL, script=_result(plain)), + ) + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"a": 1})) + assert result.args_override is not None and result.args_override["y"] == 1 + records = [(record.hook_id, record.state, record.effect) for record in logged.log.records()] + assert records == [("deep", "ended", "modified_args"), ("plain", "ended", "modified_args")] + + +async def test_a_result_nested_deeper_than_json_writes_still_ends_every_run(hooks_dir: Path) -> None: + """The decoder takes objects nested deeper than the encoder writes: recording them must not fail the call.""" + deep = _deep(100_000) + logged = _logged(hooks_dir, _hook("deep", HookEvent.BEFORE_TOOL_CALL), _hook("after", HookEvent.BEFORE_TOOL_CALL)) + + # Handed over as parsed, without a subprocess: how deep a decoder goes depends on the stack. + async def run_and_wait( + hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None, result: HookResult | None = None + ) -> HookResult: + if hook.id != "deep": + return HookResult(hook_id=hook.id, exit_code=0) + override = {"action": "modify", "args_override": {"x": deep}} + return HookResult(hook_id=hook.id, exit_code=0, decision=override, result_file="ok") + + logged.manager._runner.run_and_wait = run_and_wait # type: ignore[method-assign] + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"a": 1})) + assert result.args_override is not None and result.args_override["x"] is deep + records = [(record.hook_id, record.state, record.effect) for record in logged.log.records()] + assert records == [("deep", "ended", "modified_args"), ("after", "ended", None)] + detail = logged.detail("deep") + assert detail.io is not None and detail.applied is not None + assert (detail.io.decision, detail.applied.args) == (UNWRITABLE_JSON, UNWRITABLE_JSON) + + +async def test_overrides_too_deep_to_compare_still_report_every_end(hooks_dir: Path) -> None: + first, second = _deep(100_000), _deep(100_000) + overrides = {"first": first, "second": second} + logged = _logged(hooks_dir, _hook("first", HookEvent.BEFORE_TOOL_CALL), _hook("second", HookEvent.BEFORE_TOOL_CALL)) + + async def run_and_wait( + hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None, result: HookResult | None = None + ) -> HookResult: + decision = {"action": "modify", "args_override": {"x": overrides[hook.id]}} + return HookResult(hook_id=hook.id, exit_code=0, decision=decision, result_file="ok") + + logged.manager._runner.run_and_wait = run_and_wait # type: ignore[method-assign] + result = await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"a": 1})) + assert result.args_override is not None and result.args_override["x"] is second + assert [(record.hook_id, record.state) for record in logged.log.records()] == [ + ("first", "ended"), + ("second", "ended"), + ] + # Both ends reported: the indicator and the list show nothing running. + assert logged.manager.executions() == () + assert logged.changes[-1] == logged.log.records() + + +@pytest.mark.parametrize("mode", ["blocking", "async"]) +async def test_only_a_string_names_an_action(hooks_dir: Path, mode: str) -> None: + """Any other value names none, though one nested too deep to write as text.""" + logged = _logged(hooks_dir, _hook("odd", HookEvent.AFTER_TURN, mode=mode)) + + async def run_and_wait( + hook: HookConfig, _payload: dict[str, Any], *, payload_text: str | None = None, result: HookResult | None = None + ) -> HookResult: + return HookResult(hook_id=hook.id, exit_code=0, decision={"action": _deep(100_000)}, result_file="ok") + + logged.manager._runner.run_and_wait = run_and_wait # type: ignore[method-assign] + decision = await logged.manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) + await asyncio.wait_for(logged.manager.drain_turn(), ENGINE_TURN_TIMEOUT) + assert (decision.blocked, decision.args_override) == (False, None) + record = logged.only("odd") + assert (record.state, record.outcome, record.effect) == ("ended", HookOutcome.SUCCESS, None) + assert logged.detail("odd").applied == HookRunApplied() + assert logged.manager.executions() == () + + +async def test_a_result_that_cannot_be_taken_still_ends_and_reports_its_run( + hooks_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The error goes on to the caller, as it always did, but the run is not left running.""" + logged = _logged(hooks_dir, _hook("odd", HookEvent.BEFORE_TOOL_CALL, script=_result({"action": "block"}))) + + # As a reason nested too deep to write as text raises, where the stack is too small to write it. + def unwritable(*_args: object, **_kwargs: object) -> None: + raise RecursionError("maximum recursion depth exceeded") + + monkeypatch.setattr(manager_module, "_merge_blocking_result", unwritable) + sink = FakeSink() + with trajectory_scope(make_context(sink)), pytest.raises(RecursionError): + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool()) + record = logged.only("odd") + assert (record.state, record.outcome) == ("ended", HookOutcome.SUCCESS) + assert logged.manager.executions() == () + assert logged.changes[-1] == logged.log.records() + finished = [draft.payload["outcome"] for draft in sink.of_type(EventType.HOOK_OPERATION_FINISHED)] + assert finished == [HookOutcome.SUCCESS] + + +async def test_a_deeply_nested_result_is_recorded_compact(hooks_dir: Path) -> None: + """Indented, 3,000 levels would take some 18 million characters: the depth on every line.""" + logged = _logged(hooks_dir, _hook("deep", HookEvent.BEFORE_TOOL_CALL, script=_nested_override(3_000))) + await logged.manager.fire(HookEvent.BEFORE_TOOL_CALL, _tool(args={"a": 1})) + nested = "[" * 3_000 + "]" * 3_000 + detail = logged.detail("deep") + assert detail.io is not None and detail.applied is not None + assert detail.io.decision == f'{{"action": "modify", "args_override": {{"x": {nested}}}}}' + assert detail.applied.args == f'{{"a": 1, "x": {nested}}}' + + async def test_an_added_reminder_is_recorded(hooks_dir: Path) -> None: decision = {"system_reminder": "remember the style guide"} logged = _logged(hooks_dir, _hook("remind", HookEvent.USER_PROMPT_SUBMIT, script=_result(decision))) @@ -338,6 +515,41 @@ async def test_a_cancelled_running_run_is_recorded_cancelled_and_reported(hooks_ assert logged.changes[-1] == logged.log.records() +@pytest.mark.parametrize("mode", ["blocking", "async"]) +async def test_a_cancelled_run_keeps_what_it_printed( + hooks_dir: Path, release: Path, fed: list[bytes], mode: str +) -> None: + """Opened to see why a hook stalled, its details show what it printed until then.""" + script = "import sys\nprint('contacting server', flush=True)\nprint('slow', file=sys.stderr, flush=True)\n" + event = HookEvent.BEFORE_TOOL_CALL if mode == "blocking" else HookEvent.SESSION_END + logged = _logged( + hooks_dir, _hook("stalls", event, mode=mode, script=script + _held(release)), shutdown_grace_seconds=0 + ) + + def printed() -> bool: + read = b"".join(fed) + return b"contacting server" in read and b"slow" in read + + if mode == "blocking": + fire = asyncio.create_task(logged.manager.fire(event, _tool())) + await wait_for(lambda: printed() or fire.done(), timeout=ENGINE_TURN_TIMEOUT, description="output read") + assert not fire.done() + fire.cancel() + with contextlib.suppress(asyncio.CancelledError): + await fire + else: + await logged.manager.fire(event, {}, scope="session") + await wait_for(printed, timeout=ENGINE_TURN_TIMEOUT, description="output read") + await asyncio.wait_for(logged.manager.drain_session(close=False), ENGINE_TURN_TIMEOUT) + record = logged.only("stalls") + assert (record.state, record.outcome) == ("ended", HookOutcome.CANCELLED) + io = logged.detail("stalls").io + assert io is not None + assert "contacting server" in io.stdout + assert "slow" in io.stderr + await logged.manager.drain_session() + + @pytest.mark.parametrize("runs", [1, 2]) async def test_runs_a_zero_grace_drain_cancels_before_their_first_step_are_recorded_cancelled( hooks_dir: Path, release: Path, runs: int @@ -418,6 +630,26 @@ async def test_run_ids_are_the_trajectory_operation_ids_and_outcomes_match(hooks assert records["guard"].effect == "blocked" +async def test_an_outbox_job_whose_payload_is_no_object_fails_and_the_jobs_after_it_still_run( + hooks_dir: Path, +) -> None: + """As unrecorded: a job edited by hand, or written by another version, is read back as it is.""" + outbox = Outbox(hooks_dir / "outbox") + odd = outbox.write_pending(hook_id="durable", event="after_turn", payload={}) + odd.payload = [] # type: ignore[assignment] + outbox._write(odd) + outbox.write_pending(hook_id="durable", event="after_turn", payload={"turn": 1}) + hook = _hook("durable", HookEvent.AFTER_TURN, mode="async", delivery="durable") + logged = _logged(hooks_dir, hook, outbox_retry_age_seconds=0.0) + assert await logged.manager.recover_outbox() == 2 + await asyncio.wait_for(logged.manager.drain_session(close=False), ENGINE_TURN_TIMEOUT) + assert [record.state for record in logged.log.records()] == ["ended", "ended"] + assert logged.manager.executions() == () + assert len(list((hooks_dir / "outbox" / FAILED).glob("*.json"))) == 1 + assert len(list((hooks_dir / "outbox" / DONE).glob("*.json"))) == 1 + await logged.manager.drain_session() + + async def test_runs_are_recorded_without_a_trajectory(hooks_dir: Path) -> None: logged = _logged(hooks_dir, _hook("a", HookEvent.AFTER_TURN), _hook("b", HookEvent.AFTER_TURN)) await logged.manager.fire(HookEvent.AFTER_TURN, {"turn": 1}) diff --git a/tests/service/hooks/test_run_log_retention.py b/tests/service/hooks/test_run_log_retention.py index f5d4fc63e..40cbc69ed 100644 --- a/tests/service/hooks/test_run_log_retention.py +++ b/tests/service/hooks/test_run_log_retention.py @@ -19,16 +19,18 @@ import pytest -from chrys.foundation.models.hook_runs import HookRunRecord +from chrys.foundation.models.hook_runs import HookRunApplied, HookRunDetail, HookRunRecord from chrys.service.hooks.events import HookEvent from chrys.service.hooks.manager import HookManager from chrys.service.hooks.run_log import ( HOOK_RUN_LOG_MAX_DETAIL_CHARS, HOOK_RUN_LOG_MAX_ENDED, - HOOK_RUN_PAYLOAD_MAX_CHARS, + HOOK_RUN_TEXT_MAX_BYTES, + UNWRITABLE_JSON, HookRunLog, + indented_json, ) -from chrys.service.hooks.runner import HookRunner +from chrys.service.hooks.runner import HookResult, HookRunner from chrys.service.hooks.schema import HookConfig, HookExecution, HookMatch, HookRun, HookSettings, HooksFile from chrys.service.trajectory.hooks import HookOutcome from tests.support.waiting import ENGINE_TURN_TIMEOUT, wait_for @@ -108,7 +110,9 @@ async def release(tmp_path: Path) -> AsyncIterator[Path]: def test_the_limits_are_the_planned_ones() -> None: assert HOOK_RUN_LOG_MAX_ENDED == 100 assert HOOK_RUN_LOG_MAX_DETAIL_CHARS == 16 * 1024 * 1024 - assert HOOK_RUN_PAYLOAD_MAX_CHARS == 64 * 1024 + assert HOOK_RUN_TEXT_MAX_BYTES == 64 * 1024 + # The block reasons of every ended run are kept, so they must fit the budget together. + assert HOOK_RUN_LOG_MAX_ENDED * HOOK_RUN_TEXT_MAX_BYTES <= HOOK_RUN_LOG_MAX_DETAIL_CHARS # --- Queueing (max_parallel_hooks=1) -------------------------------------------- @@ -188,6 +192,8 @@ async def on_change() -> None: assert changes[-1] == log.records() release.touch() await manager.drain_session() + # No drain waits on a fire_and_forget run: the holder ends once released. + await wait_for(lambda: not manager._inflight, timeout=ENGINE_TURN_TIMEOUT, description="the holder ended") # --- The payload as registered (5.3) -------------------------------------------- @@ -233,7 +239,7 @@ async def test_a_large_payload_is_cut_and_its_size_kept(hooks_dir: Path, tmp_pat assert detail is not None and detail.io is not None full = received.read_text(encoding="utf-8") assert len(full) > 200_000, "the hook itself gets the whole payload" - assert len(detail.io.payload) <= HOOK_RUN_PAYLOAD_MAX_CHARS + assert len(detail.io.payload) <= HOOK_RUN_TEXT_MAX_BYTES assert full.startswith(detail.io.payload) assert detail.io.payload_size == len(full) @@ -242,9 +248,16 @@ async def test_without_a_log_nothing_is_serialized_for_it(hooks_dir: Path, monke seen: list[str | None] = [] original = HookRunner.run_and_wait - async def spy(self: HookRunner, hook: HookConfig, payload: dict[str, object], *, payload_text: str | None = None): + async def spy( + self: HookRunner, + hook: HookConfig, + payload: dict[str, object], + *, + payload_text: str | None = None, + result: HookResult | None = None, + ) -> HookResult: seen.append(payload_text) - return await original(self, hook, payload, payload_text=payload_text) + return await original(self, hook, payload, payload_text=payload_text, result=result) monkeypatch.setattr(HookRunner, "run_and_wait", spy) unrecorded = _manager(hooks_dir, _hook("plain", HookEvent.AFTER_TURN)) @@ -393,6 +406,8 @@ async def test_records_stay_in_registration_order_as_runs_start_and_end(hooks_di for path in releases.values(): path.touch() await asyncio.wait_for(manager.drain_session(), ENGINE_TURN_TIMEOUT) + # No drain waits on a fire_and_forget run: c and d end once released. + await wait_for(lambda: not manager._inflight, timeout=ENGINE_TURN_TIMEOUT, description="every run ended") # --- Details kept --------------------------------------------------------------- @@ -459,6 +474,136 @@ async def test_a_running_runs_detail_is_never_dropped(hooks_dir: Path, release: await asyncio.wait_for(manager.drain_turn(), ENGINE_TURN_TIMEOUT) +def _register(log: HookRunLog, run_id: str) -> None: + log.register( + run_id=run_id, + session_id=_SESSION, + hook=_hook("h", HookEvent.BEFORE_TOOL_CALL), + source="", + source_path="", + payload_text="{}", + tool_name="", + tool_call_id="", + sub_agent="", + retry=False, + ) + + +def _kept_text(detail: HookRunDetail) -> int: + """Every text *detail* holds, counted here apart from the log's own count.""" + size = 0 + if detail.io is not None: + size += sum(len(text) for text in (detail.io.payload, detail.io.decision, detail.io.stdout, detail.io.stderr)) + if detail.applied is not None: + applied = detail.applied + size += sum(len(text) for text in (applied.block_reason, applied.args, applied.reminder, applied.extra_context)) + return size + + +def test_what_ended_runs_applied_counts_toward_the_detail_budget() -> None: + log = HookRunLog(max_detail_chars=10_000) + for n in range(5): + _register(log, f"r{n}") + log.finish( + f"r{n}", + outcome=HookOutcome.SUCCESS, + effect="added_reminder", + applied=HookRunApplied(reminder="r" * 4000), + stdout="o" * 10, + ) + details = [log.detail(f"r{n}") for n in range(5)] + assert all(detail is not None for detail in details) + kept = [detail for detail in details if detail is not None] + assert sum(_kept_text(detail) for detail in kept) <= 10_000 + assert log._detail_chars == sum(_kept_text(detail) for detail in kept), "the count is what the details hold" + # The oldest go first, the texts iCode took from them with what they read and wrote. + assert kept[0].io is None and kept[0].applied is not None and kept[0].applied.reminder == "" + assert kept[-1].applied is not None and kept[-1].applied.reminder == "r" * 4000 + + +def test_a_block_whose_details_went_still_says_why() -> None: + log = HookRunLog(max_detail_chars=1_000) + _register(log, "guard") + log.finish("guard", outcome=HookOutcome.SUCCESS, effect="blocked", applied=HookRunApplied(block_reason="no")) + _register(log, "noisy") + log.finish("noisy", outcome=HookOutcome.SUCCESS, stdout="o" * 2_000) + detail = log.detail(log.records()[0].run_id) + assert detail is not None and detail.io is None + assert detail.applied is not None and detail.applied.block_reason == "no" + + +@pytest.mark.parametrize("unit", ["a", "é", "\udc80"], ids=["ascii", "two-byte", "lone-surrogate"]) +def test_changed_arguments_past_the_cap_are_cut_with_their_size_kept(unit: str) -> None: + full = json.dumps({"content": unit * 70_000}, ensure_ascii=False) + log = HookRunLog() + _register(log, "rewrite") + log.finish("rewrite", outcome=HookOutcome.SUCCESS, effect="modified_args", applied=HookRunApplied(args=full)) + detail = log.detail("rewrite") + assert detail is not None and detail.applied is not None + kept = detail.applied.args + # Cut short of the limit by the character it would split, at most: a path that is not UTF-8 included. + assert HOOK_RUN_TEXT_MAX_BYTES - 4 < len(kept.encode("utf-8", "surrogatepass")) <= HOOK_RUN_TEXT_MAX_BYTES + assert detail.applied.args_size == len(full.encode("utf-8", "surrogatepass")) + assert full.startswith(kept) + + +def test_changed_arguments_within_the_cap_are_kept_whole() -> None: + full = json.dumps({"path": "/é/b"}, ensure_ascii=False) + log = HookRunLog() + _register(log, "rewrite") + log.finish("rewrite", outcome=HookOutcome.SUCCESS, effect="modified_args", applied=HookRunApplied(args=full)) + detail = log.detail("rewrite") + assert detail is not None and detail.applied is not None + assert (detail.applied.args, detail.applied.args_size) == (full, 0) + + +def test_the_block_reasons_kept_fit_the_detail_budget() -> None: + """A block reason outlives the other details, so each is cut as the payload is.""" + reason = "r" * (HOOK_RUN_LOG_MAX_DETAIL_CHARS // HOOK_RUN_LOG_MAX_ENDED + 1) + log = HookRunLog() + for index in range(HOOK_RUN_LOG_MAX_ENDED): + _register(log, f"guard-{index}") + log.finish( + f"guard-{index}", outcome=HookOutcome.SUCCESS, effect="blocked", applied=HookRunApplied(block_reason=reason) + ) + details = [log.detail(record.run_id) for record in log.records()] + assert len(details) == HOOK_RUN_LOG_MAX_ENDED + assert sum(_kept_text(detail) for detail in details if detail is not None) <= HOOK_RUN_LOG_MAX_DETAIL_CHARS + for detail in details: + assert detail is not None and detail.applied is not None + assert detail.applied.block_reason == reason[:HOOK_RUN_TEXT_MAX_BYTES] + + +def _nested(depth: int) -> object: + return json.loads("[" * depth + "]" * depth) + + +def test_json_is_indented_while_that_keeps_it_small() -> None: + value = {"path": "/é", "lines": [1, 2], "deep": _nested(5)} + assert indented_json(value) == json.dumps(value, indent=2, ensure_ascii=False) + + +@pytest.mark.parametrize( + "value", + # Indenting adds the depth to every line: 200 levels of 400 characters would indent to 80,000. + [_nested(200), {"pad": "x" * 2_000_000, "deep": _nested(3_000)}], + ids=["swelling", "past-the-recursion-limit"], +) +def test_json_that_indenting_would_swell_is_kept_compact(value: object) -> None: + assert indented_json(value) == json.dumps(value, ensure_ascii=False) + + +def test_json_nested_deeper_than_the_encoder_writes_reads_as_a_note() -> None: + # Deeper than any stack lets the encoder go; parsed JSON can be deeper than it writes. + deep: dict[str, object] = {} + node = deep + for _ in range(100_000): + child: dict[str, object] = {} + node["a"] = child + node = child + assert indented_json({"action": "allow", "meta": deep}) == UNWRITABLE_JSON + + # --- Outbox retries and sessions ------------------------------------------------ diff --git a/tests/service/hooks/test_runner.py b/tests/service/hooks/test_runner.py index c5d6618a3..85c97bd09 100644 --- a/tests/service/hooks/test_runner.py +++ b/tests/service/hooks/test_runner.py @@ -230,7 +230,7 @@ def test_oversized_result_file_is_rejected_before_json_parse( with caplog.at_level(logging.WARNING, logger="chrys.service.hooks.runner"): result = runner_mod._read_result_file(path) - assert result == {} + assert result == ({}, "invalid") assert "exceeded the 100-byte limit" in caplog.text @@ -240,7 +240,7 @@ def test_result_file_at_byte_limit_is_parsed(tmp_path: Path, monkeypatch: pytest path = tmp_path / "at-limit.json" path.write_bytes(payload) - assert runner_mod._read_result_file(path) == {"action": "allow"} + assert runner_mod._read_result_file(path) == ({"action": "allow"}, "ok") @pytest.mark.asyncio @@ -301,6 +301,7 @@ async def test_stopped_grandchild_hook_returns_and_cleans_group(runner: HookRunn result = await runner.run_and_wait(hook, {}) assert result.timed_out is True + assert result.stopped is True assert "stopped state" in result.stderr child_pid = int(pid_file.read_text(encoding="ascii")) for _ in range(20): diff --git a/tests/service/hooks/test_runner_capture.py b/tests/service/hooks/test_runner_capture.py index c78a92a3a..5c5cf07cc 100644 --- a/tests/service/hooks/test_runner_capture.py +++ b/tests/service/hooks/test_runner_capture.py @@ -76,7 +76,7 @@ async def test_a_timed_out_run_keeps_what_it_printed_before_the_timeout(runner: """ ) result = await runner.run_and_wait(_hook(script, timeout=1.0), {}) - assert result.timed_out is True + assert (result.timed_out, result.stopped) == (True, False) assert "stdout before the timeout" in result.stdout assert "stderr before the timeout" in result.stderr @@ -100,7 +100,8 @@ async def test_a_run_whose_process_stopped_keeps_what_it_printed(runner: HookRun """ ) result = await runner.run_and_wait(_hook(script, timeout=10), {}) - assert result.timed_out is True + # Named apart from a timeout, which it is ended as. + assert (result.timed_out, result.stopped) == (True, True) assert "printed before the stop" in result.stdout assert "warned before the stop" in result.stderr