Skip to content

Commit fc67296

Browse files
elkaixcursoragent
andcommitted
fix(tools): normalize Cursor TodoWrite shape and compact failed todo cards
Boundary-normalize content→title before SetTodoList validation, persist canonical title-only session state, and render validation failures as compact actionable errors instead of a fake persisted todo tree. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7550b2b commit fc67296

5 files changed

Lines changed: 381 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
2929
hidden/deferred tools exist (pythinker loads no tools lazily), removing the prompt
3030
that primed the loop in the first place.
3131
- **Output-token-limit nudge text aligned with reference.** The system-reminder injected when a response is cut off by the output token limit now matches the reference byte-exactly: "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces."
32-
- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) now validate and persist correctly instead of failing with missing-`title` errors.
32+
- **`SetTodoList` accepts Cursor-style todo payloads.** Todo items sent with `content` instead of `title` (the shape models learn from Cursor/Claude `TodoWrite`) are normalized at the validation boundary (`content``title` when `title` is absent; canonical `title` wins; `content` is dropped) and persist as title-only session state instead of failing with missing-`title` errors.
33+
- **Failed `SetTodoList` cards stay compact.** Validation failures no longer render a broken todo tree with blank labels plus a raw Pydantic dump; the card shows a short actionable summary (with full detail only when expanded).
3334
- **ToolSearch scrollback suppression.** Consecutive `ToolSearch` probes during deferred tool discovery are now collapsed: only the last probe in each run is shown in the transcript, mirroring the blackbox `isAbsorbedSilently` contract. Intermediate discovery calls no longer produce repeated "Tools(…)" lines.
3435
- **Bare skill/flow slash names.** The slash menu now matches `skill:`/`flow:`
3536
commands on their bare segment, so typing `/designer` (or `/design`) surfaces

src/pythinker_code/tools/todo/__init__.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,47 @@
2121
}
2222

2323

24+
def normalize_set_todo_list_args(args: dict[str, Any]) -> dict[str, Any]:
25+
"""Accept Cursor/Claude TodoWrite shape while keeping internal state canonical.
26+
27+
Supported external aliases:
28+
- ``content`` -> ``title``, only when ``title`` is missing
29+
30+
Deliberately does not:
31+
- invent titles
32+
- coerce invalid status values
33+
- accept random aliases like text/name/label
34+
- mutate the input dict
35+
"""
36+
todos = args.get("todos")
37+
if not isinstance(todos, list):
38+
return args
39+
40+
normalized: list[Any] = []
41+
42+
for raw in cast("list[Any]", todos):
43+
if not isinstance(raw, dict):
44+
normalized.append(raw)
45+
continue
46+
47+
item = dict(cast(dict[str, Any], raw))
48+
49+
title = item.get("title")
50+
content = item.get("content")
51+
52+
if title is None and content is not None:
53+
item["title"] = content
54+
55+
item.pop("content", None)
56+
normalized.append(item)
57+
58+
return {**args, "todos": normalized}
59+
60+
2461
class Todo(BaseModel):
2562
title: str = Field(description="The title of the todo", min_length=1)
2663
status: TodoStatus = Field(description="The status of the todo")
2764

28-
@model_validator(mode="before")
29-
@classmethod
30-
def _normalize_todo_write_shape(cls, data: Any) -> Any:
31-
"""Accept Cursor/Claude TodoWrite shapes that use ``content`` instead of ``title``."""
32-
if not isinstance(data, dict):
33-
return data
34-
values = dict(cast(dict[str, Any], data))
35-
if "title" not in values and "content" in values:
36-
values["title"] = values["content"]
37-
return values
38-
3965
@field_validator("status", mode="before")
4066
@classmethod
4167
def _normalize_status(cls, v: Any) -> Any:
@@ -54,6 +80,13 @@ class Params(BaseModel):
5480
),
5581
)
5682

83+
@model_validator(mode="before")
84+
@classmethod
85+
def _normalize_todo_write_args(cls, data: Any) -> Any:
86+
if not isinstance(data, dict):
87+
return data
88+
return normalize_set_todo_list_args(cast(dict[str, Any], data))
89+
5790
@field_validator("todos", mode="before")
5891
@classmethod
5992
def _parse_todos_string(cls, v: Any) -> Any:

src/pythinker_code/ui/shell/tool_renderers/todo.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010

1111
from __future__ import annotations
1212

13+
import re
1314
from typing import Any, cast
1415

1516
from rich.console import Group, RenderableType
1617
from rich.table import Table
1718
from rich.text import Text
1819

