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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user