Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions strix/tools/todo/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,30 @@ def _normalize_todo_ids(raw_ids: Any) -> list[str]:
stripped = raw_ids.strip()
if not stripped:
return []
# json.loads is used only to unpack a JSON *array* ('["a", "b"]')
# or a JSON *string* scalar ('"a3f9c2"', which unwraps to the id).
# Any other bare token is a literal id (optionally comma-separated)
# and must NOT be routed through a parsed *numeric* scalar: ids are
# 6-char uuid slugs, and ones like "1e5230" or "2363e0" are valid
# JSON numbers that json.loads would mangle (-> inf / "2363.0"),
# silently targeting a non-existent todo.
try:
data = json.loads(stripped)
parsed = json.loads(stripped)
except json.JSONDecodeError:
data = stripped.split(",") if "," in stripped else [stripped]
if isinstance(data, list):
return [str(item).strip() for item in data if str(item).strip()]
return [str(data).strip()]
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
# Otherwise resolve to literal tokens: a JSON string scalar unwraps
# to its value, a comma string splits, and anything else is a single
# token kept verbatim (so numeric-looking ids aren't mangled by the
# parse above).
if isinstance(parsed, str):
tokens = [parsed]
elif "," in stripped:
tokens = stripped.split(",")
else:
tokens = [stripped]
return [token.strip() for token in tokens if token.strip()]
if isinstance(raw_ids, list):
return [str(item).strip() for item in raw_ids if str(item).strip()]
return [str(raw_ids).strip()]
Expand Down
91 changes: 91 additions & 0 deletions tests/test_todo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for per-agent todo id normalization and resolution."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

import pytest

import strix.tools.todo.tools as todo_tools
from strix.tools.todo.tools import _normalize_todo_ids


if TYPE_CHECKING:
from collections.abc import Iterator


@pytest.fixture(autouse=True)
def _reset_todos_storage(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setattr(todo_tools, "_todos_path", None)
with todo_tools._todos_io_lock:
todo_tools._todos_storage.clear()
yield
with todo_tools._todos_io_lock:
todo_tools._todos_storage.clear()


def test_bare_numeric_looking_id_is_preserved() -> None:
# ids are `str(uuid.uuid4())[:6]` hex slugs; slugs like these are valid
# JSON numbers, so json.loads would turn "1e5230" into inf and "2363e0"
# into 2363.0. They must be kept verbatim as literal ids.
assert _normalize_todo_ids("1e5230") == ["1e5230"]
assert _normalize_todo_ids("2363e0") == ["2363e0"]
assert _normalize_todo_ids("0e4440") == ["0e4440"]


def test_plain_hex_and_digit_ids_are_preserved() -> None:
assert _normalize_todo_ids("a3f9c2") == ["a3f9c2"]
assert _normalize_todo_ids("123456") == ["123456"]


def test_json_array_of_ids_is_unpacked() -> None:
assert _normalize_todo_ids('["1e5230", "a3f9c2"]') == ["1e5230", "a3f9c2"]


def test_json_string_scalar_id_is_unwrapped() -> None:
# A caller may JSON-encode a single id ('"a3f9c2"'); the surrounding
# quotes must be stripped, not treated as part of the id. A quoted
# numeric-looking slug stays a literal string (not a parsed number),
# and an empty JSON string yields no ids.
assert _normalize_todo_ids('"a3f9c2"') == ["a3f9c2"]
assert _normalize_todo_ids('"1e5230"') == ["1e5230"]
assert _normalize_todo_ids('""') == []


def test_comma_separated_ids_are_split() -> None:
assert _normalize_todo_ids("1e5230, a3f9c2") == ["1e5230", "a3f9c2"]


def test_list_input_is_stringified_and_stripped() -> None:
assert _normalize_todo_ids([" 1e5230 ", "a3f9c2"]) == ["1e5230", "a3f9c2"]


def test_empty_and_none_inputs_yield_no_ids() -> None:
assert _normalize_todo_ids("") == []
assert _normalize_todo_ids(" ") == []
assert _normalize_todo_ids(None) == []


def test_mark_resolves_a_bare_numeric_looking_id() -> None:
# End-to-end: marking a bare id whose slug is a valid JSON number must
# find and update the real todo, not fail with "Todo with ID 'inf' not
# found".
agent_id = "agent-1"
todos = todo_tools._get_agent_todos(agent_id)
todos["1e5230"] = {
"title": "probe /admin",
"description": None,
"priority": "normal",
"status": "pending",
"created_at": "2026-07-03T00:00:00+00:00",
"updated_at": "2026-07-03T00:00:00+00:00",
"completed_at": None,
}

result = json.loads(todo_tools._mark(agent_id=agent_id, todo_ids="1e5230", new_status="done"))

assert result["success"] is True
assert result["marked"] == ["1e5230"]
assert "errors" not in result
assert todos["1e5230"]["status"] == "done"