forked from Rockachopa/Timmy-time-dashboard
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user