From ea3df69a7c5d9f96178d28e46de529fb855efbd5 Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:18:54 +0700 Subject: [PATCH] fix(todo): accept the TodoItem shape todo_write declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo_write is annotated `todos: list[TodoItem]`, but its validation and rendering index every item as a mapping. Passing the declared model returns "Error: Todo item 0 missing 'content' field", because BaseModel.__iter__ yields (name, value) tuples rather than field names, and passing an attribute-style item raises TypeError. Normalize each item to a dict before validation, accepting mappings, pydantic models and attribute objects — mirroring the handling already used in thought_logger._display_todo_list. Dict callers, including the decoded tool-call path, are unaffected. --- dana/core/resource/todo_resource.py | 24 ++++ tests/unit/test_todo_resource.py | 175 ++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 tests/unit/test_todo_resource.py diff --git a/dana/core/resource/todo_resource.py b/dana/core/resource/todo_resource.py index ef62055..d675a68 100644 --- a/dana/core/resource/todo_resource.py +++ b/dana/core/resource/todo_resource.py @@ -3,18 +3,38 @@ Provides todo_write() tool mirroring Claude Code's TodoWrite signature. """ +from collections.abc import Mapping +from typing import Any + from pydantic import BaseModel from dana.common.protocols.war import tool_use from dana.core.resource.base_resource import BaseResource +TODO_FIELDS = ("content", "status", "activeForm") + + class TodoItem(BaseModel): content: str status: str activeForm: str +def _as_mapping(todo: Any) -> dict[str, Any]: + """Normalize a single todo item to a plain dict. + + Accepts the three shapes that reach this resource: mappings (the JSON a + tool call decodes to), the ``TodoItem`` model declared by ``todo_write``, + and dataclass-style items that expose the fields as attributes. + """ + if isinstance(todo, Mapping): + return dict(todo) + if isinstance(todo, BaseModel): + return todo.model_dump() + return {field: getattr(todo, field) for field in TODO_FIELDS if hasattr(todo, field)} + + class ToDoResource(BaseResource): """Resource for tracking task progress.""" @@ -187,6 +207,10 @@ async def todo_write(self, todos: list[TodoItem]) -> str: Returns: Formatted todo list with status indicators. """ + # Normalize first: validation and rendering below index every item as a + # mapping, which the declared TodoItem model does not support. + todos = [_as_mapping(todo) for todo in todos] + # Validate todos valid_statuses = {"pending", "in_progress", "completed"} diff --git a/tests/unit/test_todo_resource.py b/tests/unit/test_todo_resource.py new file mode 100644 index 0000000..de246da --- /dev/null +++ b/tests/unit/test_todo_resource.py @@ -0,0 +1,175 @@ +"""Tests for ToDoResource.todo_write input handling. + +Covers: +- Mapping input (the shape a decoded tool call produces) - unchanged behavior +- TodoItem model input (the type todo_write declares in its signature) +- Attribute-style items (dana.core.runtime.TodoItem dataclass) +- Validation errors and rendering are identical across all accepted shapes +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from dana.core.resource.todo_resource import TodoItem, ToDoResource + + +@pytest.fixture +def todo_resource(): + """Create a ToDoResource instance.""" + return ToDoResource(resource_id="test-todo") + + +@dataclass +class AttrTodo: + """Dataclass-style todo item, mirroring dana.core.runtime.TodoItem.""" + + content: str + status: str + activeForm: str + + +# ============================================================================ +# ACCEPTED INPUT SHAPES +# ============================================================================ + + +class TestAcceptedInputShapes: + """todo_write should accept every todo item shape used in the codebase.""" + + @pytest.mark.asyncio + async def test_mapping_input_renders_list(self, todo_resource): + """Dict input (decoded tool-call JSON) renders the todo list.""" + result = await todo_resource.todo_write(todos=[{"content": "Run tests", "status": "pending", "activeForm": "Running tests"}]) + + assert "Todo List:" in result + assert "Run tests" in result + assert "Progress: 0/1 completed, 0 in progress, 1 pending" in result + + @pytest.mark.asyncio + async def test_todoitem_model_input_renders_list(self, todo_resource): + """The TodoItem model declared by todo_write is accepted.""" + result = await todo_resource.todo_write(todos=[TodoItem(content="Run tests", status="pending", activeForm="Running tests")]) + + assert "Todo List:" in result + assert "Run tests" in result + assert "Progress: 0/1 completed, 0 in progress, 1 pending" in result + + @pytest.mark.asyncio + async def test_attribute_item_input_renders_list(self, todo_resource): + """Dataclass-style items exposing the fields as attributes are accepted.""" + result = await todo_resource.todo_write(todos=[AttrTodo(content="Run tests", status="pending", activeForm="Running tests")]) + + assert "Todo List:" in result + assert "Run tests" in result + + @pytest.mark.asyncio + async def test_shapes_render_identically(self, todo_resource): + """The same todo expressed in each shape produces the same output.""" + fields = {"content": "Build project", "status": "in_progress", "activeForm": "Building project"} + + from_dict = await todo_resource.todo_write(todos=[dict(fields)]) + from_model = await todo_resource.todo_write(todos=[TodoItem(**fields)]) + from_attrs = await todo_resource.todo_write(todos=[AttrTodo(**fields)]) + + assert from_dict == from_model == from_attrs + + @pytest.mark.asyncio + async def test_mixed_shapes_in_one_call(self, todo_resource): + """A list mixing shapes is normalized as a whole.""" + result = await todo_resource.todo_write( + todos=[ + {"content": "First", "status": "completed", "activeForm": "Doing first"}, + TodoItem(content="Second", status="in_progress", activeForm="Doing second"), + AttrTodo(content="Third", status="pending", activeForm="Doing third"), + ] + ) + + assert "First" in result + assert "Doing second" in result + assert "Third" in result + assert "Progress: 1/3 completed, 1 in progress, 1 pending" in result + + +# ============================================================================ +# STORED STATE +# ============================================================================ + + +class TestStoredTodos: + """get_todos should return dicts regardless of the input shape.""" + + @pytest.mark.asyncio + async def test_model_input_is_stored_as_dict(self, todo_resource): + """Model input is normalized before it is stored.""" + await todo_resource.todo_write(todos=[TodoItem(content="Run tests", status="pending", activeForm="Running tests")]) + + stored = todo_resource.get_todos() + + assert stored == [{"content": "Run tests", "status": "pending", "activeForm": "Running tests"}] + + @pytest.mark.asyncio + async def test_dict_input_is_stored_unchanged(self, todo_resource): + """Dict input round-trips through get_todos unchanged.""" + todo = {"content": "Run tests", "status": "completed", "activeForm": "Running tests"} + + await todo_resource.todo_write(todos=[todo]) + + assert todo_resource.get_todos() == [todo] + + +# ============================================================================ +# VALIDATION +# ============================================================================ + + +class TestValidation: + """Validation messages should be the same for every accepted shape.""" + + @pytest.mark.asyncio + async def test_missing_field_in_mapping_reports_error(self, todo_resource): + """A dict missing activeForm is reported, not rendered.""" + result = await todo_resource.todo_write(todos=[{"content": "Run tests", "status": "pending"}]) + + assert result == "Error: Todo item 0 missing 'activeForm' field" + + @pytest.mark.asyncio + async def test_missing_field_in_attribute_item_reports_error(self, todo_resource): + """An attribute item missing activeForm is reported, not a TypeError.""" + + @dataclass + class PartialTodo: + content: str + status: str + + result = await todo_resource.todo_write(todos=[PartialTodo(content="Run tests", status="pending")]) + + assert result == "Error: Todo item 0 missing 'activeForm' field" + + @pytest.mark.asyncio + async def test_invalid_status_in_model_reports_error(self, todo_resource): + """An invalid status on a model item is reported with its index.""" + result = await todo_resource.todo_write(todos=[TodoItem(content="Run tests", status="blocked", activeForm="Running tests")]) + + assert result == "Error: Todo item 0 has invalid status 'blocked'" + + @pytest.mark.asyncio + async def test_error_index_points_at_offending_item(self, todo_resource): + """The reported index identifies the offending item in a mixed list.""" + result = await todo_resource.todo_write( + todos=[ + TodoItem(content="First", status="pending", activeForm="Doing first"), + {"content": "Second", "status": "nope", "activeForm": "Doing second"}, + ] + ) + + assert result == "Error: Todo item 1 has invalid status 'nope'" + + @pytest.mark.asyncio + async def test_empty_list_renders_empty_progress(self, todo_resource): + """An empty todo list is rendered rather than rejected.""" + result = await todo_resource.todo_write(todos=[]) + + assert "Progress: 0/0 completed, 0 in progress, 0 pending" in result