rescue: WS heartbeat ping + commitment tracking from stale PRs (#415)

## What
Manually integrated unique code from two stale PRs that were **not** superseded by merged work.

### PR #399 (kimi/issue-362) — WebSocket heartbeat ping
- 15-second ping loop detects dead iPad/Safari connections
- `_heartbeat()` coroutine launched as background task per WS client
- `ping_task` properly cancelled on disconnect

### PR #408 (kimi/issue-322) — Conversation commitment tracking
- Regex extraction of commitments from Timmy replies (`I'll` / `I will` / `Let me`)
- `_record_commitments()` stores with dedup + cap at 10
- `_tick_commitments()` increments message counter per commitment
- `_build_commitment_context()` surfaces overdue commitments as grounding context
- Wired into `_bark_and_broadcast()` and `_generate_bark()`
- Public API: `get_commitments()`, `close_commitment()`, `reset_commitments()`

### Tests
22 new tests covering both features: extraction, recording, dedup, caps, tick/context, integration, heartbeat ping, dead connection handling.

---
This PR rescues unique code from stale PRs #399 and #408. The other two stale PRs (#402, #411) were already superseded by merged work and should be closed.

Co-authored-by: Perplexity Computer <perplexity@tower.dev>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/415
Co-authored-by: Perplexity Computer <perplexity@tower.local>
Co-committed-by: Perplexity Computer <perplexity@tower.local>
This commit is contained in:
2026-03-19 03:22:44 -04:00
committed by rockachopa
parent 63e4542f31
commit 76b26ead55
2 changed files with 323 additions and 2 deletions

View File

@@ -7,19 +7,31 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import asyncio
from dashboard.routes.world import (
_GROUND_TTL,
_REMIND_AFTER,
_STALE_THRESHOLD,
_bark_and_broadcast,
_broadcast,
_build_commitment_context,
_build_world_state,
_commitments,
_conversation,
_extract_commitments,
_generate_bark,
_handle_client_message,
_heartbeat,
_log_bark_failure,
_read_presence_file,
_record_commitments,
_refresh_ground,
_tick_commitments,
broadcast_world_state,
close_commitment,
get_commitments,
reset_commitments,
reset_conversation_ground,
)
@@ -506,3 +518,206 @@ class TestConversationGrounding:
finally:
_ws_clients.clear()
_conversation.clear()
# ---------------------------------------------------------------------------
# Conversation grounding — commitment tracking (rescued from PR #408)
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=False)
def _clean_commitments():
"""Reset commitments before and after each commitment test."""
reset_commitments()
yield
reset_commitments()
class TestExtractCommitments:
def test_extracts_ill_pattern(self):
text = "I'll draft the skeleton ticket in 30 minutes."
result = _extract_commitments(text)
assert len(result) == 1
assert "draft the skeleton ticket" in result[0]
def test_extracts_i_will_pattern(self):
result = _extract_commitments("I will review that PR tomorrow.")
assert len(result) == 1
assert "review that PR tomorrow" in result[0]
def test_extracts_let_me_pattern(self):
result = _extract_commitments("Let me write up a summary for you.")
assert len(result) == 1
assert "write up a summary" in result[0]
def test_skips_short_matches(self):
result = _extract_commitments("I'll do it.")
# "do it" is 5 chars — should be skipped (needs > 5)
assert result == []
def test_no_commitments_in_normal_text(self):
result = _extract_commitments("The weather is nice today.")
assert result == []
def test_truncates_long_commitments(self):
long_phrase = "a" * 200
result = _extract_commitments(f"I'll {long_phrase}.")
assert len(result) == 1
assert len(result[0]) == 120
class TestRecordCommitments:
def test_records_new_commitment(self, _clean_commitments):
_record_commitments("I'll draft the ticket now.")
assert len(get_commitments()) == 1
assert get_commitments()[0]["messages_since"] == 0
def test_avoids_duplicate_commitments(self, _clean_commitments):
_record_commitments("I'll draft the ticket now.")
_record_commitments("I'll draft the ticket now.")
assert len(get_commitments()) == 1
def test_caps_at_max(self, _clean_commitments):
from dashboard.routes.world import _MAX_COMMITMENTS
for i in range(_MAX_COMMITMENTS + 3):
_record_commitments(f"I'll handle commitment number {i} right away.")
assert len(get_commitments()) <= _MAX_COMMITMENTS
class TestTickAndContext:
def test_tick_increments_messages_since(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
_tick_commitments()
_tick_commitments()
assert _commitments[0]["messages_since"] == 2
def test_context_empty_when_no_overdue(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
assert _build_commitment_context() == ""
def test_context_surfaces_overdue_commitments(self, _clean_commitments):
_commitments.append(
{
"text": "draft the skeleton ticket",
"created_at": 0,
"messages_since": _REMIND_AFTER,
}
)
ctx = _build_commitment_context()
assert "draft the skeleton ticket" in ctx
assert "Open commitments" in ctx
def test_context_only_includes_overdue(self, _clean_commitments):
_commitments.append({"text": "recent thing", "created_at": 0, "messages_since": 1})
_commitments.append(
{
"text": "old thing",
"created_at": 0,
"messages_since": _REMIND_AFTER,
}
)
ctx = _build_commitment_context()
assert "old thing" in ctx
assert "recent thing" not in ctx
class TestCloseCommitment:
def test_close_valid_index(self, _clean_commitments):
_commitments.append({"text": "write the docs", "created_at": 0, "messages_since": 0})
assert close_commitment(0) is True
assert len(get_commitments()) == 0
def test_close_invalid_index(self, _clean_commitments):
assert close_commitment(99) is False
class TestGroundingIntegration:
@pytest.mark.asyncio
async def test_bark_records_commitments_from_reply(self, _clean_commitments):
from dashboard.routes.world import _ws_clients
ws = AsyncMock()
_ws_clients.append(ws)
_conversation.clear()
try:
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="I'll draft the ticket for you!",
):
await _bark_and_broadcast("Can you help?")
assert len(get_commitments()) == 1
assert "draft the ticket" in get_commitments()[0]["text"]
finally:
_ws_clients.clear()
_conversation.clear()
@pytest.mark.asyncio
async def test_bark_prepends_context_after_n_messages(self, _clean_commitments):
"""After _REMIND_AFTER messages, commitment context is prepended."""
_commitments.append(
{
"text": "draft the skeleton ticket",
"created_at": 0,
"messages_since": _REMIND_AFTER - 1,
}
)
with patch(
"timmy.session.chat",
new_callable=AsyncMock,
return_value="Sure thing!",
) as mock_chat:
# This tick will push messages_since to _REMIND_AFTER
await _generate_bark("Any updates?")
# _generate_bark doesn't tick — _bark_and_broadcast does.
# But we pre-set messages_since to _REMIND_AFTER - 1,
# so we need to tick once to make it overdue.
_tick_commitments()
await _generate_bark("Any updates?")
# Second call should have context prepended
last_call = mock_chat.call_args_list[-1]
sent_text = last_call[0][0]
assert "draft the skeleton ticket" in sent_text
assert "Open commitments" in sent_text
# ---------------------------------------------------------------------------
# WebSocket heartbeat ping (rescued from PR #399)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_heartbeat_sends_ping():
"""Heartbeat sends a ping JSON frame after the interval elapses."""
ws = AsyncMock()
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
# Let the first sleep complete, then raise to exit the loop
call_count = 0
async def sleep_side_effect(_interval):
nonlocal call_count
call_count += 1
if call_count > 1:
raise ConnectionError("stop")
mock_sleep.side_effect = sleep_side_effect
await _heartbeat(ws)
ws.send_text.assert_called_once()
msg = json.loads(ws.send_text.call_args[0][0])
assert msg["type"] == "ping"
@pytest.mark.asyncio
async def test_heartbeat_exits_on_dead_connection():
"""Heartbeat exits cleanly when the WebSocket is dead."""
ws = AsyncMock()
ws.send_text.side_effect = ConnectionError("gone")
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock):
await _heartbeat(ws) # should not raise