core/agent+tools+cli: number todos by listing order, not the internal id

This commit is contained in:
2026-10-16 18:52:36 +08:00
parent ac33e92d33
commit 2eb99a079d
9 changed files with 192 additions and 79 deletions
+33 -20
View File
@@ -14,7 +14,7 @@ _REVIEW_OPEN_TITLE_LIMIT = 8
class TodoItem(Struct, omit_defaults=True): class TodoItem(Struct, omit_defaults=True):
"""One task inside a group (siblings share a group; not stacked individually).""" """One task inside a group (siblings share a group; not stacked individually)."""
id: str id: str # stored key only — a listing numbers items by render order, never by id
title: str title: str
status: TodoStatus = "pending" status: TodoStatus = "pending"
notes: str = "" notes: str = ""
@@ -39,7 +39,8 @@ class TodoStack:
- **Push** always creates **one new group** containing one or more sibling - **Push** always creates **one new group** containing one or more sibling
tasks (``push T1, T2`` is one frame, not two stack levels). tasks (``push T1, T2`` is one frame, not two stack levels).
- **Pop** removes the entire **top group**. - **Pop** removes the entire **top group**.
- Within a group, update tasks by id (done / in_progress / …). - **Update** takes the 1-based number a listing shows (TOP group first); the
stored ``id`` never leaves the stack.
Breakdown pattern:: Breakdown pattern::
@@ -161,13 +162,26 @@ class TodoStack:
raw = cast("dict[object, object]", out) raw = cast("dict[object, object]", out)
return {str(key): value for key, value in raw.items()} return {str(key): value for key, value in raw.items()}
def _render_order(self) -> list[tuple[TodoGroup, int, TodoItem]]:
"""``(group, index, item)`` in listing order — TOP group first.
A listing numbers these 1-based, so an item's position here *is* the
number it is shown (and updated) by.
"""
ordered: list[tuple[TodoGroup, int, TodoItem]] = []
for group in reversed(self._data.groups):
ordered.extend((group, index, item) for index, item in enumerate(group.items))
return ordered
def render(self) -> str: def render(self) -> str:
if not self._data.groups: if not self._data.groups:
return "(todo stack empty — no groups)" return "(todo stack empty — no groups)"
lines: list[str] = [ lines: list[str] = [
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)", f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
] ]
# Top group first for the model. # Top group first for the model; the running number is the item's
# position in :meth:`_render_order`, the same list ``update`` resolves.
number = 0
for offset, group in enumerate(reversed(self._data.groups)): for offset, group in enumerate(reversed(self._data.groups)):
depth = self.depth - 1 - offset depth = self.depth - 1 - offset
tag = " TOP" if offset == 0 else "" tag = " TOP" if offset == 0 else ""
@@ -176,6 +190,7 @@ class TodoStack:
lines.append(" (empty group)") lines.append(" (empty group)")
continue continue
for item in group.items: for item in group.items:
number += 1
mark = { mark = {
"pending": "[ ]", "pending": "[ ]",
"in_progress": "[~]", "in_progress": "[~]",
@@ -183,7 +198,7 @@ class TodoStack:
"cancelled": "[-]", "cancelled": "[-]",
}.get(item.status, "[?]") }.get(item.status, "[?]")
note = f" — {item.notes}" if item.notes else "" note = f" — {item.notes}" if item.notes else ""
lines.append(f" {mark} {item.id}: {item.title}{note}") lines.append(f" {mark} {number}. {item.title}{note}")
return "\n".join(lines) return "\n".join(lines)
def turn_reminder_prompt(self) -> str: def turn_reminder_prompt(self) -> str:
@@ -220,7 +235,7 @@ class TodoStack:
headline, headline,
action, action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP = current level; push = one sibling group; pop = whole TOP group.", "Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
"Stack:", "Stack:",
self.render(), self.render(),
] ]
@@ -232,7 +247,7 @@ class TodoStack:
n_open = len(open_items) n_open = len(open_items)
n_groups = self.depth n_groups = self.depth
if n_open: if n_open:
open_titles = "; ".join(f"{item.id}:{item.title}" for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT]) open_titles = "; ".join(item.title for item in open_items[:_REVIEW_OPEN_TITLE_LIMIT])
if n_open > _REVIEW_OPEN_TITLE_LIMIT: if n_open > _REVIEW_OPEN_TITLE_LIMIT:
open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)" open_titles += f"; …(+{n_open - _REVIEW_OPEN_TITLE_LIMIT} more)"
headline = ( headline = (
@@ -258,7 +273,7 @@ class TodoStack:
headline, headline,
action, action,
"Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear", "Tools: todo_list | todo_push(titles=[...]) | todo_update | todo_pop | todo_clear",
"Rules: TOP group = current level; push=one sibling group; pop=remove whole TOP group.", "Rules: TOP = current level; push = one sibling group; pop = whole TOP group; update by listing number.",
"Stack:", "Stack:",
self.render(), self.render(),
] ]
@@ -326,24 +341,24 @@ class TodoStack:
self.mark_touched() self.mark_touched()
return n return n
def get(self, item_id: str) -> TodoItem | None:
for item in self.all_items():
if item.id == item_id:
return item
return None
def update( def update(
self, self,
item_id: str, number: int,
*, *,
title: str | None = None, title: str | None = None,
status: TodoStatus | None = None, status: TodoStatus | None = None,
notes: str | None = None, notes: str | None = None,
) -> TodoItem: ) -> TodoItem:
for group in self._data.groups: """Update the item a listing shows as *number* (1-based, TOP group first).
for index, item in enumerate(group.items):
if item.id != item_id: Numbers are listing positions, not stored ids: a push/pop shifts them, so
continue they have to come from the latest listing.
"""
ordered = self._render_order()
if not 1 <= number <= len(ordered):
msg = f"no todo item {number}: the list has {len(ordered)} item(s); pick a number from the latest listing"
raise ValueError(msg)
group, index, item = ordered[number - 1]
new_title = title.strip() if title is not None else item.title new_title = title.strip() if title is not None else item.title
if not new_title: if not new_title:
msg = "title must not be empty" msg = "title must not be empty"
@@ -354,8 +369,6 @@ class TodoStack:
group.items[index] = updated group.items[index] = updated
self.mark_touched() self.mark_touched()
return updated return updated
msg = f"unknown todo id {item_id!r}"
raise KeyError(msg)
def parse_push_titles(raw: str) -> list[str]: def parse_push_titles(raw: str) -> list[str]:
+18 -13
View File
@@ -420,12 +420,12 @@ def complete_slash_args(
if flags: if flags:
return sorted(set(flags)) return sorted(set(flags))
# Contextual /todos second token: todo ids after done|cancel|pending|in_progress. # Contextual /todos second token: listing numbers after done|cancel|…
if name == "todos" and prior: if name == "todos" and prior:
action = prior[0].lower() action = prior[0].lower()
if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1: if action in {"done", "cancel", "pending", "in_progress"} and len(prior) == 1:
ids = [item.id for item in state.todo_stack.all_items()] numbers = [str(n) for n in range(1, len(state.todo_stack.all_items()) + 1)]
return [i for i in ids if i.startswith(incomplete)] return [n for n in numbers if n.startswith(incomplete)]
# Positional arguments: complete the next unset argument. # Positional arguments: complete the next unset argument.
arg_params = [p for p in cmd.params if isinstance(p, click.Argument)] arg_params = [p for p in cmd.params if isinstance(p, click.Argument)]
@@ -1524,10 +1524,10 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
) -> None: ) -> None:
"""Show or edit the LIFO stack of **task groups**. """Show or edit the LIFO stack of **task groups**.
``/todos`` — list (top group first) ``/todos`` — list (top group first; items numbered as shown)
``/todos push T1; T2`` — one new group of siblings ``/todos push T1; T2`` — one new group of siblings
``/todos pop`` — pop entire top group ``/todos pop`` — pop entire top group
``/todos done <id>`` / ``/todos cancel <id>`` — set status ``/todos done <number>`` / ``/todos cancel <number>`` — set status
``/todos clear`` — wipe stack ``/todos clear`` — wipe stack
""" """
from plyngent.agent.todo_stack import parse_push_titles from plyngent.agent.todo_stack import parse_push_titles
@@ -1552,8 +1552,8 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
click.echo(f"error: {exc}") click.echo(f"error: {exc}")
return return
_await(state.persist_todo_stack()) _await(state.persist_todo_stack())
ids = ", ".join(i.id for i in group.items) pushed = ", ".join(i.title for i in group.items)
click.echo(f"pushed group depth={stack.depth} items=[{ids}]") click.echo(f"pushed group depth={stack.depth} items=[{pushed}]")
click.echo(stack.render()) click.echo(stack.render())
return return
if act == "pop": if act == "pop":
@@ -1562,13 +1562,13 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
click.echo("todo stack empty") click.echo("todo stack empty")
return return
_await(state.persist_todo_stack()) _await(state.persist_todo_stack())
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)" popped = ", ".join(i.title for i in group.items) or "(empty)"
click.echo(f"popped TOP group ({titles})") click.echo(f"popped TOP group ({popped})")
click.echo(stack.render()) click.echo(stack.render())
return return
if act in {"done", "cancel", "pending", "in_progress"}: if act in {"done", "cancel", "pending", "in_progress"}:
if not rest: if not rest:
click.echo(f"error: usage: /todos {act} <id>") click.echo(f"error: usage: /todos {act} <number>")
return return
if act == "done": if act == "done":
new_status = "done" new_status = "done"
@@ -1579,12 +1579,17 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0915 — one branch per /todos action
else: else:
new_status = "in_progress" new_status = "in_progress"
try: try:
item = stack.update(rest[0], status=new_status) number = int(rest[0].strip())
except (KeyError, ValueError) as exc: except ValueError:
click.echo(f"error: /todos {act} takes an item number from the list (e.g. /todos {act} 2)")
return
try:
item = stack.update(number, status=new_status)
except ValueError as exc:
click.echo(f"error: {exc}") click.echo(f"error: {exc}")
return return
_await(state.persist_todo_stack()) _await(state.persist_todo_stack())
click.echo(f"updated {item.id} → {item.status}") click.echo(f"updated {number} → {item.status}")
click.echo(stack.render()) click.echo(stack.render())
return return
if act == "clear": if act == "clear":
+3
View File
@@ -97,6 +97,9 @@ otherwise a small default is used. Never rely on shell to spoof identity.
### Todos ### Todos
- Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \ - Use the todo stack for multi-step work (LIFO groups: push related items, finish/update, \
pop the group). Open items mean unfinished work. pop the group). Open items mean unfinished work.
- A listing numbers items in the order it shows them (TOP group first); those numbers are \
positions, not ids, and shift when the stack changes — `todo_update` takes the number from \
the latest listing.
- The stack does **not** auto-clear. When every task is done (all items done/cancelled), \ - The stack does **not** auto-clear. When every task is done (all items done/cancelled), \
call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behind. call `todo_clear` (or pop finished TOP groups) so no hygiene noise is left behind.
""" """
+27 -13
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.tools.chat.shape import number_arg
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -52,6 +53,10 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
async def todo_list() -> str: async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level). """Show the LIFO stack of **task groups** (TOP group = current breakdown level).
Items are numbered in the order they print (1-based, TOP group first). Those
numbers are listing positions, not stored ids, so they shift whenever the
stack changes — take them from the latest listing.
Non-empty stack with open (pending/in_progress) items = unfinished work. Non-empty stack with open (pending/in_progress) items = unfinished work.
All-terminal but non-empty = hygiene only (pop/clear). Push creates one All-terminal but non-empty = hygiene only (pop/clear). Push creates one
group of siblings; pop removes the whole top group. group of siblings; pop removes the whole top group.
@@ -82,8 +87,8 @@ async def todo_push(titles: list[str], notes: str = "") -> str:
group = stack.push_group(parsed, notes=notes) group = stack.push_group(parsed, notes=notes)
except ValueError as exc: except ValueError as exc:
return f"error: {exc}" return f"error: {exc}"
ids = ", ".join(i.id for i in group.items) pushed = ", ".join(i.title for i in group.items)
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}" return f"pushed group (depth={stack.depth}) items=[{pushed}]\n{stack.render()}"
return await _with_todo_stack(_run) return await _with_todo_stack(_run)
@@ -100,27 +105,36 @@ async def todo_pop() -> str:
group = stack.pop() group = stack.pop()
if group is None: if group is None:
return "todo stack empty" return "todo stack empty"
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)" popped_titles = ", ".join(i.title for i in group.items) or "(empty)"
top = stack.top_group top = stack.top_group
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items) top_titles = "(empty)" if top is None else ", ".join(i.title for i in top.items)
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}" return f"popped TOP group ({popped_titles}); new top group=[{top_titles}]\n{stack.render()}"
return await _with_todo_stack(_run) return await _with_todo_stack(_run)
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE) @tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_update( async def todo_update(
item_id: str, number: int,
status: str = "", status: str = "",
title: str = "", title: str = "",
notes: str = "", notes: str = "",
) -> str: ) -> str:
"""Update a task by id inside any group. """Update a task by the number the listing shows (1-based, TOP group first).
Open items are unfinished work: mark done/cancelled when the work is truly Numbers come from the latest listing and shift when the stack changes, so
finished (not just deferred). Pop the TOP group when that breakdown level re-list rather than reusing an older number. Open items are unfinished work:
is complete so the stack does not linger as false open work. mark done/cancelled when the work is truly finished (not just deferred). Pop
the TOP group when that breakdown level is complete so the stack does not
linger as false open work.
""" """
item_number, number_error = number_arg("todo_update", "number", number, example="2")
if number_error is not None:
return f"error: {number_error}"
assert item_number is not None # number_arg returns a value whenever it accepts
if not item_number.is_integer():
return "error: `todo_update` expects `number` as a whole item number; pass e.g. 2"
target = int(item_number)
def _run(stack: TodoStack) -> str: def _run(stack: TodoStack) -> str:
status_arg: TodoStatus | None = None status_arg: TodoStatus | None = None
@@ -131,14 +145,14 @@ async def todo_update(
status_arg = cast("TodoStatus", token) status_arg = cast("TodoStatus", token)
try: try:
item = stack.update( item = stack.update(
item_id.strip(), target,
title=title if title.strip() else None, title=title if title.strip() else None,
status=status_arg, status=status_arg,
notes=notes if notes != "" else None, notes=notes if notes != "" else None,
) )
except (KeyError, ValueError) as exc: except ValueError as exc:
return f"error: {exc}" return f"error: {exc}"
return f"updated {item.id} → {item.status}: {item.title}\n{stack.render()}" return f"updated {target} → {item.status}: {item.title}\n{stack.render()}"
return await _with_todo_stack(_run) return await _with_todo_stack(_run)
+36 -8
View File
@@ -66,6 +66,29 @@ def test_ids_reuse_after_pop() -> None:
assert g3.items[0].id == "t3" assert g3.items[0].id == "t3"
def test_render_numbers_follow_listing_order() -> None:
"""Numbers are listing positions (TOP group first), never the stored ids."""
stack = TodoStack()
root = stack.push_group(["root", "sibling"]) # stored ids t1, t2
_ = stack.push_group(["child"]) # stored id t3
listing = stack.render()
assert " [ ] 1. child" in listing
assert " [ ] 2. root" in listing
assert " [ ] 3. sibling" in listing
assert "t1" not in listing
assert "t3" not in listing
# update(1) is the first item the listing shows — the one stored as t3.
updated = stack.update(1, status="done")
assert updated.id == "t3"
assert updated.title == "child"
assert updated.status == "done"
assert root.items[0].status == "pending"
with pytest.raises(ValueError, match="no todo item 4"):
stack.update(4, status="done")
def test_push_is_group_not_per_task_stack() -> None: def test_push_is_group_not_per_task_stack() -> None:
"""Multi-title push is one group; pop removes the whole group.""" """Multi-title push is one group; pop removes the whole group."""
stack = TodoStack() stack = TodoStack()
@@ -94,11 +117,16 @@ def test_dfs_breakdown_with_groups() -> None:
root = stack.push_group(["T1", "T2"]) root = stack.push_group(["T1", "T2"])
children = stack.push_group(["T1.1", "T1.2"]) children = stack.push_group(["T1.1", "T1.2"])
assert stack.depth == 2 assert stack.depth == 2
stack.update(children.items[0].id, status="done") # Numbers come from the listing: the pushed children are the TOP group → 1, 2.
stack.update(children.items[1].id, status="done") stack.update(1, status="done")
stack.update(2, status="done")
assert [i.status for i in children.items] == ["done", "done"]
_ = stack.pop() _ = stack.pop()
assert stack.depth == 1 assert stack.depth == 1
stack.update(root.items[0].id, status="done") # Children gone: the root group is the listing again → 1 is T1, 2 is T2.
stack.update(1, status="done")
assert root.items[0].status == "done"
assert root.items[1].status == "pending"
_ = stack.push_group(["T2.1"]) _ = stack.push_group(["T2.1"])
assert stack.depth == 2 assert stack.depth == 2
assert stack.top_group is not None assert stack.top_group is not None
@@ -119,14 +147,14 @@ def test_single_title_still_one_group() -> None:
def test_todo_stack_needs_review() -> None: def test_todo_stack_needs_review() -> None:
stack = TodoStack() stack = TodoStack()
assert not stack.needs_review() assert not stack.needs_review()
item = stack.push("work") _ = stack.push("work")
stack.begin_turn() stack.begin_turn()
# Non-empty + untouched → nag (open or hygiene). # Non-empty + untouched → nag (open or hygiene).
assert stack.needs_review() assert stack.needs_review()
# Any todo_* access this turn suppresses end-of-turn nag, even with open items. # Any todo_* access this turn suppresses end-of-turn nag, even with open items.
stack.mark_touched() stack.mark_touched()
assert not stack.needs_review() assert not stack.needs_review()
stack.update(item.id, status="done") stack.update(1, status="done")
stack.begin_turn() stack.begin_turn()
# Terminal-only stack: same gate (untouched only). # Terminal-only stack: same gate (untouched only).
assert stack.needs_review() assert stack.needs_review()
@@ -212,7 +240,7 @@ def test_inject_todo_nag_strategies() -> None:
def test_todo_prompts_signal_undone_work() -> None: def test_todo_prompts_signal_undone_work() -> None:
stack = TodoStack() stack = TodoStack()
item = stack.push("open work") _ = stack.push("open work")
reminder = stack.turn_reminder_prompt() reminder = stack.turn_reminder_prompt()
assert "[TODO OPEN WORK]" in reminder assert "[TODO OPEN WORK]" in reminder
assert "Stack not empty" in reminder assert "Stack not empty" in reminder
@@ -223,9 +251,9 @@ def test_todo_prompts_signal_undone_work() -> None:
assert "[TODO OPEN WORK]" in review assert "[TODO OPEN WORK]" in review
assert "undone work" in review.lower() or "open item" in review.lower() assert "undone work" in review.lower() or "open item" in review.lower()
assert "open work" in review assert "open work" in review
assert "t1:open work" in review or "open work" in review assert "1. open work" in review # the review embeds the numbered listing
stack.update(item.id, status="done") stack.update(1, status="done")
terminal_review = stack.review_prompt() terminal_review = stack.review_prompt()
assert "[TODO HYGIENE]" in terminal_review assert "[TODO HYGIENE]" in terminal_review
assert "done/cancelled" in terminal_review assert "done/cancelled" in terminal_review
+12 -12
View File
@@ -174,28 +174,28 @@ async def test_pretty_todo_push(capsys: pytest.CaptureFixture[str]) -> None:
) )
) )
result = ( result = (
"pushed group (depth=1) items=[a1]\n" "pushed group (depth=1) items=[T1]\n"
"(LIFO of groups: depth=1; TOP group = current breakdown level)\n" "(LIFO of groups: depth=1; TOP group = current breakdown level)\n"
"group d=0 TOP:\n" "group d=0 TOP:\n"
" [ ] a1: T1" " [ ] 1. T1"
) )
await render_events(_aiter([call, _result(result)])) await render_events(_aiter([call, _result(result)]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "* Todo Push:" in out assert "* Todo Push:" in out
assert " [ ] a1: T1" in out assert " [ ] 1. T1" in out
assert "[tool]" not in out assert "[tool]" not in out
async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None: async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixture[str]) -> None:
"""A batch of todo calls: one summary line, then the last result's stack.""" """A batch of todo calls: one summary line, then the last result's stack."""
stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] a1: T1" stack = "(LIFO of groups: depth=1; TOP group = current breakdown level)\ngroup d=0 TOP:\n [ ] 1. T1"
await render_events( await render_events(
_aiter( _aiter(
[ [
_pretty_call("todo_push", '{"titles": ["T1"]}'), _pretty_call("todo_push", '{"titles": ["T1"]}'),
_pretty_call("todo_update", '{"item_id": "a1", "status": "done"}'), _pretty_call("todo_update", '{"number": 1, "status": "done"}'),
_result(f"pushed group (depth=1) items=[a1]\n{stack}"), _result(f"pushed group (depth=1) items=[T1]\n{stack}"),
_result(f"updated a1 → done: T1\n{stack}"), _result(f"updated 1 → done: T1\n{stack}"),
] ]
) )
) )
@@ -206,14 +206,14 @@ async def test_pretty_todo_batch_merges_to_one_stack(capsys: pytest.CaptureFixtu
# Only the last result is printed, so the stack renders once. # Only the last result is printed, so the stack renders once.
assert out.count("group d=0 TOP:") == 1 assert out.count("group d=0 TOP:") == 1
assert "pushed group" not in out assert "pushed group" not in out
assert "updated a1 → done: T1" in out assert "updated 1 → done: T1" in out
async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call( async def test_pretty_todo_batch_mixed_with_other_tool_stays_per_call(
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
) -> None: ) -> None:
"""A batch holding a non-todo call keeps one line (and stack) per todo call.""" """A batch holding a non-todo call keeps one line (and stack) per todo call."""
stack = "group d=0 TOP:\n [ ] a1: T1" stack = "group d=0 TOP:\n [ ] 1. T1"
await render_events( await render_events(
_aiter( _aiter(
[ [
@@ -867,13 +867,13 @@ async def test_pretty_vcs_kind_branch_and_diff(capsys: pytest.CaptureFixture[str
async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None: async def test_pretty_todo_list_pop_and_clear(capsys: pytest.CaptureFixture[str]) -> None:
stack = " [ ] a1: T1\n [x] a2: T2" stack = " [ ] 1. T1\n [x] 2. T2"
await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)])) await render_events(_aiter([_pretty_call("todo_list", "{}"), _result(stack)]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "* Todo List:" in out assert "* Todo List:" in out
assert " [ ] a1: T1" in out assert " [ ] 1. T1" in out
await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (a1:T1)\n{stack}")])) await render_events(_aiter([_pretty_call("todo_pop", "{}"), _result(f"popped TOP group (T1)\n{stack}")]))
out = capsys.readouterr().out out = capsys.readouterr().out
assert "* Todo Pop:" in out assert "* Todo Pop:" in out
+3 -3
View File
@@ -194,9 +194,9 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
state.todo_stack = TodoStack() state.todo_stack = TodoStack()
_ = state.todo_stack.push_group(["alpha", "beta"]) _ = state.todo_stack.push_group(["alpha", "beta"])
ids = complete_slash_args(state, "/todos", "t", prior_args=["done"]) numbers = complete_slash_args(state, "/todos", "", prior_args=["done"])
assert "t1" in ids assert numbers == ["1", "2"]
assert "t2" in ids assert complete_slash_args(state, "/todos", "2", prior_args=["done"]) == ["2"]
state.remember_session_ids([3, 12, 40]) state.remember_session_ids([3, 12, 40])
assert complete_slash_args(state, "/resume", "1") == ["12"] assert complete_slash_args(state, "/resume", "1") == ["12"]
+23
View File
@@ -757,3 +757,26 @@ async def test_btw_read_notice_states_read_only_scope(
assert "read-only" in notice.body assert "read-only" in notice.body
assert "not saved" in notice.body assert "not saved" in notice.body
assert "[notice] aside: read tools" in capsys.readouterr().out assert "[notice] aside: read tools" in capsys.readouterr().out
async def test_todos_update_takes_a_listing_number(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert await handle_slash(state, "/todos push first; second") is True
out = capsys.readouterr().out
assert "pushed group depth=1 items=[first, second]" in out
assert " [ ] 1. first" in out
assert " [ ] 2. second" in out
assert "t1" not in out
assert await handle_slash(state, "/todos done 2") is True
out = capsys.readouterr().out
assert "updated 2 → done" in out
assert " [x] 2. second" in out
# The stored id is not an address: only listing numbers are.
assert await handle_slash(state, "/todos done t1") is True
assert "takes an item number from the list" in capsys.readouterr().out
assert await handle_slash(state, "/todos done 7") is True
assert "no todo item 7" in capsys.readouterr().out
assert await handle_slash(state, "/todos pop") is True
assert "popped TOP group (first, second)" in capsys.readouterr().out
+27
View File
@@ -101,6 +101,33 @@ async def test_session_on_todo_change_fires() -> None:
assert hits == ["session"] assert hits == ["session"]
async def test_todo_update_uses_listing_numbers() -> None:
store = MemoryViewStore({})
session = SessionState(session_id="numbers", data=session_data_view(store=store))
registry = ToolRegistry(list(TODO_TOOLS), session_state=session)
out = await registry.execute("todo_push", '{"titles": ["root"]}')
assert "pushed group (depth=1) items=[root]" in out
out = await registry.execute("todo_push", '{"titles": ["child"]}')
assert "pushed group (depth=2) items=[child]" in out
# TOP group first; the stored ids stay out of every result.
assert " [ ] 1. child" in out
assert " [ ] 2. root" in out
assert "t1" not in out and "t2" not in out
numbered = await registry.execute("todo_update", '{"number": 2, "status": "done"}')
assert "updated 2 → done: root" in numbered
assert " [x] 2. root" in numbered
stale = await registry.execute("todo_update", '{"number": 9, "status": "done"}')
assert stale.startswith("error: no todo item 9")
shaped = await registry.execute("todo_update", '{"number": "first"}')
assert shaped.startswith("error: `todo_update` expects `number`")
popped = await registry.execute("todo_pop", "{}")
assert "popped TOP group (child); new top group=[root]" in popped
async def test_todo_without_session_errors() -> None: async def test_todo_without_session_errors() -> None:
registry = ToolRegistry(list(TODO_TOOLS)) registry = ToolRegistry(list(TODO_TOOLS))
out = await registry.execute("todo_list", "{}") out = await registry.execute("todo_list", "{}")