20+
from pythinker_code.ui.shell.components import sanitize_ansi
1921
from pythinker_code.ui.shell.spacing import blank_row
2022
from pythinker_code.ui.shell.tool_renderers import (
2123
ToolRenderContext,
@@ -45,6 +47,42 @@
4547
_TREE_BRANCH = "├─"
4648
_TREE_LAST = "└─"
4749

50+
_MISSING_TITLE_RE = re.compile(r"todos\.\d+\.title", re.MULTILINE)
51+
_UNTITLED_TODO = "Untitled todo"
52+
53+
54+
def _has_cursor_todowrite_shape(args: dict[str, Any]) -> bool:
55+
"""True when args look like Cursor/Claude TodoWrite ({content} without {title})."""
56+
todos = args.get("todos")
57+
if not isinstance(todos, list):
58+
return False
59+
for raw in cast("list[Any]", todos):
60+
if not isinstance(raw, dict):
61+
continue
62+
item = cast(dict[str, Any], raw)
63+
if "content" in item and "title" not in item:
64+
return True
65+
return False
66+
67+
68+
def _failed_todo_badge(todos: list[Any]) -> str:
69+
count = len(todos)
70+
noun = "item" if count == 1 else "items"
71+
return f"update failed · {count} {noun}"
72+
73+
74+
def _summarize_todo_validation_error(text: str, args: dict[str, Any]) -> str:
75+
"""Return a short, actionable summary for SetTodoList validation failures."""
76+
if "Error validating JSON arguments:" not in text:
77+
return "Todo update failed: invalid arguments."
78+
if _MISSING_TITLE_RE.search(text):
79+
if _has_cursor_todowrite_shape(args):
80+
return (
81+
"Todo update failed: each item needs `title` (received `content` without `title`)."
82+
)
83+
return "Todo update failed: each item needs a `title` field."
84+
return "Todo update failed: invalid todo arguments."
85+
4886

4987
def _icon_token(status: str) -> str:
5088
if status == "done":
@@ -56,16 +94,23 @@ def _icon_token(status: str) -> str:
5694
return "muted"
5795

5896

97+
def _clean_todo_title(raw_title: str) -> str:
98+
cleaned = " ".join(sanitize_ansi(raw_title).split()).strip()
99+
return cleaned or _UNTITLED_TODO
100+
101+
59102
def _todo_level_and_title(item: dict[str, Any]) -> tuple[int, str]:
60103
"""Return display nesting level and a cleaned title."""
61104
raw_title = as_str(item.get("title")) or as_str(item.get("content")) or ""
62105
explicit = item.get("level", item.get("depth", item.get("indent")))
106+
cleaned = _clean_todo_title(raw_title)
107+
63108
if isinstance(explicit, int):
64-
return max(0, min(explicit, 6)), raw_title.strip()
109+
return max(0, min(explicit, 6)), cleaned
65110

66111
leading_spaces = len(raw_title) - len(raw_title.lstrip(" "))
67112
level = max(0, min(leading_spaces // 2, 6))
68-
return level, raw_title.strip()
113+
return level, cleaned
69114

70115

71116
def _status_title(status: str, title: str) -> Text:
@@ -82,10 +127,24 @@ def _status_title(status: str, title: str) -> Text:
82127
return fg("tool_output", title)
83128

84129

130+
def _render_error_call(ctx: ToolRenderContext) -> RenderableType:
131+
args = ctx.args or {}
132+
todos = args.get("todos")
133+
badge = "update failed"
134+
if isinstance(todos, list):
135+
badge = _failed_todo_badge(cast("list[Any]", todos))
136+
header = tool_call_header("todos", fg("error", badge), style_token="error")
137+
return running_spinner(
138+
header, execution_started=ctx.execution_started, has_result=ctx.has_result
139+
)
140+
141+
85142
def _render_call(ctx: ToolRenderContext) -> RenderableType:
86143
args = ctx.args or {}
144+
if ctx.is_error:
145+
return _render_error_call(ctx)
87146
todos = args.get("todos")
88-
style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted"
147+
style_token = "success" if ctx.has_result else "muted"
89148

90149
if todos is None:
91150
header = tool_call_header("todos", fg("muted", "read"), style_token=style_token)
@@ -163,15 +222,18 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType:
163222
def _render_result(ctx: ToolRenderContext, result: ToolResultPayload) -> RenderableType | None:
164223
if not result.text or not result.is_error:
165224
return None
225+
summary = fg("error", _summarize_todo_validation_error(result.text, ctx.args or {}))
226+
if not ctx.expanded:
227+
return summary
166228
body, _ = format_lines_block(
167229
result.text,
168230
expanded=True,
169231
collapsed_max_lines=0,
170232
style_token="error",
171233
)
172234
if not body.plain:
173-
return None
174-
return body
235+
return summary
236+
return Group(summary, body)
175237

176238

177239
TODO_RENDERER = ToolRenderDefinition(

tests/tools/test_todo.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from pythinker_code.scratchpad import session_scratch_path
1010
from pythinker_code.soul.agent import Runtime
11-
from pythinker_code.tools.todo import Params, SetTodoList, Todo
11+
from pythinker_code.tools.todo import Params, SetTodoList, Todo, normalize_set_todo_list_args
1212
from pythinker_code.wire.types import TodoListUpdated
1313

1414

@@ -27,6 +27,76 @@ def set_todo_list_tool(runtime: Runtime) -> SetTodoList:
2727
return SetTodoList(runtime)
2828

2929

30+
class TestNormalizeSetTodoListArgs:
31+
"""Boundary normalization: one compatibility alias (content → title), strict elsewhere."""
32+
33+
def test_normalizes_cursor_todowrite_content_to_title(self):
34+
args = {
35+
"todos": [{"id": "1", "content": "Install framer-motion", "status": "in_progress"}]
36+
}
37+
out = normalize_set_todo_list_args(args)
38+
assert out["todos"][0]["title"] == "Install framer-motion"
39+
assert "content" not in out["todos"][0]
40+
41+
def test_title_wins_over_content(self):
42+
args = {
43+
"todos": [
44+
{
45+
"title": "Canonical",
46+
"content": "Alias",
47+
"status": "pending",
48+
}
49+
]
50+
}
51+
out = normalize_set_todo_list_args(args)
52+
assert out["todos"][0]["title"] == "Canonical"
53+
assert "content" not in out["todos"][0]
54+
55+
def test_normalizer_does_not_mutate_input(self):
56+
args = {
57+
"todos": [{"content": "Install framer-motion", "status": "pending"}]
58+
}
59+
original = {
60+
"todos": [{"content": "Install framer-motion", "status": "pending"}]
61+
}
62+
normalize_set_todo_list_args(args)
63+
assert args == original
64+
65+
def test_missing_title_still_fails_after_normalization(self):
66+
args = {"todos": [{"id": "1", "status": "pending"}]}
67+
out = normalize_set_todo_list_args(args)
68+
assert "title" not in out["todos"][0]
69+
70+
def test_invalid_status_is_not_repaired(self):
71+
args = {"todos": [{"content": "Install framer-motion", "status": "started"}]}
72+
out = normalize_set_todo_list_args(args)
73+
assert out["todos"][0]["title"] == "Install framer-motion"
74+
assert out["todos"][0]["status"] == "started"
75+
76+
def test_non_list_todos_passthrough(self):
77+
args = {"todos": {"title": "bad"}}
78+
assert normalize_set_todo_list_args(args) == args
79+
80+
def test_params_title_wins_over_content(self):
81+
params = Params(
82+
todos=[{"title": "Use this", "content": "Do not use this", "status": "pending"}] # type: ignore[list-item]
83+
)
84+
assert params.todos is not None
85+
assert params.todos[0].title == "Use this"
86+
87+
def test_params_empty_title_fails_validation(self):
88+
from pydantic import ValidationError
89+
90+
with pytest.raises(ValidationError):
91+
Params(todos=[{"title": "", "status": "pending"}]) # type: ignore[list-item]
92+
93+
def test_params_invalid_status_fails_validation(self):
94+
from pydantic import ValidationError
95+
96+
with pytest.raises(ValidationError):
97+
Params(todos=[{"content": "Install framer-motion", "status": "started"}]) # type: ignore[list-item]
98+
99+
30100
class TestParamsJsonStringCoercion:
31101
"""Regression: LLM occasionally passes todos as a JSON-encoded string instead of a list."""
32102

@@ -68,6 +138,53 @@ def test_todo_write_merge_field_is_ignored(self):
68138
assert params.todos is not None
69139
assert params.todos[0].title == "Task A"
70140

141+
async def test_cursor_todowrite_shape_persists_normalized_title(
142+
self, set_todo_list_tool: SetTodoList, runtime: Runtime
143+
):
144+
"""Cursor/Claude payloads must normalize ``content`` → ``title`` and persist."""
145+
from pythinker_code.session_state import load_session_state
146+
147+
params = Params(
148+
todos=[{"id": "1", "content": "Install framer-motion", "status": "in_progress"}] # type: ignore[list-item]
149+
)
150+
result = await set_todo_list_tool(params)
151+
152+
assert not result.is_error
153+
state = load_session_state(runtime.session.dir)
154+
assert len(state.todos) == 1
155+
assert state.todos[0].title == "Install framer-motion"
156+
assert state.todos[0].status == "in_progress"
157+
158+
async def test_callable_tool_call_accepts_content_shape(
159+
self, set_todo_list_tool: SetTodoList
160+
):
161+
"""``CallableTool2.call`` must accept raw JSON with ``content`` items."""
162+
result = await set_todo_list_tool.call(
163+
{
164+
"todos": [
165+
{"id": "1", "content": "Install framer-motion", "status": "in_progress"},
166+
]
167+
}
168+
)
169+
assert not result.is_error
170+
assert "Todo list updated" in result.output
171+
172+
async def test_validation_failure_does_not_mutate_session(
173+
self, set_todo_list_tool: SetTodoList, runtime: Runtime
174+
):
175+
"""Failed SetTodoList must not change persisted todo state."""
176+
from pythinker_code.session_state import load_session_state
177+
178+
await set_todo_list_tool(Params(todos=[Todo(title="Existing", status="pending")]))
179+
result = await set_todo_list_tool.call(
180+
{"todos": [{"content": "Replacement", "status": "started"}]}
181+
)
182+
assert result.is_error
183+
state = load_session_state(runtime.session.dir)
184+
assert len(state.todos) == 1
185+
assert state.todos[0].title == "Existing"
186+
assert state.todos[0].status == "pending"
187+
71188
def test_todos_none_still_works(self):
72189
params = Params(todos=None)
73190
assert params.todos is None

0 commit comments

Comments
 (0)