diff --git a/src/plyngent/agent/todo_stack.py b/src/plyngent/agent/todo_stack.py index fa2bb01..510c470 100644 --- a/src/plyngent/agent/todo_stack.py +++ b/src/plyngent/agent/todo_stack.py @@ -184,6 +184,20 @@ class TodoStack: f"Current stack:\n{self.render()}" ) + def _existing_numeric_ids(self) -> list[int]: + """Numeric suffixes of ids that look like ``tN`` (for counter reuse).""" + return [ + int(item.id[1:]) + for group in self._data.groups + for item in group.items + if item.id.startswith("t") and item.id[1:].isdigit() + ] + + def _sync_next_id(self) -> None: + """Set ``next_id`` to one past the highest live id (or 1 if empty).""" + nums = self._existing_numeric_ids() + self._data.next_id = max(nums) + 1 if nums else 1 + def _alloc(self, title: str, *, notes: str, status: TodoStatus) -> TodoItem: item_id = f"t{self._data.next_id}" self._data.next_id += 1 @@ -202,6 +216,8 @@ class TodoStack: msg = "at least one non-empty title is required" raise ValueError(msg) note = notes.strip() + # Rebase counter on live ids so clear/pop do not leave a high watermark. + self._sync_next_id() items = [self._alloc(title, notes=note, status=status) for title in cleaned] group = TodoGroup(items=items) self._data.groups.append(group) @@ -218,12 +234,14 @@ class TodoStack: if not self._data.groups: return None group = self._data.groups.pop() + self._sync_next_id() self.mark_touched() return group def clear(self) -> int: n = sum(len(g.items) for g in self._data.groups) self._data.groups.clear() + self._sync_next_id() if n: self.mark_touched() return n diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 67f6045..13a61aa 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -970,11 +970,11 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915 if act == "push": raw = " ".join(rest).strip() if not raw: - click.echo("error: usage: /todos push | /todos push T1; T2") + click.echo('error: usage: /todos push <title> | /todos push ["T1","T2"] | T1; T2') return titles = parse_push_titles(raw) if not titles: - click.echo("error: no titles to push") + click.echo("error: no titles to push (use a JSON array or ; / newlines)") return try: group = stack.push_group(titles) diff --git a/src/plyngent/tools/todo.py b/src/plyngent/tools/todo.py index 6e67779..c9e8835 100644 --- a/src/plyngent/tools/todo.py +++ b/src/plyngent/tools/todo.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast from plyngent.agent import tool -from plyngent.agent.todo_stack import parse_push_titles if TYPE_CHECKING: from collections.abc import Callable @@ -53,17 +52,17 @@ def todo_list() -> str: @tool(name="todo_push") -def todo_push(titles: str, notes: str = "") -> str: +def todo_push(titles: list[str], notes: str = "") -> str: """Push **one task group** (siblings) onto the stack — not one level per title. - ``titles``: one title, newlines, ``;``, or JSON array. All listed titles - become members of a single new TOP group. Example: ``T1\\nT2`` pushes one - group {T1, T2}; a later ``T1.1\\nT1.2`` pushes a child group above it. + ``titles``: JSON array of strings (tool schema type ``array``). All entries + become members of a single new TOP group. Example: ``[\"T1\", \"T2\"]`` pushes + one group {T1, T2}; a later ``[\"T1.1\", \"T1.2\"]`` pushes a child group above it. """ stack = _require_stack() - parsed = parse_push_titles(titles) + parsed = [t.strip() for t in titles if t and t.strip()] if not parsed: - return "error: titles must contain at least one non-empty title" + return "error: titles must be a non-empty array of strings" try: group = stack.push_group(parsed, notes=notes) except ValueError as exc: diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index 00aad28..b5dcdd1 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -30,6 +30,26 @@ def test_parse_push_titles() -> None: assert parse_push_titles('["A", "B"]') == ["A", "B"] +def test_ids_reuse_after_clear() -> None: + stack = TodoStack() + g = stack.push_group(["A", "B"]) + assert [i.id for i in g.items] == ["t1", "t2"] + assert stack.clear() == 2 + g2 = stack.push_group(["C"]) + assert [i.id for i in g2.items] == ["t1"] + + +def test_ids_reuse_after_pop() -> None: + stack = TodoStack() + _ = stack.push_group(["A", "B"]) # t1, t2 + g2 = stack.push_group(["C"]) # t3 + assert g2.items[0].id == "t3" + _ = stack.pop() # drop t3 + g3 = stack.push_group(["D"]) + # Highest live id is t2 → next is t3 again + assert g3.items[0].id == "t3" + + def test_push_is_group_not_per_task_stack() -> None: """Multi-title push is one group; pop removes the whole group.""" stack = TodoStack()