fix(tool-todo): reject unknown item keys instead of silently dropping them

An item carrying keys beyond content/status (ids, children, priority) was
flattened to {content, status} on append, so the logged snapshot diverged
from what the model believed it wrote (model-visible must equal logged).
Reject loudly; the isError result lets the model self-correct.
This commit is contained in:
Chinesezjc
2026-07-23 14:10:00 +08:00
parent 0a3c7f2b39
commit 2d95a60ac9
3 changed files with 11 additions and 3 deletions

View File

@@ -14,7 +14,7 @@ The list belongs to the ONE agent session that called the tool. There is no suba
## Validation
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content` and more than one `in_progress` task (a coherent plan has at most one task active). Ordering and the discipline of keeping the list current are left to the model via the tool description.
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, more than one `in_progress` task (a coherent plan has at most one task active), and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. Ordering and the discipline of keeping the list current are left to the model via the tool description.
## Rendering

View File

@@ -28,14 +28,21 @@ const DESCRIPTION =
/**
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
* has already enforced the status enum; the cast below records that guarantee.
* TodoItem}[]: known keys only, trimmed non-empty unique content, and at most one in-progress
* item. The registry has already enforced the status enum; the cast below records that
* guarantee. Unknown keys are rejected rather than dropped — the logged snapshot must equal
* what the model believes it wrote (model-visible ⟺ logged), so a nested/extended item shape
* fails loud instead of silently flattening.
*/
function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
const todos: TodoItem[] = []
const seen = new Set<string>()
let inProgress = 0
for (const item of raw) {
const unknown = Object.keys(item).filter(key => key !== 'content' && key !== 'status')
if (unknown.length > 0) {
throw new Error(`invalid todo: unknown key(s) ${unknown.map(k => JSON.stringify(k)).join(', ')} — each item is exactly { content, status }`)
}
const content = item.content.trim()
if (content.length === 0) {
throw new Error('invalid todo: `content` must be a non-empty string')

View File

@@ -126,6 +126,7 @@ describe('dsh-tool-todo', () => {
{ label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' },
{ label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' },
{ label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' },
{ label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'unknown key' },
])('rejects $label as an isError result', async ({ todos, fragment }) => {
const ctx = await setup()
const result = await callTodo(ctx, { todos })