diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index febb2c9ce0..2e06c7dde5 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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 diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 1da9914ac9..5297103e77 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -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() 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') diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 79758cb3ea..f83b202d32 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -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 })