"""Tests for the todo tool module.""" import json from tools.todo_tool import TodoStore, todo_tool class TestWriteAndRead: def test_write_replaces_list(self): store = TodoStore() items = [ {"id": "1", "content": "First task", "status": "pending"}, {"id": "2", "content": "Second task", "status": "in_progress"}, ] result = store.write(items) assert len(result) == 2 assert result[0]["id"] == "2" assert result[0]["status"] == "in_progress" assert result[1]["id"] == "1" def test_write_deduplicates_duplicate_ids(self): store = TodoStore() result = store.write([ {"id": "1", "content": "First version", "status": "pending"}, {"id": "2", "content": "Other task", "status": "pending"}, {"id": "1", "content": "Latest version", "status": "in_progress"}, ]) assert result == [ {"id": "1", "content": "Latest version", "status": "in_progress"}, {"id": "2", "content": "Other task", "status": "pending"}, ] def test_write_moves_active_item_before_earlier_pending_step(self): store = TodoStore() result = store.write([ {"id": "1", "content": "Already done", "status": "completed"}, {"id": "2", "content": "Verify freed space", "status": "pending"}, {"id": "3", "content": "Move archives to Trash", "status": "in_progress"}, ]) assert result == [ {"id": "1", "content": "Already done", "status": "completed"}, {"id": "3", "content": "Move archives to Trash", "status": "in_progress"}, {"id": "2", "content": "Verify freed space", "status": "pending"}, ] class TestHasItems: def test_empty_store(self): store = TodoStore() assert store.has_items() is False def test_non_empty_store(self): store = TodoStore() store.write([{"id": "1", "content": "x", "status": "pending"}]) assert store.has_items() is True class TestFormatForInjection: def test_empty_returns_none(self): store = TodoStore() assert store.format_for_injection() is None def test_non_empty_has_markers(self): store = TodoStore() store.write([ {"id": "1", "content": "Do thing", "status": "completed"}, {"id": "2", "content": "Next", "status": "pending"}, {"id": "3", "content": "Working", "status": "in_progress"}, ]) text = store.format_for_injection() # Completed items are filtered out of injection assert "[x]" not in text assert "Do thing" not in text # Active items are included assert "[ ]" in text assert "[>]" in text assert "Next" in text assert "Working" in text class TestMergeMode: def test_update_existing_by_id(self): store = TodoStore() store.write([ {"id": "1", "content": "Original", "status": "pending"}, ]) store.write( [{"id": "1", "status": "completed"}], merge=True, ) items = store.read() assert len(items) == 1 assert items[0]["status"] == "completed" assert items[0]["content"] == "Original" def test_merge_appends_new(self): store = TodoStore() store.write([{"id": "1", "content": "First", "status": "pending"}]) store.write( [{"id": "2", "content": "Second", "status": "pending"}], merge=True, ) items = store.read() assert len(items) == 2 def test_merge_reorders_active_item_ahead_of_earlier_pending_step(self): store = TodoStore() store.write([ {"id": "1", "content": "Completed", "status": "completed"}, {"id": "2", "content": "Verify freed space", "status": "pending"}, {"id": "3", "content": "Move archives to Trash", "status": "pending"}, ]) result = store.write( [{"id": "3", "status": "in_progress"}], merge=True, ) assert result == [ {"id": "1", "content": "Completed", "status": "completed"}, {"id": "3", "content": "Move archives to Trash", "status": "in_progress"}, {"id": "2", "content": "Verify freed space", "status": "pending"}, ] class TestTodoToolFunction: def test_read_mode(self): store = TodoStore() store.write([{"id": "1", "content": "Task", "status": "pending"}]) result = json.loads(todo_tool(store=store)) assert result["summary"]["total"] == 1 assert result["summary"]["pending"] == 1 assert result["revision"] == 1 def test_no_store_returns_error(self): result = json.loads(todo_tool()) assert "error" in result class TestTodoStoreSnapshots: def test_revision_only_advances_when_state_changes(self): store = TodoStore() items = [{"id": "1", "content": "Task", "status": "pending"}] store.write(items) first = store.snapshot() store.write(items) assert first["revision"] == 1 assert store.snapshot() == first def test_restore_adopts_a_trusted_revision(self): store = TodoStore() store.restore( [{"id": "1", "content": "Task", "status": "pending"}], revision=7 ) assert store.snapshot()["revision"] == 7 store.write([{"id": "1", "content": "Task", "status": "completed"}]) assert store.snapshot()["revision"] == 8 class TestTodoStoreBounds: """Bounds on persisted todo state (GHSA-5g4g-6jrg-mw3g hardening). The todo list is re-injected into context after every compression event, so an unbounded item — whether authored by the model or replayed from caller-supplied history on the API server's _hydrate_todo_store path — would defeat the compression it rides through. These pin the caps. Not a security boundary (the API surface is authenticated and the caller supplies their own history); this is footgun containment / parity. """ def test_oversized_content_is_truncated(self): from tools.todo_tool import MAX_TODO_CONTENT_CHARS store = TodoStore() store.write([{"id": "1", "content": "A" * 50001, "status": "pending"}]) item = store.read()[0] assert len(item["content"]) <= MAX_TODO_CONTENT_CHARS assert item["content"].endswith("… [truncated]") def test_injection_block_is_bounded(self): from tools.todo_tool import MAX_TODO_CONTENT_CHARS store = TodoStore() store.write([{"id": "1", "content": "A" * 50001, "status": "pending"}]) inj = store.format_for_injection() # Before the fix this was ~50085 chars; now it tracks the cap. assert len(inj) < MAX_TODO_CONTENT_CHARS + 200 def test_item_count_is_bounded(self): from tools.todo_tool import MAX_TODO_ITEMS store = TodoStore() store.write([ {"id": str(i), "content": f"task {i}", "status": "pending"} for i in range(5000) ]) assert len(store.read()) == MAX_TODO_ITEMS def test_normal_list_is_unchanged(self): """No regression: ordinary plans pass through untouched (no marker, same content, same order).""" store = TodoStore() store.write([ {"id": "1", "content": "write the report", "status": "in_progress"}, {"id": "2", "content": "review PR", "status": "pending"}, ]) items = store.read() assert [i["content"] for i in items] == ["write the report", "review PR"] assert "[truncated]" not in items[0]["content"] class TestRejectEmptyContent: """Empty or missing content must fail the call instead of substituting '(no description)'. See #44496.""" def test_write_rejects_empty_content(self): store = TodoStore() try: store.write([{"id": "1", "content": "", "status": "pending"}]) assert False, "should have raised ValueError" except ValueError as e: assert "empty or missing content" in str(e) def test_write_rejects_missing_content_key(self): store = TodoStore() try: store.write([{"id": "2", "status": "pending"}]) assert False, "should have raised ValueError" except ValueError as e: assert "empty or missing content" in str(e) def test_write_rejects_whitespace_content(self): store = TodoStore() try: store.write([{"id": "3", "content": " ", "status": "pending"}]) assert False, "should have raised ValueError" except ValueError as e: assert "empty or missing content" in str(e) def test_partial_write_does_not_mutate_existing_list(self): """A failed write with mixed valid/invalid items leaves the prior list untouched (acceptance criterion #3 from #44496).""" store = TodoStore() store.write([ {"id": "1", "content": "Keep me", "status": "pending"}, {"id": "2", "content": "Also keep me", "status": "completed"}, ]) try: store.write([ {"id": "1", "content": "Updated", "status": "pending"}, # valid {"id": "2", "content": "", "status": "pending"}, # invalid ]) except ValueError: pass items = store.read() assert [i["content"] for i in items] == ["Keep me", "Also keep me"] assert [i["status"] for i in items] == ["pending", "completed"] def test_todo_tool_returns_error_on_empty_content(self): result = json.loads(todo_tool( todos=[{"id": "1", "content": "", "status": "pending"}], store=TodoStore(), )) assert "error" in result assert "empty or missing content" in result["error"] def test_merge_new_item_rejects_empty_content(self): """Merge mode: new items with empty content are rejected.""" store = TodoStore() store.write([{"id": "1", "content": "Existing", "status": "pending"}]) try: store.write([{"id": "2", "content": "", "status": "pending"}], merge=True) assert False, "should have raised ValueError" except ValueError as e: assert "empty or missing content" in str(e) def test_merge_new_item_rejects_whitespace_content(self): """Merge mode: new items with whitespace-only content are rejected.""" store = TodoStore() store.write([{"id": "1", "content": "Existing", "status": "pending"}]) try: store.write([{"id": "2", "content": " ", "status": "pending"}], merge=True) assert False, "should have raised ValueError" except ValueError as e: assert "empty or missing content" in str(e) def test_merge_existing_item_keeps_content_on_status_only_update(self): """Merge mode: updating only status (no content key) preserves existing content.""" store = TodoStore() store.write([{"id": "1", "content": "Keep this", "status": "pending"}]) store.write([{"id": "1", "status": "completed"}], merge=True) items = store.read() assert items[0]["content"] == "Keep this" assert items[0]["status"] == "completed" def test_valid_content_still_works(self): """Regression: valid items still pass through normally.""" store = TodoStore() items = store.write([ {"id": "1", "content": "A real task", "status": "pending"}, ]) assert items[0]["content"] == "A real task"