From 4ca6703c3469b128ef5225746fe29a4d9a4e8d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 6 Nov 2026 02:53:00 -0800 Subject: [PATCH] fix(agents): read the self-test's log from its byte offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev agents test recorded the hooks log size in bytes and then sliced the decoded text at that offset. With multi-byte characters in earlier lines (the guard's messages use … → ·), the slice skipped the new lines, so the live test reported the PreToolUse guard as silent while it had refused the test branch. The canary check had the same flaw. Both read the appended bytes now; the live test passes 9 of 9. --- CHANGELOG.md | 7 +++++++ lib/devkit/providers.py | 13 +++++++++++-- tests/test_quota.py | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4a281..b6ecb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to devkit. Versions follow CalVer `YY.MM.PATCH` (PATCH restarts at 0 each month); tags are `vYY.MM.PATCH`. Sections follow [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Fixed + +- `dev agents test` reads the hooks log from the byte offset it recorded, so the guard check no longer reports + "no guard decision logged" when earlier lines hold multi-byte characters. + ## [26.11.1] - 2026-11-06 devkit is open source under the Apache License 2.0. diff --git a/lib/devkit/providers.py b/lib/devkit/providers.py index f9b38ac..addc70c 100644 --- a/lib/devkit/providers.py +++ b/lib/devkit/providers.py @@ -25,6 +25,15 @@ "3. Say whether you received any workspace context when the session started.") + +def _appended(path: Path, offset: int) -> str: + """The text written to `path` after it was `offset` bytes long. The offset is a byte count (stat), so the slice is + taken on bytes: slicing the decoded text would skip new lines whenever earlier lines hold multi-byte characters.""" + try: + return path.read_bytes()[offset:].decode("utf-8", "replace") + except OSError: + return "" + def _claude_status() -> dict: out = {"installed": core.have("claude"), "name": "Claude Code"} if not out["installed"]: @@ -181,10 +190,10 @@ def selftest(agent: str, model: str = None) -> dict: sess = STATE / "sessions" / f"{agent}-{sid}.json" if sid else None checks.append(("SessionStart hook (context)", bool(sess and sess.exists()), "session recorded by devkit" if sess and sess.exists() else "no session record")) - new_log = log.read_text()[log_before:] if log.exists() else "" + new_log = _appended(log, log_before) blocked = f"{agent}/devkit-selftest" in new_log and f"guard {agent} deny" in new_log checks.append(("PreToolUse guard", blocked, "tool-prefixed branch blocked" if blocked else "no guard decision logged")) - new_canary = canary_f.read_text()[canary_before:] if canary_f.exists() else "" + new_canary = _appended(canary_f, canary_before) recs = [json.loads(ln) for ln in new_canary.splitlines() if ln.strip()] mine = [r for r in recs if sid and r.get("session") == sid[:12]] checks.append(("Stop hook + instructions (canary)", bool(mine) and all(r["ok"] for r in mine), diff --git a/tests/test_quota.py b/tests/test_quota.py index c8dd73a..bb241fb 100644 --- a/tests/test_quota.py +++ b/tests/test_quota.py @@ -64,5 +64,19 @@ def test_no_rate_limits_still_draws_a_line(self): self.assertTrue(self._line({"model": {"display_name": "Claude Sonnet 5.5"}}).endswith("Sonnet 5.5")) + +class SelfTestLogTests(unittest.TestCase): + def test_reads_exactly_what_was_appended_after_multibyte_text(self): + from devkit import providers + with tempfile.TemporaryDirectory() as d: + log = Path(d) / "hooks.log" + log.write_text("guard claude deny: `cp` would overwrite … → use `dev cp` · ✓\n" * 50) + mark = log.stat().st_size # bytes, as the self-test records it + with log.open("a") as fh: + fh.write("guard claude deny: Branch `claude/devkit-selftest` is prefixed\n") + self.assertEqual(providers._appended(log, mark), + "guard claude deny: Branch `claude/devkit-selftest` is prefixed\n") + self.assertEqual(providers._appended(Path(d) / "missing.log", 0), "") + if __name__ == "__main__": unittest.main()