Claude Agent SDK 的待办追踪机制:todo 不只是给人看的
先摆出这篇要解决的具体处境:你写了一段监听 Agent 进度的代码,逻辑是遍历 assistant 消息里的 content,匹配 block.name === "TodoWrite",然后把 block.input.todos 渲染成一个进度列表——这正是官方文档《Todo Lists》页给出的监听示例的写法。可是换一个会话跑,这段代码一条都收不到,进度条一直是空的。为什么会这样,官方文档这一页恰好把原因写全了。
Claude Agent SDK 官方文档的《Todo Lists》页(code.claude.com/docs/en/agent-sdk/todo-tracking)把这件事的来龙去脉写得挺清楚,只是分散在几个小节里,单看任何一节都不够。下面沿着文档描述的路径走一遍。
第一道岔口:这个会话里到底有没有这几个工具
文档开头有一个 Note,直接给了答案:在 TypeScript Agent SDK 0.3.233 及以后、Python Agent SDK 0.2.139 及以后,TodoWrite、TaskCreate、TaskGet、TaskUpdate、TaskList 这五个工具,在文档点名的那几个模型系列(以及这些系列的后续版本)上默认不提供,除非你显式开启。在其它模型上,Claude Code 默认给的是 Task 系列工具,而 TodoWrite 只有在你设置 CLAUDE_CODE_ENABLE_TASKS=0 时才提供。
这两句话合在一起就解释了开头那个现象的两种可能:要么这个会话里五个工具一个都没有,要么工具在、但给的是 Task 系列,而你的代码只匹配 TodoWrite 这个名字。文档在「Model availability」一节里把前一种情况的表现写死了——在那些模型上,消息流里根本看不到这些工具的 tool_use 块。不是漏了,是压根没产生。
文档给的开启办法有三条,任选其一:
- 在
allowedTools(Python 里是allowed_tools)选项里点名其中某个工具 - 用
tools选项列出工具。文档特别注明这个选项会把会话的内置工具限制成它列出的那些,所以你要把自己还在用的其它内置工具一并写进去 - 在
env选项里设CLAUDE_CODE_ENABLE_TODO_TOOLS=1,这也是该页所有示例采用的写法
第三条有个跨端差异值得单独拎出来,因为它坑的不是逻辑而是环境:文档写明,TypeScript 里 env 是替换子进程环境,所以要展开 ...process.env 才能保住继承下来的变量;Python 里 env 是合并到继承环境之上的。这条差异在 Windows 上尤其要留意。作为一条与产品无关的通用经验:Windows 的进程环境里本来就带着一批由系统注入的变量,子进程整体换掉环境之后,报错未必出现在 SDK 这一层,容易把排查方向带偏。这一句是通用工程常识,不是官方文档的内容;至于 Windows 上具体哪些变量不能丢,官方文档没有说明这一点,文档只给了「在 TypeScript 里展开 ...process.env」这一个办法。
还有一条容易被忽略:如果你在 Python 里指定了 cli_path、或在 TypeScript 里指定了 pathToClaudeCodeExecutable,指向自己的 Claude Code 安装,那么你拿到的是那个安装提供的工具集。也就是说上面这套判断在自带安装的场景下要重新走一遍。
状态流转:四个节点,最后一个不是「消失」
文档的「Todo Lifecycle」一节把流转写成四步:
- Created:识别出一件任务时,以
pending加入 - Activated:开始做的时候置为
in_progress - Completed:任务顺利结束时标记为完成
- Removed:不再需要的待办,通过
TaskUpdate调用里status: "deleted"来删除
前三步是常识,第四步是容易写错监听代码的地方。删除在这里不是「从列表里消失」这样一个独立动作,而是沿用同一个 status 字段的一个取值。你如果按状态枚举去做分支,只处理 pending / in_progress / completed 三档,那么 deleted 会落进 else 分支。文档自己给的那段监听示例就是这个形状:一个三元表达式,completed 一个图标、in_progress 一个图标,其余状态一律落到同一个图标上。文档在迁移表里把取值写得很明确:status 是 "pending"、"in_progress" 或 "completed",而设置 status: "deleted" 表示删除。
至于什么时候会产生待办,文档在「When Todos Are Used」里列了四类场景:需要三个或更多不同动作的多步任务、用户自己给出多条任务的清单、值得跟踪进度的非平凡操作、以及用户明确要求做待办组织的时候。同一节末尾还有一句限定:很短或单步的请求可能会跳过待办。所以「进度列表是空的」本身不构成异常信号,得先确认这次请求属不属于上面四类。
TodoWrite 与 Task 工具:全量重写 vs 按 ID 打补丁
这是这一页信息量最大的地方,文档用一张迁移对照表讲清了差别。挑与监听代码直接相关的几行说:
用 TodoWrite | 用 Task 工具 |
|---|---|
一次调用重写整个 todos 数组 | TaskCreate 增加一条,TaskUpdate 按 taskId 修改一条 |
匹配 block.name === "TodoWrite" | 匹配 block.name === "TaskCreate" 或 "TaskUpdate" |
条目形状:{ content, status, activeForm } | TaskCreate 入参:{ subject, description, activeForm?, metadata? } |
直接渲染 block.input.todos | 跨多次调用累积,或从 TaskList 的工具结果里读一份快照 |
TaskUpdate 的入参文档列的是 { taskId, status?, subject?, description?, activeForm?, addBlocks?, addBlockedBy?, owner?, metadata? }。其中 addBlocks、addBlockedBy、owner、metadata 这几个字段,文档在这一页只给了字段名,没有说明它们的语义与取值形式,也没有给出示例。名字听上去像是什么意思是一回事,文档写没写是另一回事,这里就停在「文档没说」为止,不替它补。
对做进度展示的人来说,这张表的实质差别是:TodoWrite 模式下每次调用你都拿到一份完整快照,直接替换本地状态就行;Task 工具模式下你拿到的是增量事件,必须自己维护一张以任务 ID 为键的表。文档原话就是把整份列表替换改成按 ID 维护映射。
而这里埋着一个具体的坑:TaskCreate 的入参里没有任务 ID。文档写明 ID 是在对应的 tool_result 里以 { task: { id, subject } } 的形式回来的,你得从结果块里去捞。只读 tool_use 块的监听代码——包括文档自己给的那段最小改动示例——是拿不到 ID 的,文档也直说了那个示例「只读 tool_use 入参,跳过了从 tool_result 捕获 ID」。要渲染完整列表,文档给的两条路是:在流里等一个 TaskList 的工具结果,或者把 TaskCreate 的结果与 TaskUpdate 的入参累积进一张映射表。
顺带一提,TaskList 和 TaskGet 的存在本身就是一处行为差异:文档说这两个工具是给模型自己读回当前列表用的。TodoWrite 那套里没有对应的读回入口,模型每次是重写整个数组。这一点文档只陈述到这里,再往下推「所以模型的行为会怎样」就是我在编了。
流里看到的不是执行时用的那份
这一页还有一段提醒,属于「不看文档绝对想不到」的类型:流里的 tool_use 入参是模型原样吐出来的形状。Claude Code 会在执行前修复一些接近但不正确的键名——把 id 或 task_id 映射成 taskId,把 active_form 映射成 activeForm——但这个修复不会体现在流里。
所以文档给的建议是防御性地读 TaskUpdate 的入参字段,别假设规范名一定在。它自己的示例就是这么写的:
const input = block.input as {
taskId?: string;
id?: string;
task_id?: string;
status?: string;
};
const taskId = input.taskId ?? input.id ?? input.task_id;
if (taskId && input.status) console.log(` ${taskId} -> ${input.status}`);
Python 侧对应的写法同样是三个键依次兜底:
task_id = (
block.input.get("taskId")
or block.input.get("id")
or block.input.get("task_id")
)
这两段是从官方文档示例里原样抄下来的。这类修复清单属于实现细节,随版本变动很正常,以官方文档最新内容为准。
还有一个渲染细节:activeForm
条目形状里的 activeForm 不是装饰。文档的实时进度展示示例里,取文案的逻辑是:状态为 in_progress 时渲染 todo.activeForm,否则渲染 todo.content。也就是说同一条待办在「正在做」和「没在做」两种状态下,展示的是两个不同的字段。监听代码如果只读 content,进行中的那条会显示成静态描述,不影响正确性,但你就白丢了这个字段。
在 TaskCreate 的入参里,activeForm 是可选的(activeForm?),TaskUpdate 里同样可选,所以渲染时得考虑它不存在的情况。
循环什么时候结束
最后补一条与状态流转直接相关的边界。文档说这一页的例子都是单次 query() 调用,跑到 Agent 结束、产出最终 result 消息为止;如果会话先撞到轮次上限,那条 result 消息的 subtype 是 error_max_turns,靠检查 subtype 就能识别这个结局。文档还写明,单次 query() 在产出 error_max_turns 结果之后会抛错,错误信息里包含 Reached maximum number of turns——所以那一页每个示例都把循环包在 try 块里。
这条对做进度展示的影响很直接:进度列表停在一堆 in_progress 上、再也不动了,未必是待办机制出了问题,先看你有没有吞掉那个异常。
以上代码片段抄自官方文档中的示例,其余字段语义按文档原文转述,未经实测,以官方文档与 --help 的实际输出为准。该产品迭代频繁,文中涉及的工具名、字段名、环境变量与 SDK 版本要求随版本变动,请以官方文档最新内容为准。
本文依据 Claude Code 官方文档(code.claude.com/docs)于 2026-08-17 的公开内容整理。
该产品闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观、操作手感与运行速度的任何描述。
该产品迭代频繁,文中涉及的命令、配置项与默认值随版本变动,请以官方文档最新内容为准。
本文不涉及价格、额度与限流的具体数值,相关信息请以官方定价与用量说明页为准。