From de64d5ebe720f6dd15eebaa07afbf11a45e8aaf1 Mon Sep 17 00:00:00 2001 From: "Jiaqi (0x7c13) Liu" Date: Sat, 24 Oct 2026 08:52:11 +0800 Subject: [PATCH] fix(hooks): keep a path's lone surrogates when a run log text is cut The Hooks panel merged from the branch one push before its last, so a cut of changed arguments or a block reason past 64 KB still decoded with "ignore": it dropped every lone surrogate of the text (what a path that is not UTF-8 holds), not only the character the cut splits. The cut now backs up to the first byte of the character the limit splits and decodes the rest with "surrogatepass". The retention test checks, for every unit a lone surrogate included, that the kept text starts the full text and ends within 4 bytes of the limit. A trailing wait in the reporting test that could not fail is removed. --- src/chrys/service/hooks/run_log.py | 9 +++++++-- tests/orchestration/engine/test_hook_run_reporting.py | 3 +-- tests/service/hooks/test_run_log_retention.py | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/chrys/service/hooks/run_log.py b/src/chrys/service/hooks/run_log.py index 1a26b108c..3f660c273 100644 --- a/src/chrys/service/hooks/run_log.py +++ b/src/chrys/service/hooks/run_log.py @@ -107,14 +107,19 @@ def indented_json(value: object) -> str: 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, and every lone surrogate of the text. + 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) - return data[:limit].decode("utf-8", "ignore"), 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: 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/service/hooks/test_run_log_retention.py b/tests/service/hooks/test_run_log_retention.py index 42503b90a..40cbc69ed 100644 --- a/tests/service/hooks/test_run_log_retention.py +++ b/tests/service/hooks/test_run_log_retention.py @@ -541,10 +541,10 @@ def test_changed_arguments_past_the_cap_are_cut_with_their_size_kept(unit: str) detail = log.detail("rewrite") assert detail is not None and detail.applied is not None kept = detail.applied.args - assert len(kept.encode("utf-8", "surrogatepass")) <= HOOK_RUN_TEXT_MAX_BYTES + # 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")) - if unit != "\udc80": - assert full.startswith(kept) + assert full.startswith(kept) def test_changed_arguments_within_the_cap_are_kept_whole() -> None: