fix(todo): unversioned tool.start still merges after resume

Resume was attaching an unused store as {todos: [], revision: 0} and the desktop rejected tool.start updates that have no revision. A merge:true start after reconnect never patched the list until complete.

Skip unused empty snapshots. Apply unversioned updates without moving the watermark so a later todo.updated can still win.
This commit is contained in:
Adolanium
2026-08-30 05:55:42 +03:00
committed by Teknium
parent d5fd2e9366
commit 58523f284c
4 changed files with 56 additions and 2 deletions
+18
View File
@@ -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')
})
})
+11 -1
View File
@@ -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) {
@@ -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}
+7 -1
View File
@@ -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: