Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions packages/claude-code-plugin/hooks/lib/hud_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,36 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]:

Cleared fields (so the HUD renders a safe default):

- ``currentMode`` → ``None`` (statusLine shows "Ready")
- ``version`` → ``""`` (hud_version falls back to plugin.json)
- ``activeAgent`` → ``None``
- ``phase`` → ``"ready"``
- ``focus`` → ``None``
- ``blockerCount``→ ``0``
- ``currentMode`` → ``None`` (statusLine shows "Ready")
- ``version`` → ``""`` (hud_version falls back to plugin.json)
- ``activeAgent`` → ``None``
- ``phase`` → ``"ready"``
- ``focus`` → ``None``
- ``blockerCount`` → ``0``
- ``sessionStartTimestamp`` → ``""`` (Wave 1-B duration-leak fix)

Preserved fields:

- ``sessionId`` (so debugging can see what was there)
- ``sessionStartTimestamp`` (for audit / forensics)
- Any other field not listed above

Forensics field (Wave 1-B fix — #1326):

- ``_healedFromSessionStartTimestamp`` — retains the original
``sessionStartTimestamp`` value when one was present. Earlier
revisions preserved ``sessionStartTimestamp`` in place for
"audit / forensics", but ``resolve_duration`` in
codingbuddy-hud.py uses that same field as a fallback when
stdin lacks ``total_duration_ms``. A stale timestamp therefore
rendered huge durations like ``322h52m`` for brand-new
sessions. Relocating into a ``_healed…`` field keeps the
debug value while pulling the render-path fallback out of
the line of fire.

Idempotence: re-healing an already-healed state keeps the
forensics field stable — we only move a non-empty timestamp,
so the second pass sees ``sessionStartTimestamp == ""`` and
leaves ``_healedFromSessionStartTimestamp`` alone.
"""
healed: Dict[str, Any] = dict(state)
healed["currentMode"] = None
Expand All @@ -123,6 +141,16 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]:
healed["phase"] = "ready"
healed["focus"] = None
healed["blockerCount"] = 0

# Wave 1-B fix: move sessionStartTimestamp out of the render
# fallback path and into a forensics field. Only capture a
# non-empty value so repeated heals do not overwrite a
# previously preserved forensic timestamp with "".
original_ts = healed.get("sessionStartTimestamp", "") or ""
if original_ts:
healed["_healedFromSessionStartTimestamp"] = original_ts
healed["sessionStartTimestamp"] = ""

return healed


Expand Down
54 changes: 54 additions & 0 deletions packages/claude-code-plugin/tests/test_hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,60 @@ def test_no_data_returns_zero(self):
assert hud.resolve_duration({}, {}) == "0m"


class TestHealedStateDurationDoesNotLeak:
"""Wave 1-B regression (#1326): format_status_line must not render a
stale ``sessionStartTimestamp`` as duration after a self-heal.

Prior bug reproduction:
echo '{}' | python3 codingbuddy-hud.py
→ ◕‿◕ CB v5.6.0 | Ready 🟢 | 322h52m | ~$0.00 | Ctx:0%

Root cause: ``heal_stale_state`` preserved ``sessionStartTimestamp``
for "audit / forensics", but ``resolve_duration`` read the same
field as a fallback when stdin had no ``total_duration_ms``.
Fix: timestamp is relocated into ``_healedFromSessionStartTimestamp``.
"""

_NO_PLUGINS = "/tmp/codingbuddy-heal-test-nonexistent-plugins.json"

def test_healed_state_renders_0m_duration(self):
# Simulate the on-disk stale state that triggered the bug:
# a manual-fix repair marker with a weeks-old timestamp.
stale = {
"sessionId": "manual-fix",
"sessionStartTimestamp": "2026-03-29T04:10:47+00:00",
"currentMode": "ACT",
"version": "5.2.0",
}
from hud_session import heal_stale_state # noqa: E402
healed = heal_stale_state(stale)

# stdin has no total_duration_ms, so resolve_duration must
# NOT fall back onto the old sessionStartTimestamp.
output = hud.format_status_line(
{"session_id": "brand-new"},
healed,
plugins_file=self._NO_PLUGINS,
)

assert "322h" not in output, (
f"healed state leaked stale duration into output: {output!r}"
)
assert " 0m " in output or output.endswith(" 0m") or "| 0m |" in output, (
f"healed state should render '0m' duration, got: {output!r}"
)

def test_healed_state_preserves_forensics_field(self):
# The forensics field is the mechanism we rely on — guard it
# with a direct assertion so a regression here is caught here,
# not in test_hud_session.py only.
from hud_session import heal_stale_state # noqa: E402
stale = {"sessionId": "manual-fix", "sessionStartTimestamp": "2026-03-29T04:10:47+00:00"}
healed = heal_stale_state(stale)
assert healed["sessionStartTimestamp"] == ""
assert healed["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00"


class TestResolveAgent:
def test_stdin_agent_preferred(self):
stdin = {"agent": {"name": "security-reviewer"}}
Expand Down
47 changes: 44 additions & 3 deletions packages/claude-code-plugin/tests/test_hud_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,16 +154,57 @@ def test_heal_clears_ephemeral_fields():
assert healed["blockerCount"] == 0


def test_heal_preserves_session_id_and_timestamp():
"""heal_stale_state keeps sessionId and sessionStartTimestamp intact."""
def test_heal_preserves_session_id_and_moves_timestamp_to_forensics():
"""heal_stale_state keeps sessionId but relocates sessionStartTimestamp.

Historical note: an earlier version of this function preserved
``sessionStartTimestamp`` verbatim for "audit / forensics". That
caused the Wave 1-B duration-leak bug — ``resolve_duration`` in
codingbuddy-hud.py uses ``sessionStartTimestamp`` as a fallback
when stdin has no ``total_duration_ms``, so a healed (but
timestamp-retaining) state rendered enormous durations
(e.g., ``322h52m``) for brand-new sessions.

The fix: relocate the timestamp into ``_healedFromSessionStartTimestamp``
so forensic value is preserved for debuggers/tests while the render
fallback path no longer sees it.
"""
state = {
"sessionId": "abc-123",
"sessionStartTimestamp": "2026-04-01T00:00:00+00:00",
"currentMode": "ACT",
}
healed = hud_session.heal_stale_state(state)
assert healed["sessionId"] == "abc-123"
assert healed["sessionStartTimestamp"] == "2026-04-01T00:00:00+00:00"
# Render fallback path must not see the stale timestamp
assert healed["sessionStartTimestamp"] == ""
# Forensic value is preserved for post-mortem debugging
assert healed["_healedFromSessionStartTimestamp"] == "2026-04-01T00:00:00+00:00"


def test_heal_without_timestamp_does_not_add_forensics_field():
"""If there is no sessionStartTimestamp to heal, no forensics field is added."""
state = {"sessionId": "abc-123", "currentMode": "ACT"}
healed = hud_session.heal_stale_state(state)
assert healed["sessionStartTimestamp"] == ""
assert "_healedFromSessionStartTimestamp" not in healed


def test_heal_is_idempotent_on_forensics_field():
"""Re-healing a healed state preserves the forensics timestamp stably.

Guards against a naive implementation that would overwrite
``_healedFromSessionStartTimestamp`` with the empty string on
the second pass, losing the original value.
"""
stale = {
"sessionId": "manual-fix",
"sessionStartTimestamp": "2026-03-29T04:10:47+00:00",
}
once = hud_session.heal_stale_state(stale)
twice = hud_session.heal_stale_state(once)
assert twice["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00"
assert twice["sessionStartTimestamp"] == ""


def test_heal_does_not_mutate_input():
Expand Down
Loading