From b3d7c7ce9fc74ede565ca1110ee598c8027b9cfc Mon Sep 17 00:00:00 2001 From: Simon Eisenhauer Date: Fri, 31 Jul 2026 00:05:16 +0200 Subject: [PATCH 1/2] test(sources): cover ended_at across every backend --- tests/_support.py | 5 +- tests/test_stores_cached.py | 101 ++++++++++++++++++++++++++++++++++ tests/test_stores_claude.py | 37 +++++++++++++ tests/test_stores_codex.py | 66 ++++++++++++++++++++++ tests/test_stores_copilot.py | 21 +++++++ tests/test_stores_csv.py | 55 ++++++++++++++++++ tests/test_stores_hermes.py | 69 +++++++++++++++++++++++ tests/test_stores_openclaw.py | 24 ++++++++ tests/test_stores_opencode.py | 100 +++++++++++++++++++++++++++++++++ tests/test_stores_pi.py | 27 +++++++++ tests/test_stores_remote.py | 18 ++++++ tests/test_stores_vscode.py | 33 +++++++++++ tests/test_stores_zaly.py | 26 +++++++++ 13 files changed, 581 insertions(+), 1 deletion(-) diff --git a/tests/_support.py b/tests/_support.py index a90783a..ba3f3d7 100644 --- a/tests/_support.py +++ b/tests/_support.py @@ -7,7 +7,9 @@ import opentab as ot -def workflow(id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/project"): +def workflow( + id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/project", ended_at="" +): return ot.Workflow( id=id, title=title or id, @@ -19,6 +21,7 @@ def workflow(id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/p model_count=1, total_tokens=tokens, unpriced_tokens=0, + ended_at=ended_at, ) diff --git a/tests/test_stores_cached.py b/tests/test_stores_cached.py index c317c61..80e9207 100644 --- a/tests/test_stores_cached.py +++ b/tests/test_stores_cached.py @@ -154,6 +154,107 @@ def model_breakdown(self): os.environ["XDG_CACHE_HOME"] = old_xdg +def test_cached_store_round_trips_ended_at(): + with tempfile.TemporaryDirectory() as tmp: + old_xdg = os.environ.get("XDG_CACHE_HOME") + os.environ["XDG_CACHE_HOME"] = os.path.join(tmp, "cfg") # isolate the cache dir + data = os.path.join(tmp, "data.jsonl") + with open(data, "w") as fh: + fh.write("one\n") + + class Backend: + combined = False + records_cost = False + demo = False + source_name = "Fake" + + def cache_inputs(self): + return [data] + + def workflows(self): + return [workflow("s1", "2026-06-01 12:00:00", ended_at="2026-06-01 15:45:00")] + + def model_breakdown(self): + return [] + + args = type("Args", (), {"demo": False, "no_cache": False})() + cid = "fake|" + data + try: + # Cold: writes the cache with ended_at in the payload (the write itself + # is triggered from model_breakdown(), which needs both fresh rollups). + c1 = ot.CachedStore(Backend(), cid, args) + assert c1.workflows()[0].ended_at == "2026-06-01 15:45:00" + c1.model_breakdown() + + # Warm: served from the cached JSON, ended_at survives the round trip. + c2 = ot.CachedStore(Backend(), cid, args) + wf2 = c2.workflows() + assert c2.served_from_cache is True + assert wf2[0].ended_at == "2026-06-01 15:45:00" + finally: + if old_xdg is None: + os.environ.pop("XDG_CACHE_HOME", None) + else: + os.environ["XDG_CACHE_HOME"] = old_xdg + + +def test_cached_store_invalidates_a_stale_cache_version(): + # A cache file written by an older opentab carries an older CACHE_VERSION; that + # mismatch alone must force a real re-parse rather than silently serving rows + # shaped for the old version until some unrelated file edit. + with tempfile.TemporaryDirectory() as tmp: + old_xdg = os.environ.get("XDG_CACHE_HOME") + os.environ["XDG_CACHE_HOME"] = os.path.join(tmp, "cfg") # isolate the cache dir + data = os.path.join(tmp, "data.jsonl") + with open(data, "w") as fh: + fh.write("one\n") + + class Backend: + combined = False + records_cost = False + demo = False + source_name = "Fake" + + def __init__(self): + self.workflow_calls = 0 + + def cache_inputs(self): + return [data] + + def workflows(self): + self.workflow_calls += 1 + return [workflow("s1", "2026-06-01 12:00:00")] + + def model_breakdown(self): + return [] + + args = type("Args", (), {"demo": False, "no_cache": False})() + cid = "fake|" + data + try: + b1 = Backend() + c1 = ot.CachedStore(b1, cid, args) + c1.workflows() + c1.model_breakdown() # triggers the actual disk write + assert b1.workflow_calls == 1 + + with open(c1._path) as fh: + payload = json.load(fh) + payload["version"] = ot.stores.cached.CACHE_VERSION - 1 + with open(c1._path, "w") as fh: + json.dump(payload, fh) + + b2 = Backend() + c2 = ot.CachedStore(b2, cid, args) + c2.workflows() + assert b2.workflow_calls == 1 # a miss, not served from the stale-shaped cache + assert c2.served_from_cache is False + finally: + if old_xdg is None: + os.environ.pop("XDG_CACHE_HOME", None) + else: + os.environ["XDG_CACHE_HOME"] = old_xdg + + def test_cache_invalidates_on_wal_write_so_reload_sees_new_opencode_sessions(): # OpenCode runs SQLite in WAL mode, so a new session lands in -wal while the # main .db's size/mtime don't move until a checkpoint. cache_inputs() must diff --git a/tests/test_stores_claude.py b/tests/test_stores_claude.py index 0dccb4a..1fcc0ba 100644 --- a/tests/test_stores_claude.py +++ b/tests/test_stores_claude.py @@ -4,10 +4,47 @@ import tempfile import opentab as ot +from opentab.formatting import iso_to_local from tests._support import _claude_msg, _usage, _write_jsonl +def test_claude_workflow_ended_at_reflects_the_latest_sidechain_activity(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "projects", "slug") + os.makedirs(root) + cwd = os.path.join(tmp, "repo") + main = _claude_msg( + "s1", + "claude-opus-4-8", + _usage(100, 50, 0, 0), + uuid="u0", + cwd=cwd, + ts="2026-06-10T18:46:00.000Z", + ) + # a subagent (sidechain) turn logged AFTER the main thread's last message -- + # ended_at must reflect it, since the subtree is still active. + side = _claude_msg( + "s1", + "claude-opus-4-8", + _usage(40, 10, 0, 0), + uuid="u1", + cwd=cwd, + parent="u0", + side=True, + ts="2026-06-10T19:10:00.000Z", + ) + _write_jsonl(os.path.join(root, "s1.jsonl"), [main, side]) + + store = ot.ClaudeStore(os.path.join(tmp, "projects"), type("A", (), {"demo": False})()) + w = store.workflows()[0] + + # iso_to_local renders in the system's local TZ, so compare against its own + # conversion of each raw UTC timestamp rather than a hardcoded wall-clock string. + assert w.created_at == iso_to_local("2026-06-10T18:46:00.000Z") + assert w.ended_at == iso_to_local("2026-06-10T19:10:00.000Z") + + def test_claude_message_timeline_orders_by_time_and_marks_sidechain(): with tempfile.TemporaryDirectory() as tmp: root = os.path.join(tmp, "projects", "slug") diff --git a/tests/test_stores_codex.py b/tests/test_stores_codex.py index bbb9677..17b62f6 100644 --- a/tests/test_stores_codex.py +++ b/tests/test_stores_codex.py @@ -4,6 +4,7 @@ import tempfile import opentab as ot +from opentab.formatting import iso_to_local from tests._support import ( FakeStore, @@ -335,3 +336,68 @@ def test_codex_spawned_threads_fold_into_a_subagent_tree(): assert [(r["agent"], r["tokens_total"]) for r in t] == [("-", 1200), ("researcher", 500)] tools = {r["tool"]: r["tokens_total"] for r in store.tool_breakdown(parent_sid)} assert tools == {"update_plan": 1200, "shell_command": 500} + + +def test_codex_ended_at_reflects_the_latest_activity_in_a_spawned_thread(): + # A spawned collab thread logging activity after the parent's own last record + # must bump the parent's ended_at -- the subtree-wide rollup, same as + # ClaudeStore's sidechain-inclusive ts_max. + parent_sid = "11111111-1111-1111-1111-111111111111" + child_sid = "22222222-2222-2222-2222-222222222222" + spawn = { + "subagent": { + "thread_spawn": { + "parent_thread_id": parent_sid, + "agent_nickname": "researcher", + } + } + } + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "sessions") + os.makedirs(root) + cwd = os.path.join(tmp, "repo") + os.makedirs(cwd) + _codex_rollout( + root, + parent_sid, + [ + _codex_meta(parent_sid, cwd, ts="2026-06-10T18:46:00.000Z"), + _codex_turn("gpt-5-codex", cwd, ts="2026-06-10T18:46:05.000Z"), + _codex_tokens(1000, 200, 0, 1200, ts="2026-06-10T18:46:10.000Z"), + ], + ) + _codex_rollout( + root, + child_sid, + [ + _codex_meta(child_sid, cwd, ts="2026-06-10T18:47:00.000Z", source=spawn), + _codex_turn("gpt-5-codex", cwd, ts="2026-06-10T19:10:00.000Z"), + _codex_tokens(400, 100, 0, 500, ts="2026-06-10T19:10:05.000Z"), + ], + ) + store = ot.CodexStore(root, type("Args", (), {"demo": False})()) + w = store.workflows()[0] + + # iso_to_local renders in the system's local TZ, so compare against its own + # conversion of each raw UTC timestamp rather than a hardcoded wall-clock string. + assert w.id == parent_sid + assert w.created_at == iso_to_local("2026-06-10T18:46:00.000Z") + assert w.ended_at == iso_to_local("2026-06-10T19:10:05.000Z") + assert w.ended_at > w.created_at + + +def test_codex_ended_at_falls_back_to_created_at_when_nothing_later(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "sessions") + os.makedirs(root) + cwd = os.path.join(tmp, "repo") + os.makedirs(cwd) + rows = [ + _codex_meta(CODEX_SID, cwd, ts="2026-06-10T18:46:00.000Z"), + _codex_turn("gpt-5-codex", cwd, ts="2026-06-10T18:46:00.000Z"), + _codex_tokens(10, 5, 0, 15, ts="2026-06-10T18:46:00.000Z"), + ] + _codex_rollout(root, CODEX_SID, rows) + store = ot.CodexStore(root, type("Args", (), {"demo": False})()) + w = store.workflows()[0] + assert w.ended_at == w.created_at diff --git a/tests/test_stores_copilot.py b/tests/test_stores_copilot.py index 3e29907..24be418 100644 --- a/tests/test_stores_copilot.py +++ b/tests/test_stores_copilot.py @@ -110,6 +110,27 @@ def test_copilot_store_splits_cache_folds_reasoning_and_stays_unpriced(): assert nodes[0]["cost"] == 0.0 +def test_copilot_ended_at_reflects_the_latest_call_not_the_first(): + with tempfile.TemporaryDirectory() as tmp: + otel = os.path.join(tmp, ".copilot", "otel") + _write_otel( + otel, + [ + _otel_chat( + COPILOT_SID, "gpt-5.4", 100, 50, trace="t1", span="s1", end=(1775934264, 0) + ), + _otel_chat( + COPILOT_SID, "gpt-5.4", 200, 80, trace="t2", span="s2", end=(1775934500, 0) + ), + ], + ) + store = ot.CopilotStore(otel, _copilot_args(otel)) + (w,) = store.workflows() + assert w.created_at == store._ms_to_local(1775934264 * 1000) + assert w.ended_at == store._ms_to_local(1775934500 * 1000) + assert w.ended_at != w.created_at + + def test_copilot_store_dedupes_redundant_records_keeping_chat_span(): with tempfile.TemporaryDirectory() as tmp: otel = os.path.join(tmp, ".copilot", "otel") diff --git a/tests/test_stores_csv.py b/tests/test_stores_csv.py index 1de2dab..30a147b 100644 --- a/tests/test_stores_csv.py +++ b/tests/test_stores_csv.py @@ -84,6 +84,25 @@ def test_csv_groups_by_day_and_project_when_no_session_id(): assert alpha18.total_tokens == 100 + 10 + 200 + 20 # both rows folded together +def test_csv_ended_at_tracks_the_latest_row_in_the_session(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "copilot.csv") + _write_csv( + path, + ["timestamp", "model", "input_tokens", "output_tokens", "session_id"], + [ + ["2026-06-18T10:00:00Z", "gpt-4o", 100, 10, "s1"], + ["2026-06-18T12:00:00Z", "gpt-4o", 50, 5, "s1"], # latest -> ended_at + ["2026-06-18T11:00:00Z", "gpt-4o", 20, 2, "s1"], # out of order, still earlier + ], + ) + store = ot.CsvStore(path, _csv_args()) + w = store.workflows()[0] + assert w.created_at == ot.CsvStore._parse_ts("2026-06-18T10:00:00Z") + assert w.ended_at == ot.CsvStore._parse_ts("2026-06-18T12:00:00Z") + assert w.ended_at != w.created_at + + def test_csv_credits_column_prices_as_real_spend(): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "copilot.csv") @@ -277,6 +296,42 @@ def test_jsonl_store_splits_cache_prefixes_providers_and_supports_turns(): assert re.match(r"2026-06-18 \d\d:\d\d:\d\d$", turns[0]["time"]) +def test_jsonl_ended_at_tracks_the_latest_line_in_the_session(): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "requests.jsonl") + _write_jsonl( + path, + [ + { + "timestamp": "2026-06-18T10:00:00Z", + "session_id": "s1", + "model": "gpt-4o", + "input_tokens": 100, + "output_tokens": 10, + }, + { + "timestamp": "2026-06-18T12:00:00Z", # latest -> ended_at + "session_id": "s1", + "model": "gpt-4o", + "input_tokens": 50, + "output_tokens": 5, + }, + { + "timestamp": "2026-06-18T11:00:00Z", # out of order, still earlier + "session_id": "s1", + "model": "gpt-4o", + "input_tokens": 20, + "output_tokens": 2, + }, + ], + ) + store = ot.JsonlStore(path, _jsonl_args()) + w = store.workflows()[0] + assert w.created_at == ot.JsonlStore._parse_ts("2026-06-18T10:00:00Z") + assert w.ended_at == ot.JsonlStore._parse_ts("2026-06-18T12:00:00Z") + assert w.ended_at != w.created_at + + def test_jsonl_cost_and_credits_price_as_real_spend(): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "requests.jsonl") diff --git a/tests/test_stores_hermes.py b/tests/test_stores_hermes.py index 902fb60..7a831da 100644 --- a/tests/test_stores_hermes.py +++ b/tests/test_stores_hermes.py @@ -255,6 +255,75 @@ def test_hermes_store_rolls_child_session_into_parent_subtotal(): assert root_node["cost"] == 0.0 and child_node["cost"] == 0.0 +def test_hermes_ended_at_reflects_latest_message_in_subtree(): + # ended_at tracks the newest message anywhere in the subtree (root or + # subagent alike), not just the root's own started_at. + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "state.db") + cwd = os.path.join(tmp, "project") + os.makedirs(cwd) + _hermes_db( + db, + [ + { + "id": "root1", + "title": "Root task", + "cwd": cwd, + "started_at": 1750000000.0, + "inp": 10, + "out": 5, + }, + { + "id": "child1", + "parent_id": "root1", + "cwd": cwd, + "started_at": 1750001000.0, + "inp": 20, + "out": 10, + }, + ], + ) + conn = sqlite3.connect(db) + conn.execute( + """CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + timestamp REAL NOT NULL + )""" + ) + conn.executemany( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?,?,?,?)", + [ + ("root1", "user", "go", 1750000000.0), + ("child1", "user", "subagent work", 1750005000.0), # newest, subtree-wide + ], + ) + conn.commit() + conn.close() + + args = type("Args", (), {"demo": False})() + (w,) = ot.HermesStore(db, args).workflows() + assert w.created_at == ot.HermesStore._ts_to_local(1750000000.0) + assert w.ended_at == ot.HermesStore._ts_to_local(1750005000.0) + assert w.ended_at != w.created_at + + +def test_hermes_ended_at_is_blank_without_messages_table(): + # An old/partial state.db without a messages table has no evidence of activity + # beyond the start -- ended_at goes blank (not a same-as-created_at value), so + # the "last_activity" sort's own empty-string check is what falls it back to + # created_at, not a value HermesStore fabricates here. + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "state.db") + _hermes_db(db, [{"id": "s1", "started_at": 1750000000.0, "inp": 10, "out": 5}]) + args = type("Args", (), {"demo": False})() + (w,) = ot.HermesStore(db, args).workflows() + assert w.ended_at == "" + assert w.worked_seconds is None + + def test_hermes_store_rolls_grandchild_session_into_subtotal(): """Depth-2+ sessions must be included in aggregate totals and node list.""" with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_stores_openclaw.py b/tests/test_stores_openclaw.py index 066d836..27f6716 100644 --- a/tests/test_stores_openclaw.py +++ b/tests/test_stores_openclaw.py @@ -30,6 +30,30 @@ def _ocl_oauth(root, profiles): json.dump(data, fh) +def test_openclaw_store_ended_at_reflects_the_latest_assistant_reply(): + with tempfile.TemporaryDirectory() as root: + rows = [ + _ocl_user("go", ts="2026-04-27T16:00:00.000Z"), + _ocl_msg( + "claude-opus-4-6", + 100, + 40, + cost=0.02, + provider="anthropic", + mid="a1", + ts="2026-04-27T16:30:00.000Z", # the latest activity in the session + ), + ] + _ocl_write(root, "finance-os", OCL_SID, rows) + store = ot.OpenClawStore(root, _ocl_args()) + w = store.workflows()[0] + + # _fmt_epoch renders in the system's local TZ, so compare against its own + # conversion of each raw timestamp rather than a hardcoded wall-clock string. + assert w.created_at == store._fmt_epoch(store._epoch("2026-04-27T16:00:00.000Z")) + assert w.ended_at == store._fmt_epoch(store._epoch("2026-04-27T16:30:00.000Z")) + + def test_openclaw_store_meters_cost_splits_cache_and_uses_agent_as_project(): with tempfile.TemporaryDirectory() as root: # A direct-Anthropic-key turn: provider isn't OAuth/plan -> metered, real spend. diff --git a/tests/test_stores_opencode.py b/tests/test_stores_opencode.py index 5a799ac..523776d 100644 --- a/tests/test_stores_opencode.py +++ b/tests/test_stores_opencode.py @@ -276,6 +276,106 @@ def test_store_reads_db_without_session_token_columns(): assert nodes[1]["agent"] == "-" +def test_workflows_ended_at_is_the_latest_update_in_the_subtree(): + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "opencode.db") + conn = sqlite3.connect(db) + conn.executescript( + """ + create table session ( + id text primary key, + parent_id text, + title text, + directory text, + time_created integer, + time_updated integer + ); + create table message (session_id text, data text); + """ + ) + conn.executemany( + "insert into session values (?, ?, ?, ?, ?, ?)", + [ + # root's own time_updated is earlier than its subagent child's -- + # ended_at must reflect the child bumping the whole subtree. + ("root", None, "Root", "/tmp/project", 1760000000000, 1760000001000), + ("child", "root", "Child", "/tmp/project", 1760000000500, 1760000005000), + ], + ) + conn.executemany( + "insert into message values (?, ?)", + [ + ( + "root", + '{"role":"assistant","providerID":"openai","modelID":"gpt-5-mini","cost":1.0,"tokens":{"input":1,"output":1}}', + ), + ( + "child", + '{"role":"assistant","providerID":"openai","modelID":"gpt-5-mini","cost":0,"tokens":{"input":1,"output":1}}', + ), + ], + ) + conn.commit() + conn.close() + + args = type("Args", (), {"demo": False})() + store = ot.Store(db, args) + workflow = store.workflows()[0] + + # localtime rendering is TZ-dependent, so compare against sqlite's own + # conversion of each raw epoch-ms rather than a hardcoded wall-clock string. + conn = sqlite3.connect(db) + expected_created = conn.execute( + "select datetime(1760000000000 / 1000, 'unixepoch', 'localtime')" + ).fetchone()[0] + expected_ended_at = conn.execute( + "select datetime(1760000005000 / 1000, 'unixepoch', 'localtime')" + ).fetchone()[0] + conn.close() + + assert workflow.created_at == expected_created + assert workflow.ended_at == expected_ended_at + assert workflow.ended_at > workflow.created_at + + +def test_workflows_ended_at_is_blank_without_time_updated(): + with tempfile.TemporaryDirectory() as tmp: + db = os.path.join(tmp, "opencode.db") + conn = sqlite3.connect(db) + conn.executescript( + """ + create table session ( + id text primary key, parent_id text, title text, directory text, + time_created integer + ); + create table message (session_id text, data text); + """ + ) + conn.execute( + "insert into session values (?, ?, ?, ?, ?)", + ("root", None, "Root", "/tmp/project", 1760000000000), + ) + conn.execute( + "insert into message values (?, ?)", + ( + "root", + '{"role":"assistant","providerID":"openai","modelID":"gpt-5-mini","cost":1.0,"tokens":{"input":1,"output":1}}', + ), + ) + conn.commit() + conn.close() + + args = type("Args", (), {"demo": False})() + store = ot.Store(db, args) + workflow = store.workflows()[0] + + # Legacy schema: the only signal is the creation time itself, so ended_at is + # blanked rather than reported as a same-as-start value (the "last_activity" + # sort's own empty-string check is what falls it back to created_at, not this + # store). + assert workflow.ended_at == "" + + def test_records_cost_probe_runs_lazily_not_at_construction(): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "requests.jsonl") diff --git a/tests/test_stores_pi.py b/tests/test_stores_pi.py index a175623..d7a7b28 100644 --- a/tests/test_stores_pi.py +++ b/tests/test_stores_pi.py @@ -5,10 +5,37 @@ import tempfile import opentab as ot +from opentab.formatting import iso_to_local from tests._support import PI_SID, _pi_args, _pi_assistant, _pi_session, _pi_user, _pi_write +def test_pi_store_ended_at_reflects_the_latest_assistant_reply(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "sessions") + cwd = os.path.join(tmp, "repo") + os.makedirs(cwd) + rows = [ + _pi_session(PI_SID, cwd, ts="2026-05-15T07:32:15.949Z"), + _pi_user("go", ts="2026-05-15T07:32:20.000Z"), + _pi_assistant( + "anthropic/claude-sonnet-4", + 100, + 50, + cost=0.01, + mid="a1", + ts="2026-05-15T07:40:00.000Z", # the latest activity in the session + ), + ] + _pi_write(root, "--proj--", PI_SID, rows) + w = ot.PiStore(root, _pi_args()).workflows()[0] + + # iso_to_local renders in the system's local TZ, so compare against its own + # conversion of each raw UTC timestamp rather than a hardcoded wall-clock string. + assert w.created_at == iso_to_local("2026-05-15T07:32:15.949Z") + assert w.ended_at == iso_to_local("2026-05-15T07:40:00.000Z") + + def test_pi_store_meters_cost_splits_cache_and_rolls_up_to_git_root(): with tempfile.TemporaryDirectory() as tmp: root = os.path.join(tmp, "sessions") diff --git a/tests/test_stores_remote.py b/tests/test_stores_remote.py index e2ac10b..b326129 100644 --- a/tests/test_stores_remote.py +++ b/tests/test_stores_remote.py @@ -751,6 +751,24 @@ def test_remote_store_workflows_returns_fresh_objects_each_call(): assert store.workflows()[0].total_cost == 5.0 # the next call is pristine, not 999 +def test_remote_store_round_trips_ended_at(): + # ended_at is a plain Workflow field like any other -- asdict()/Workflow(**clean) + # already carry it through generically, with no remote.py-specific plumbing needed. + # An older export simply omits the key, and Workflow's default ("") applies. + with tempfile.TemporaryDirectory() as d: + _write( + d, + "z.json", + _summary( + "z", + [workflow("s1", "2026-07-15 10:00:00", ended_at="2026-07-15 12:30:00")], + ), + ) + store = ot.RemoteStore(d, _parse([])) + w = store.workflows()[0] + assert w.ended_at == "2026-07-15 12:30:00" + + def test_remote_store_excludes_given_ids(): # The fleet passes live-local ids so a pulled summary that re-states one is dropped # (no double count), model rows and all -- the live local copy wins. diff --git a/tests/test_stores_vscode.py b/tests/test_stores_vscode.py index a09d9f6..5fcd307 100644 --- a/tests/test_stores_vscode.py +++ b/tests/test_stores_vscode.py @@ -115,6 +115,39 @@ def test_vscode_store_replays_journal_and_prefers_cumulative_output(): assert nodes[0]["tokens_total"] == 32543 + 490 +def test_vscode_ended_at_reflects_the_latest_request_not_the_first(): + with tempfile.TemporaryDirectory() as tmp: + user, _ = _vscode_user_dir( + tmp, + [ + { + "kind": 0, + "v": { + "version": 3, + "sessionId": VSCODE_SID, + "creationDate": 1781122762688, + "requests": [], + }, + }, + { + "kind": 2, + "k": ["requests"], + "v": [_vscode_request(rid="request_1", ts=1781122800000)], + }, + { + "kind": 2, + "k": ["requests"], + "v": [_vscode_request(rid="request_2", ts=1781200000000)], + }, + ], + ) + store = ot.VscodeStore([user], _vscode_args(user)) + (w,) = store.workflows() + assert w.created_at == store._ms_to_local(1781122800000) + assert w.ended_at == store._ms_to_local(1781200000000) + assert w.ended_at != w.created_at + + def test_vscode_store_reads_legacy_json_and_dedupes_against_journal(): with tempfile.TemporaryDirectory() as tmp: user, _ = _vscode_user_dir( diff --git a/tests/test_stores_zaly.py b/tests/test_stores_zaly.py index cb6e505..329147c 100644 --- a/tests/test_stores_zaly.py +++ b/tests/test_stores_zaly.py @@ -19,6 +19,32 @@ ZALY_SID = "019f4c95-ffa0-7e39-875c-2e9b34958e7f" +def test_zaly_store_ended_at_reflects_the_latest_assistant_reply(): + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "zaly") + cwd = os.path.join(tmp, "repo") + os.makedirs(cwd) + rows = [ + _zaly_settings(ZALY_SID, cwd, ts=1783696388553), + _zaly_user("go", ts=1783696388555), + _zaly_assistant( + "anthropic/claude-opus-4-6", + 100, + 40, + cost={"input": 0.01}, + ts=1783696500000, # the latest activity in the session + ), + ] + _zaly_write(root, "+tmp+repo", ZALY_SID, rows) + store = _zaly_store(root) + w = store.workflows()[0] + + # _fmt_epoch renders in the system's local TZ, so compare against its own + # conversion of each raw epoch-ms value rather than a hardcoded wall-clock string. + assert w.created_at == store._fmt_epoch(store._epoch(1783696388553)) + assert w.ended_at == store._fmt_epoch(store._epoch(1783696500000)) + + def test_zaly_store_sums_cost_components_and_folds_to_git_root(): with tempfile.TemporaryDirectory() as tmp: root = os.path.join(tmp, "zaly") From 188a5639be52363a6bb714c58de689adb66d45db Mon Sep 17 00:00:00 2001 From: Simon Eisenhauer Date: Fri, 31 Jul 2026 09:04:14 +0200 Subject: [PATCH 2/2] feat(sort): sort sessions and projects by last activity Adds "last_activity" (ended_at, falling back to created_at) as a sort option alongside "date" for the sessions list, and alongside the existing "recency" for Projects mode -- so "what did I last work on" no longer needs the discarded A grouping toggle: it's a plain, reversible column, not a mode that reshuffles the Months/Days panels or the active date range. The Date column follows whichever sort is active ("Last act" next to "Started"), sized to fit its header field together with the sort arrow. Only offered outside the Time overview's Days pane: a single Day's Sessions list is read by start time, and activity can run into a later day than the one a row is filed under, so ranking by it there would be more confusing than useful. active_session_sort_options() is the one place that decides the vocabulary, so the picker, a header click, and the applied sort can never disagree; falling back away from last_activity also resolves its saved sort_reverse back to the fallback column's own natural order, and Projects mode gets its own guard so a stale self.focus never leaks the restriction in. --- docs/keys.md | 2 +- docs/sources.md | 6 ++ src/opentab/models.py | 21 +++-- src/opentab/tui/app.py | 57 +++++++++++-- src/opentab/tui/renderer.py | 33 ++++++-- tests/test_state.py | 27 ++++++ tests/test_tui_app.py | 159 ++++++++++++++++++++++++++++++++++++ tests/test_tui_detail.py | 26 ++++++ 8 files changed, 313 insertions(+), 18 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index 53da165..cdc2446 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -62,7 +62,7 @@ the harness records cumulative-total deltas rather than per-request prompts |-----|--------| | `R` | Set the date range — `all` · `30d` (or `30`) · `2m` · `1y` · `2026` · `2026-05` · `start..end` | | `a` | Back to all time, keeping the current selection where possible | -| `s` | Sort picker for the visible list (`j`/`k` move · `Enter` · `Esc`) | +| `s` | Sort picker for the visible list (`j`/`k` move · `Enter` · `Esc`). Sessions offer **Start Date** (`created_at`, default) and, everywhere except the Time overview's **Days** pane, **Last Activity** (`ended_at`, including subagent activity where tracked) — a single day's list is read by start time, and activity can run into a later day than the one the row is filed under, so ranking by it there is deliberately left out. The Date column follows whichever is active, and its header shows "Last act" under the latter | | `f` or `/` | Live filter — fuzzy (fzf-style) over sessions (title/project/id/**note**) and projects; model lists (`P`, `w`) match word-anchored (letters may scatter inside a word, a new word only joins at its first letter — `opus48` works, `opus` no longer drags in `qwen3-c`**`o`**`der-`**`p`**`l`**`us`**), routes by substring. Non-ASCII (`ä`, `界`) can be typed. While filtering: `↑`/`↓` select · `Enter` keep · `Esc` cancel · `Ctrl-U` clear | | `x` | Clear the filter | diff --git a/docs/sources.md b/docs/sources.md index 081bcfa..ec2cd9f 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -54,6 +54,12 @@ trends. What each tool's records support on top: per tool call and MCP server · ¹ headerless: the OTEL export captures no prompt text · ² with the optional `tool` column. +Every harness also derives when a session was last active (not just when it started), +including activity from its subagent subtree where tracked. This timestamp +(`ended_at`) feeds the sessions list's **Last Activity** sort (`s`) — the alternative +to sorting by when a session started, offered everywhere except the Time overview's +Days pane (see [`docs/keys.md`](keys.md#scope--filter)). + ### Token-only harnesses The whole TUI works the same everywhere — with two differences for the token-only diff --git a/src/opentab/models.py b/src/opentab/models.py index 68400db..4c14611 100644 --- a/src/opentab/models.py +++ b/src/opentab/models.py @@ -34,12 +34,16 @@ class Workflow: # with the exporting machine's label so the consolidated view can tag/group by box. # A second, orthogonal dimension to `source` (a session has both a tool and a host). machine: str = "" - # When the session's LAST recorded activity happened, same local - # "YYYY-MM-DD HH:MM:SS" string as created_at. Each backend fills it from data it - # already reads (a time_updated column, the max event timestamp of the parse it - # runs anyway) -- never a new scan. Empty = the backend can't know (an old - # --export, a schema without the column). Purely for the "(until 16:42)" hint on - # the detail line; the headline duration is worked_seconds, not this span. + # When the session's LAST recorded activity happened (root plus its whole subagent + # subtree), same local "YYYY-MM-DD HH:MM:SS" string as created_at. Each backend + # fills it from data it already reads (a time_updated column, the max event + # timestamp of the parse it runs anyway) -- never a new scan. Empty = the backend + # can't know (an old --export, a schema without the column), in which case the + # "last_activity" sort falls back to created_at. Feeds the "(until 16:42)" hint on + # the detail line and the sessions list's "last_activity" sort option alike -- one + # timestamp, both readers. Distinct from ProjectSummary/MachineSummary.last_active, + # which is the most recent *session's* created_at across a group of sessions, not + # activity inside a single one. ended_at: str = "" # How long the agent ACTUALLY worked, in seconds -- the sum of its working bursts # with the idle gaps (you reading/composing the next prompt) removed, computed at @@ -99,6 +103,11 @@ class ProjectSummary: subagents: int unpriced_tokens: int last_active: str = "" # created_at of the project's most recent session + # Most recent activity across every session in the project (ended_at-or-created_at + # per session, then maxed) -- distinct from last_active, which is the newest + # session's own START. A project whose newest session is still short but whose + # OLDER session ran a long subagent tail can rank higher here than by last_active. + last_activity: str = "" ignored: bool = False diff --git a/src/opentab/tui/app.py b/src/opentab/tui/app.py index 14bb768..e16ce6c 100644 --- a/src/opentab/tui/app.py +++ b/src/opentab/tui/app.py @@ -285,8 +285,25 @@ class App: # its sessions, its model mix, and which projects ran on it. "Harnesses" is injected # after Overview by current_tabs like every other scope (the fleet is always combined). machine_tabs = ("Overview", "Sessions", "Models", "Projects") - sort_options = ("cost", "tokens", "date", "duration", "subagents", "project", "title") - project_sort_options = ("cost", "tokens", "sessions", "subagents", "project", "recency") + sort_options = ( + "cost", + "tokens", + "date", + "last_activity", + "duration", + "subagents", + "project", + "title", + ) + project_sort_options = ( + "cost", + "tokens", + "sessions", + "subagents", + "project", + "recency", + "last_activity", + ) subagent_sort_options = ("cost", "tokens", "date", "title", "model", "agent", "depth") # The P overlay's price table sorts by model name, the blended eff column, your # usage share, or any of the four list-price columns. "eff" is the default and @@ -886,6 +903,7 @@ def projects_for_workflows( subagents=sum(w.subagents for w in workflows), unpriced_tokens=sum(w.unpriced_tokens for w in workflows), last_active=max(w.created_at for w in workflows), + last_activity=max((w.ended_at or w.created_at) for w in workflows), ignored=directory in self.ignored_projects, ) for directory, workflows in grouped.items() @@ -914,6 +932,8 @@ def sorted_projects(self, rows: list[ProjectSummary]) -> list[ProjectSummary]: return sorted(rows, key=lambda p: p.directory.lower(), reverse=desc) if sort_by == "recency": return sorted(rows, key=lambda p: p.last_active, reverse=desc) + if sort_by == "last_activity": + return sorted(rows, key=lambda p: p.last_activity, reverse=desc) return sorted(rows, key=lambda p: (p.cost, p.tokens), reverse=desc) # --- Machines mode (one row per box; a fleet adds the pulled ones) ------- @@ -3960,7 +3980,7 @@ def handle_source_menu_key(self, key: int | str) -> bool: def sorted_workflows(self, rows: list[Workflow]) -> list[Workflow]: sort_by = self.session_sort_key() - desc = self.sort_descending(sort_by, self.sort_reverse) + desc = self.sort_descending(sort_by, self.session_sort_reverse()) if sort_by == "cost": return sorted(rows, key=lambda item: (item.total_cost, item.total_tokens), reverse=desc) if sort_by == "tokens": @@ -3988,6 +4008,12 @@ def sorted_workflows(self, rows: list[Workflow]) -> list[Workflow]: return sorted( by_cost, key=lambda item: self.project_root(item.directory).lower(), reverse=desc ) + if sort_by == "last_activity": + return sorted( + rows, + key=lambda item: (item.ended_at or item.created_at, item.created_at), + reverse=desc, + ) return sorted(rows, key=lambda item: item.created_at, reverse=desc) def zoom_scope_workflows(self, include_ignored: bool = False) -> list[Workflow]: @@ -4200,12 +4226,31 @@ def in_subagent_sort_context(self) -> bool: # The session view's Subagents tab; its own sort pair, like project lists. return self.view == "session" and self.on_subagents_tab + def active_session_sort_options(self) -> tuple[str, ...]: + # "last_activity" is a Months/Years feature, per spec, deliberately not Days: + # a single Day's Sessions list is read by start time, and an activity can run + # into a LATER day than the one the row is filed under -- ranking the list by + # a timestamp that can point outside its own scope would be more confusing + # than useful there, even though the values themselves are perfectly valid. + if self.browse_mode == "time" and self.focus == "days": + return tuple(k for k in self.sort_options if k != "last_activity") + return self.sort_options + # The three lists' active sort keys, each validated against its own vocabulary. # Headers and sorters read these directly (never each other's, and never the # context-dependent effective_sort_by) so that when a project list and a session # list share the screen neither borrows the other's sort arrow. def session_sort_key(self) -> str: - return self.sort_by if self.sort_by in self.sort_options else self.sort_options[0] + options = self.active_session_sort_options() + return self.sort_by if self.sort_by in options else options[0] + + def session_sort_reverse(self) -> bool: + # The direction belongs to the stored column; falling back to a different + # EFFECTIVE key (active_session_sort_options() dropped "last_activity" while + # the Days pane is focused) must not carry that column's own reversed flag + # onto the fallback column's natural order -- e.g. a direction flip saved + # for "last_activity" must not silently sort Cost ascending on the Days pane. + return self.sort_reverse if self.sort_by in self.active_session_sort_options() else False def project_sort_key(self) -> str: return ( @@ -4349,7 +4394,7 @@ def apply_header_sort(self, key: str, target: str) -> None: self.project_sort_reverse = False self.project_index = 0 else: - if key not in self.sort_options: + if key not in self.active_session_sort_options(): return if self.sort_by == key: self.sort_reverse = not self.sort_reverse @@ -6928,7 +6973,7 @@ def current_sort_options(self) -> tuple[str, ...]: if self.view == "session" and self.on_subagents_tab: return self.subagent_sort_options if self.view != "session" and self.on_sessions_tab: - return self.sort_options + return self.active_session_sort_options() return () def workflows_for_day(self, day: str, source: list[Workflow] | None = None) -> list[Workflow]: diff --git a/src/opentab/tui/renderer.py b/src/opentab/tui/renderer.py index 3535465..c3590fd 100644 --- a/src/opentab/tui/renderer.py +++ b/src/opentab/tui/renderer.py @@ -943,7 +943,7 @@ def draw_keybar(self, stdscr: curses.window, y: int, width: int, parts) -> None: def sort_heading(self, key: str, label: str) -> str: if self.session_sort_key() != key: return label - desc = self.sort_descending(key, self.sort_reverse) + desc = self.sort_descending(key, self.session_sort_reverse()) return f"{label} {'v' if desc else '^'}" def project_sort_heading(self, key: str, label: str) -> str: @@ -972,6 +972,28 @@ def session_started(self, workflow: Workflow) -> str: def session_date_label(self) -> str: return "Started" if self._scope_spans_days() else "Time" + def session_date_column(self) -> tuple[str, str]: + # The Date column's (sort key, header label) pair -- swaps to the activity + # timestamp under a "last_activity" sort so the visible order is legible: the + # column you're sorted by is the one shown, not always the session's start. + # "Last act" (not "Last act.") is deliberate: the header field is `:<10` and + # sort_heading() always appends a " v"/" ^" arrow, so anything over 8 chars + # overflows the column and shifts every header after it -- "Started"/"Time" + # both clear it too, just with room to spare. + if self.session_sort_key() == "last_activity": + return ("last_activity", "Last act") + return ("date", self.session_date_label()) + + def session_date_cell(self, workflow: Workflow) -> str: + if self.session_sort_key() != "last_activity": + return self.session_started(workflow) + # Always a date, never a bare clock time: App.active_session_sort_options() + # makes "last_activity" unreachable in a single-day scope (browse_mode=="time" + # and focus=="days" -- the one case _scope_spans_days() is False), so a session + # sorted by activity is always shown across a scope wide enough that a clock + # time alone would be ambiguous anyway. + return (workflow.ended_at or workflow.created_at)[:10] + def _mark_session_header(self, lines: list[str], columns: tuple) -> None: # The just-appended line is a session-list column header (browse preview); # record it in _line_sort_headers so the paint loop makes it click-sortable @@ -1102,7 +1124,7 @@ def session_columns(self, sessions: list[Workflow], width: int) -> tuple[bool, i return False, 0, False def session_header_text(self, models: bool, proj_w: int, dur: bool = True) -> str: - header = f" {self.sort_heading('date', self.session_date_label()):<10} " + header = f" {self.sort_heading(*self.session_date_column()):<10} " if dur: header += f"{self.sort_heading('duration', 'Worked'):>8} " header += ( @@ -1142,7 +1164,7 @@ def session_duration(self, workflow: Workflow) -> str: def session_row_text( self, workflow: Workflow, marker: str, models: bool, proj_w: int, dur: bool = True ) -> str: - text = f"{marker} {self.session_started(workflow):<10} " + text = f"{marker} {self.session_date_cell(workflow):<10} " if dur: text += f"{self.session_duration(workflow):>8} " text += ( @@ -1163,7 +1185,7 @@ def session_row_text( def session_sort_columns(self, proj_w: int, dur: bool = True) -> tuple: # (sort_key, label) in drawn order, for the clickable headers of both frames. - columns = [("date", self.session_date_label()), *self.SESSION_SORT_COLUMNS] + columns = [self.session_date_column(), *self.SESSION_SORT_COLUMNS] if dur: columns.insert(1, ("duration", "Worked")) # right after the date cell if proj_w: @@ -5167,7 +5189,8 @@ def draw_theme_menu(self, stdscr: curses.window, scr_h: int, scr_w: int) -> None SORT_LABELS = { "cost": "Cost", "tokens": "Tokens", - "date": "Date", + "date": "Start Date", + "last_activity": "Last Activity", "duration": "Worked", "recency": "Recency", "subagents": "Subagents", diff --git a/tests/test_state.py b/tests/test_state.py index 0fcc0e1..94e7d1a 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -27,6 +27,33 @@ def test_prices_sort_is_persisted_in_state(): assert restored.prices_sort == "cache_write" and restored.prices_sort_reverse +def test_last_activity_sort_is_persisted_in_state(): + # No dedicated save/restore code exists for this -- sort_by/project_sort_by are + # already generic (state.py validates against app.sort_options/ + # project_sort_options), so "last_activity" persists for free once it's part of + # those tuples. This locks that in. + app = app_with([workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")]) + app.sort_by = "last_activity" + app.project_sort_by = "last_activity" + old_xdg = os.environ.get("XDG_STATE_HOME") + with tempfile.TemporaryDirectory() as tmp: + os.environ["XDG_STATE_HOME"] = tmp + try: + ot.save_state(app) + restored = app_with( + [workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")] + ) + assert restored.sort_by == "cost" and restored.project_sort_by == "cost" + ot.apply_state(restored, restored.args, ot.load_state()) + finally: + if old_xdg is None: + os.environ.pop("XDG_STATE_HOME", None) + else: + os.environ["XDG_STATE_HOME"] = old_xdg + assert restored.sort_by == "last_activity" + assert restored.project_sort_by == "last_activity" + + def test_machines_browse_mode_is_restored_fleet_or_not(): from tests._support import fleet_app diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 5aa4bc8..5436c5e 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -1188,6 +1188,165 @@ def test_clicking_active_column_header_toggles_direction(): assert app.sort_by == "title" and app.sort_reverse is False +def test_last_activity_sort_orders_by_activity_and_falls_back_to_created_at(): + app = app_with( + [ + # Started first but stayed active longest -- last_activity ranks it above + # "b"/"c", even though "date" (created_at) ranks it near the bottom. + workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00"), + workflow("b", "2026-06-03 12:00:00"), # no ended_at -> falls back to created_at + workflow("c", "2026-06-02 12:00:00"), # no ended_at -> falls back to created_at + # No ended_at, but its OWN created_at is later than "a"'s ended_at -- this + # only ranks first if the fallback key is really (ended_at or created_at), + # not bare ended_at (which would leave it with an empty-string key and + # stuck behind every session that has any ended_at at all). + workflow("d", "2026-06-07 12:00:00"), + ] + ) + app.focus = "months" # last_activity is unreachable while the Days pane is focused + app.sort_by = "last_activity" + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["d", "a", "b", "c"] + + app.sort_by = "date" + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["d", "b", "c", "a"] + + +def test_last_activity_sort_is_unavailable_while_the_days_pane_is_focused(): + # Per spec, "last_activity" is a Months/Years feature -- the Days pane's Sessions + # list is by definition every session that STARTED that day, so ranking it by an + # out-of-day activity timestamp doesn't apply there. It must not even be offered. + app = app_with( + [ + workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00"), + workflow("b", "2026-06-02 12:00:00"), + ] + ) + app.focus = "months" + app.tab = len(app.current_tabs()) - 1 # the Sessions tab (always last, day/month/year) + assert "last_activity" in app.current_sort_options() + assert "last_activity" in app.sort_menu_options() + + app.focus = "years" + app.tab = len(app.current_tabs()) - 1 # re-set: day/month/year tabs aren't all the same length + assert "last_activity" in app.current_sort_options() + + app.focus = "days" + app.tab = len(app.current_tabs()) - 1 # day_tabs is a different tuple/length + options = app.current_sort_options() + # "cost" staying present is what makes the negative checks below meaningful -- + # without it they'd pass just as well if current_sort_options() returned () + # entirely (e.g. on_sessions_tab wrongly False), which is a different bug. + assert "cost" in options and "last_activity" not in options + assert "last_activity" not in app.sort_menu_options() + # A leftover self.focus from a previous Time-mode session must not leak the + # restriction into Projects mode, where "days" means nothing. + app.set_browse_mode("projects") + app.tab = len(app.current_tabs()) - 1 + assert "last_activity" in app.current_sort_options() + + +def test_last_activity_sort_falls_back_and_resumes_across_a_day_focus_round_trip(): + # Switching focus never mutates sort_by -- it's the same non-destructive fallback + # pattern session_sort_key() already uses for any out-of-vocabulary value, just + # with a context-dependent vocabulary instead of the static one. + app = app_with( + [ + workflow("a", "2026-06-01 12:00:00", cost=1, ended_at="2026-06-05 09:00:00"), + workflow("b", "2026-06-02 12:00:00", cost=5), + ] + ) + app.sort_by = "last_activity" + app.focus = "months" + assert app.session_sort_key() == "last_activity" + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["a", "b"] + + app.focus = "days" + assert app.session_sort_key() == "cost" # falls back to sort_options[0], not "date" + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["b", "a"] + assert app.sort_by == "last_activity" # the stored preference itself is untouched + + app.focus = "months" + assert app.session_sort_key() == "last_activity" # resumes, nothing was lost + + +def test_apply_header_sort_rejects_last_activity_while_the_days_pane_is_focused(): + app = app_with([workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")]) + app.focus = "days" + app.apply_header_sort("last_activity", "session") + # Nothing mutated at all -- an early return, not a silent substitution. + assert app.sort_by == "cost" and app.sort_reverse is False + + +def test_apply_header_sort_still_accepts_last_activity_in_projects_mode_with_stale_days_focus(): + # set_browse_mode("projects") leaves self.focus wherever it was (it's meaningless + # there) -- and "days" is the DEFAULT, so this is the common case, not an edge + # case: a fresh app that never touched Time mode's sidebar is already in it. + app = app_with( + [ + workflow("a", "2026-06-01 12:00:00", cost=1, ended_at="2026-06-05 09:00:00"), + workflow("b", "2026-06-02 12:00:00", cost=5), + ] + ) + app.set_browse_mode("projects") + assert app.focus == "days" # confirms this exercises the leak-guard, not a no-op + app.apply_header_sort("last_activity", "session") + assert app.sort_by == "last_activity" and app.sort_reverse is False + + +def test_clicking_the_last_activity_column_sets_it_and_re_click_flips_direction(): + app = app_with( + [ + workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00"), + workflow("b", "2026-06-02 12:00:00"), + ] + ) + app.focus = "months" # last_activity is unreachable while the Days pane is focused + app.apply_header_sort("last_activity", "session") + assert app.sort_by == "last_activity" and app.sort_reverse is False + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["a", "b"] + app.apply_header_sort("last_activity", "session") # re-click flips direction + assert app.sort_reverse is True + assert [w.id for w in app.sorted_workflows(app.all_workflows)] == ["b", "a"] + + +def test_session_date_column_follows_the_active_sort(): + # The Date column is what both the browse preview and the zoom picker draw off + # (session_header_text/session_row_text/session_sort_columns feed both), so + # testing these three is testing both frames at once. + app = app_with([workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")]) + app.focus = "months" # a scope spanning more than one day, so the date form is "Started" + rnd = app.renderer + assert rnd.session_date_column() == ("date", "Started") + assert "Started" in rnd.session_header_text(False, 0) + assert rnd.session_sort_columns(0)[0] == ("date", "Started") # first column, not just present + + app.sort_by = "last_activity" + assert rnd.session_date_column() == ("last_activity", "Last act") + assert "Last act" in rnd.session_header_text(False, 0) + assert rnd.session_sort_columns(0)[0] == ("last_activity", "Last act") + + w = app.all_workflows[0] + assert rnd.session_date_cell(w) == "2026-06-05" # the activity date, not the start + assert "2026-06-05" in rnd.session_row_text(w, " ", False, 0) + + +def test_session_date_column_header_never_overflows_its_field(): + # "Last act" plus sort_heading()'s " v"/" ^" arrow must still fit the header's + # `:<10` field -- a longer label would push every column after it out of + # alignment with the rows beneath (regression: "Last act." + " v" was 11 chars). + app = app_with([workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")]) + app.focus = "months" # last_activity is unreachable while the Days pane is focused + app.sort_by = "last_activity" + rnd = app.renderer + heading = rnd.sort_heading(*rnd.session_date_column()) # includes the " v"/" ^" arrow + assert len(heading) <= 10 + # Every subsequent column starts at the same offset as under any other sort key. + cost_offset_here = rnd.session_header_text(False, 0).index("Cost") + app.sort_by = "date" + cost_offset_under_date = rnd.session_header_text(False, 0).index("Cost") + assert cost_offset_here == cost_offset_under_date + + def test_header_arrow_reflects_sort_direction(): app = app_with([workflow("a", "2026-06-01 12:00:00", directory="/tmp/a")]) app.set_browse_mode("projects") diff --git a/tests/test_tui_detail.py b/tests/test_tui_detail.py index 2234446..7babdbd 100644 --- a/tests/test_tui_detail.py +++ b/tests/test_tui_detail.py @@ -925,6 +925,32 @@ def test_projects_sort_by_recency(): assert by_dir["/tmp/new"].last_active == "2026-06-10 09:00:00" +def test_projects_sort_by_last_activity_differs_from_recency(): + # /tmp/new's session started later than /tmp/old's, so recency (newest session + # START) ranks it first -- but /tmp/old's session ran on well past that, so + # last_activity (newest ended_at-or-created_at) must rank /tmp/old first instead. + app = app_with( + [ + workflow( + "o1", + "2026-06-01 09:00:00", + cost=99, + directory="/tmp/old", + ended_at="2026-06-12 09:00:00", + ), + workflow("n1", "2026-06-10 09:00:00", cost=1, directory="/tmp/new"), + ] + ) + app.project_sort_by = "recency" + assert [p.directory for p in app.projects] == ["/tmp/new", "/tmp/old"] + + app.project_sort_by = "last_activity" + assert [p.directory for p in app.projects] == ["/tmp/old", "/tmp/new"] + by_dir = {p.directory: p for p in app.projects} + assert by_dir["/tmp/old"].last_activity == "2026-06-12 09:00:00" + assert by_dir["/tmp/new"].last_activity == "2026-06-10 09:00:00" # falls back to created_at + + def test_project_header_aligns_with_project_rows(): app = app_with( [workflow("a", "2026-06-01 12:00:00", cost=12.34, tokens=1500, directory="/tmp/project")]