diff --git a/apps/desktop/src/store/todos.test.ts b/apps/desktop/src/store/todos.test.ts index 98ec0dd5fd..8980e4a82c 100644 --- a/apps/desktop/src/store/todos.test.ts +++ b/apps/desktop/src/store/todos.test.ts @@ -134,4 +134,22 @@ describe('revisioned snapshots', () => { restoreSessionTodosFromSnapshot('s1', snapshot, true) expect($todosBySession.get().s1?.[0]?.id).toBe('active') }) + + it('applies an unversioned update after a revisioned snapshot (tool.start merge)', () => { + setSessionTodos('s1', [todo('a', 'pending'), todo('b', 'pending')], 5) + setSessionTodos('s1', [todo('a', 'completed'), todo('b', 'pending')]) + + expect($todosBySession.get().s1?.[0]?.status).toBe('completed') + expect($todoRevisionsBySession.get().s1).toBe(5) + }) + + it('does not stamp a watermark from an unused empty snapshot', () => { + restoreSessionTodosFromSnapshot('s1', { revision: 0, todos: [] }, true) + + expect($todosBySession.get().s1).toBeUndefined() + expect($todoRevisionsBySession.get().s1).toBeUndefined() + + setSessionTodos('s1', [todo('a', 'in_progress')]) + expect($todosBySession.get().s1?.[0]?.id).toBe('a') + }) }) diff --git a/apps/desktop/src/store/todos.ts b/apps/desktop/src/store/todos.ts index 5d7906364a..1454bd28d1 100644 --- a/apps/desktop/src/store/todos.ts +++ b/apps/desktop/src/store/todos.ts @@ -74,8 +74,10 @@ function acceptRevision(sid: string, revision?: null | number): boolean { const revisions = $todoRevisionsBySession.get() const current = revisions[sid] + // tool.start has no revision. Apply the merge locally and leave the + // watermark alone so a later todo.updated / tool.complete can still win. if (revision == null) { - return current == null + return true } if (current != null && revision < current) { @@ -156,6 +158,14 @@ export function restoreSessionTodosFromSnapshot(sid: string, snapshot: unknown, } const revision = parseTodoRevision(snapshot) + + // An unused store serializes as {todos: [], revision: 0}. That is not a + // real snapshot. Applying it would stamp watermark 0 and leave an empty + // list in the map. + if (todos.length === 0 && (revision == null || revision === 0)) { + return + } + const visible = running ? todos : todosForHydration(todos) if (visible !== null) { diff --git a/tests/tui_gateway/test_todo_state_events.py b/tests/tui_gateway/test_todo_state_events.py index 425f6a8ad7..8ac433c95c 100644 --- a/tests/tui_gateway/test_todo_state_events.py +++ b/tests/tui_gateway/test_todo_state_events.py @@ -78,3 +78,23 @@ def test_live_snapshot_prefers_the_highest_revision(): payload = server._attach_todo_state({}, session) assert payload["todo_state"]["revision"] == 5 + + +def test_unused_store_is_not_attached(): + class Store: + @staticmethod + def snapshot(): + return {"todos": [], "revision": 0} + + class Agent: + _todo_store = Store() + + payload = server._attach_todo_state({}, {"agent": Agent()}) + + assert "todo_state" not in payload + + +def test_empty_list_at_nonzero_revision_is_a_real_clear(): + state = server._normalize_todo_state({"todos": [], "revision": 2}) + + assert state == {"todos": [], "revision": 2} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0536cb056b..576d6db45f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7645,7 +7645,13 @@ def _normalize_todo_state(value: object) -> dict | None: revision = max(0, int(value.get("revision") or 0)) except (TypeError, ValueError): return None - return {"todos": list(value["todos"]), "revision": revision} + todos = list(value["todos"]) + # Unused TodoStore snapshot() is {todos: [], revision: 0}. Attaching + # that on resume stamps a client watermark and blocks unversioned + # tool.start merges. An empty list at revision >= 1 is a real clear. + if not todos and revision == 0: + return None + return {"todos": todos, "revision": revision} def _session_todo_state(session: dict) -> dict | None: