File size: 9,153 Bytes
b5b9c2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""Tests for hermes_cli.logs — log viewing and filtering."""

import os
from datetime import datetime, timedelta
from pathlib import Path

import pytest

from hermes_cli.logs import (
    LOG_FILES,
    _extract_level,
    _extract_logger_name,
    _line_matches_component,
    _matches_filters,
    _parse_line_timestamp,
    _parse_since,
    _read_last_n_lines,
    _read_tail,
)


# ---------------------------------------------------------------------------
# Timestamp parsing
# ---------------------------------------------------------------------------

class TestParseSince:
    def test_hours(self):
        cutoff = _parse_since("2h")
        assert cutoff is not None
        assert abs((datetime.now() - cutoff).total_seconds() - 7200) < 2

    def test_minutes(self):
        cutoff = _parse_since("30m")
        assert cutoff is not None
        assert abs((datetime.now() - cutoff).total_seconds() - 1800) < 2

    def test_days(self):
        cutoff = _parse_since("1d")
        assert cutoff is not None
        assert abs((datetime.now() - cutoff).total_seconds() - 86400) < 2

    def test_seconds(self):
        cutoff = _parse_since("120s")
        assert cutoff is not None
        assert abs((datetime.now() - cutoff).total_seconds() - 120) < 2

    def test_invalid_returns_none(self):
        assert _parse_since("abc") is None
        assert _parse_since("") is None
        assert _parse_since("10x") is None

    def test_whitespace_tolerance(self):
        cutoff = _parse_since("  5m  ")
        assert cutoff is not None


class TestParseLineTimestamp:
    def test_standard_format(self):
        ts = _parse_line_timestamp("2026-04-11 10:23:45 INFO gateway.run: msg")
        assert ts == datetime(2026, 4, 11, 10, 23, 45)

    def test_no_timestamp(self):
        assert _parse_line_timestamp("no timestamp here") is None


class TestExtractLevel:
    def test_info(self):
        assert _extract_level("2026-01-01 00:00:00 INFO gateway.run: msg") == "INFO"

    def test_warning(self):
        assert _extract_level("2026-01-01 00:00:00 WARNING tools.file: msg") == "WARNING"

    def test_error(self):
        assert _extract_level("2026-01-01 00:00:00 ERROR run_agent: msg") == "ERROR"

    def test_debug(self):
        assert _extract_level("2026-01-01 00:00:00 DEBUG agent.aux: msg") == "DEBUG"

    def test_no_level(self):
        assert _extract_level("random text") is None


# ---------------------------------------------------------------------------
# Logger name extraction (new for component filtering)
# ---------------------------------------------------------------------------

class TestExtractLoggerName:
    def test_standard_line(self):
        line = "2026-04-11 10:23:45 INFO gateway.run: Starting gateway"
        assert _extract_logger_name(line) == "gateway.run"

    def test_nested_logger(self):
        line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: connected"
        assert _extract_logger_name(line) == "gateway.platforms.telegram"

    def test_warning_level(self):
        line = "2026-04-11 10:23:45 WARNING tools.terminal_tool: timeout"
        assert _extract_logger_name(line) == "tools.terminal_tool"

    def test_with_session_tag(self):
        line = "2026-04-11 10:23:45 INFO [abc123] tools.file_tools: reading file"
        assert _extract_logger_name(line) == "tools.file_tools"

    def test_with_session_tag_and_error(self):
        line = "2026-04-11 10:23:45 ERROR [sess_xyz] agent.context_compressor: failed"
        assert _extract_logger_name(line) == "agent.context_compressor"

    def test_top_level_module(self):
        line = "2026-04-11 10:23:45 INFO run_agent: starting conversation"
        assert _extract_logger_name(line) == "run_agent"

    def test_no_match(self):
        assert _extract_logger_name("random text") is None


class TestLineMatchesComponent:
    def test_gateway_component(self):
        line = "2026-04-11 10:23:45 INFO gateway.run: msg"
        assert _line_matches_component(line, ("gateway",))

    def test_gateway_nested(self):
        line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: msg"
        assert _line_matches_component(line, ("gateway",))

    def test_tools_component(self):
        line = "2026-04-11 10:23:45 INFO tools.terminal_tool: msg"
        assert _line_matches_component(line, ("tools",))

    def test_agent_with_multiple_prefixes(self):
        prefixes = ("agent", "run_agent", "model_tools")
        assert _line_matches_component(
            "2026-04-11 10:23:45 INFO agent.context_compressor: msg", prefixes)
        assert _line_matches_component(
            "2026-04-11 10:23:45 INFO run_agent: msg", prefixes)
        assert _line_matches_component(
            "2026-04-11 10:23:45 INFO model_tools: msg", prefixes)

    def test_no_match(self):
        line = "2026-04-11 10:23:45 INFO tools.browser: msg"
        assert not _line_matches_component(line, ("gateway",))

    def test_with_session_tag(self):
        line = "2026-04-11 10:23:45 INFO [abc] gateway.run: msg"
        assert _line_matches_component(line, ("gateway",))

    def test_unparseable_line(self):
        assert not _line_matches_component("random text", ("gateway",))


# ---------------------------------------------------------------------------
# Combined filter
# ---------------------------------------------------------------------------

class TestMatchesFilters:
    def test_no_filters_passes_everything(self):
        assert _matches_filters("any line")

    def test_level_filter(self):
        assert _matches_filters(
            "2026-01-01 00:00:00 WARNING x: msg", min_level="WARNING")
        assert not _matches_filters(
            "2026-01-01 00:00:00 INFO x: msg", min_level="WARNING")

    def test_session_filter(self):
        assert _matches_filters(
            "2026-01-01 00:00:00 INFO [abc123] x: msg", session_filter="abc123")
        assert not _matches_filters(
            "2026-01-01 00:00:00 INFO [xyz789] x: msg", session_filter="abc123")

    def test_component_filter(self):
        assert _matches_filters(
            "2026-01-01 00:00:00 INFO gateway.run: msg",
            component_prefixes=("gateway",))
        assert not _matches_filters(
            "2026-01-01 00:00:00 INFO tools.file: msg",
            component_prefixes=("gateway",))

    def test_combined_filters(self):
        """All filters must pass for a line to match."""
        line = "2026-04-11 10:00:00 WARNING [sess_1] gateway.run: connection lost"
        assert _matches_filters(
            line,
            min_level="WARNING",
            session_filter="sess_1",
            component_prefixes=("gateway",),
        )
        # Fails component filter
        assert not _matches_filters(
            line,
            min_level="WARNING",
            session_filter="sess_1",
            component_prefixes=("tools",),
        )

    def test_since_filter(self):
        # Line with a very old timestamp should be filtered out
        assert not _matches_filters(
            "2020-01-01 00:00:00 INFO x: old msg",
            since=datetime.now() - timedelta(hours=1))
        # Line with a recent timestamp should pass
        recent = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        assert _matches_filters(
            f"{recent} INFO x: recent msg",
            since=datetime.now() - timedelta(hours=1))


# ---------------------------------------------------------------------------
# File reading
# ---------------------------------------------------------------------------

class TestReadTail:
    def test_read_small_file(self, tmp_path):
        log_file = tmp_path / "test.log"
        lines = [f"2026-01-01 00:00:0{i} INFO x: line {i}\n" for i in range(10)]
        log_file.write_text("".join(lines))

        result = _read_last_n_lines(log_file, 5)
        assert len(result) == 5
        assert "line 9" in result[-1]

    def test_read_with_component_filter(self, tmp_path):
        log_file = tmp_path / "test.log"
        lines = [
            "2026-01-01 00:00:00 INFO gateway.run: gw msg\n",
            "2026-01-01 00:00:01 INFO tools.file: tool msg\n",
            "2026-01-01 00:00:02 INFO gateway.session: session msg\n",
            "2026-01-01 00:00:03 INFO agent.compressor: agent msg\n",
        ]
        log_file.write_text("".join(lines))

        result = _read_tail(
            log_file, 50,
            has_filters=True,
            component_prefixes=("gateway",),
        )
        assert len(result) == 2
        assert "gw msg" in result[0]
        assert "session msg" in result[1]

    def test_empty_file(self, tmp_path):
        log_file = tmp_path / "empty.log"
        log_file.write_text("")
        result = _read_last_n_lines(log_file, 10)
        assert result == []


# ---------------------------------------------------------------------------
# LOG_FILES registry
# ---------------------------------------------------------------------------

class TestLogFiles:
    def test_known_log_files(self):
        assert "agent" in LOG_FILES
        assert "errors" in LOG_FILES
        assert "gateway" in LOG_FILES