Skip to content

Commit 67d0d97

Browse files
committed
fix(runtime): 调整 Plan/Build Todo 语义 (#659)
1 parent b88fc2f commit 67d0d97

14 files changed

Lines changed: 99 additions & 69 deletions

docs/session-todo-design.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,10 @@
5858
- `Todo` 是更细粒度的结构化执行状态
5959
- `Todo` 不直接拼入模型消息历史
6060
- 如需让 `TaskState` 汇总 Todo,应在 runtime/context 层显式投影,而不是复用同一个字段
61+
62+
## 与 Plan Mode 的关系
63+
64+
- `CurrentPlan` 是计划上下文,表示 plan 模式产出的草案或已批准计划
65+
- `Session.Todos` 是 build 模式的执行进度状态,不由 plan 模式自动创建或维护
66+
- plan 模式只能研究、澄清和产出计划;即使计划正文包含旧版 `plan_spec.todos`,runtime 也不会把它自动灌入 `Session.Todos`
67+
- build 模式开始复杂执行且没有当前 Todo State 时,应通过 `todo_write action="plan"``todo_write action="add"` 显式创建本轮执行 todo

internal/promptasset/assets_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,12 @@ func TestPlanModePromptTemplates(t *testing.T) {
8989
})
9090
}
9191

92-
if !strings.Contains(PlanModePrompt("plan"), "summary_candidate.active_todo_ids") {
93-
t.Fatalf("expected plan prompt to require active todo ownership")
92+
if strings.Contains(PlanModePrompt("plan"), "summary_candidate.active_todo_ids") ||
93+
strings.Contains(PlanModePrompt("plan"), "must not be empty") {
94+
t.Fatalf("expected plan prompt not to require execution todo ownership")
95+
}
96+
if !strings.Contains(PlanModePrompt("plan"), "Do not create execution todos in plan mode") {
97+
t.Fatalf("expected plan prompt to keep todos in build execution")
9498
}
9599
if !strings.Contains(PlanModePrompt("build_execute"), "create current-run required todos") {
96100
t.Fatalf("expected build prompt to require direct-build todo bootstrap")

internal/promptasset/templates/context/plan_mode_build_execute.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ You are currently in build execution.
44
- If a current plan summary is attached, use it as guidance by default.
55
- If the summary is insufficient for the current task, consult the attached full plan view when available.
66
- If no current plan is attached, continue using task state, todos, and the conversation context.
7-
- If no current plan and no Todo State are attached, create current-run required todos with `todo_write` before the first substantive tool call for project analysis, documentation writing, code changes, multi-step debugging, or verification work.
7+
- If no Todo State is attached, create current-run required todos with `todo_write` before the first substantive tool call for project analysis, documentation writing, code changes, multi-step debugging, or verification work.
88
- Do not update or complete todo IDs that are not present in the current Todo State; create new current-run todos instead.
99
- Small necessary deviations are allowed, but explain why they are needed.
1010
- Do not create or rewrite the current full plan in this stage.

internal/promptasset/templates/context/plan_mode_plan.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ You are currently in the planning stage.
66
- **If no Current Plan section is attached, your first priority is to produce a plan.** The user has entered planning mode expecting a structured plan. Research the codebase as needed, then output a complete `plan_spec` + `summary_candidate` JSON. Do not end the turn with only a conversational answer when there is no existing plan.
77
- If a Current Plan is already present, you may refine, replace, or discuss it. When the user asks a clarifying question or wants to explore options without committing to a new plan revision, you may answer conversationally without outputting planning JSON.
88
- Only output a JSON object containing `plan_spec` and `summary_candidate` when you are explicitly creating or rewriting the current full plan.
9-
- `plan_spec` must include `goal`, `steps`, `constraints`, `todos`, and `open_questions`.
10-
- `plan_spec.todos` **must not be empty**. Populate it with the major actionable items that the plan requires. Each todo must have a unique `id`, a descriptive `content`, and `status: "pending"`. Without todos the plan has no executable work items and the build stage cannot proceed.
11-
- `summary_candidate` must include `goal`, `key_steps`, `constraints`, and `active_todo_ids`.
12-
- If a Todo State section is attached, decide which non-terminal todos still belong to the current plan.
13-
- Todos that still belong to the current plan must appear in `plan_spec.todos` and their IDs must appear in `summary_candidate.active_todo_ids`.
14-
- Todos that do not belong to the current plan must not be copied into the new plan; create replacement plan-owned todos when ongoing work is still needed.
9+
- `plan_spec` must include `goal`, `steps`, `constraints`, and `open_questions`.
10+
- `plan_spec.todos` is optional legacy data. Do not create execution todos in plan mode; build mode will create and maintain runtime todos when implementation starts.
11+
- `summary_candidate` must include `goal`, `key_steps`, and `constraints`.
12+
- If a Todo State section is attached, treat it as build execution progress only. Do not copy, rewrite, or complete those todos while planning.

internal/promptasset/templates/core/capabilities_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ You are currently in plan mode. Write and edit tools are disabled. Only read and
33

44
- Read and search files within the current workspace.
55
- Run non-interactive shell commands for read-only inspection only.
6-
- Maintain explicit task state and todos via `todo_write`.
76
- Ask clarifying questions when requirements are ambiguous or conflicting.
7+
- Produce or refine a plan, but do not create or update execution todos.
88
- **Do not perform any write, edit, delete, or file mutation operations.** Use this stage only for research, analysis, and planning.
99

1010
## Limitations

internal/runtime/planning.go

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,6 @@ func buildPlanArtifact(current *agentsession.PlanArtifact, output planTurnOutput
258258
return plan, nil
259259
}
260260

261-
// applyCurrentPlanRevision 用新 revision 替换当前计划,并清理旧 revision 遗留的对齐状态。
262-
// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。
263261
// resolvePlanDisplayText 优先保留模型对计划的额外说明文本,缺失时回退为规范计划正文。
264262
func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) string {
265263
display := strings.TrimSpace(output.DisplayText)
@@ -269,28 +267,11 @@ func resolvePlanDisplayText(output planTurnOutput, spec agentsession.PlanSpec) s
269267
return strings.TrimSpace(agentsession.RenderPlanContent(spec))
270268
}
271269

270+
// applyCurrentPlanRevision 用新 revision 替换当前计划,并清理计划对齐状态。
272271
func applyCurrentPlanRevision(session *agentsession.Session, plan *agentsession.PlanArtifact) bool {
273272
if session == nil || plan == nil {
274273
return false
275274
}
276-
// 新 revision 覆盖时,仅取消旧 plan 明确引用的非终态 todo
277-
if oldPlan := session.CurrentPlan; oldPlan != nil && oldPlan.Revision < plan.Revision {
278-
agentsession.CancelTodosByIDs(session.Todos, oldPlan.Summary.ActiveTodoIDs)
279-
}
280-
// 将 PlanSpec.Todos 中尚不存在于 session.Todos 的条目补入,
281-
// 避免 plan 模式下模型后续通过 todo_write 引用这些 ID 时找不到。
282-
for _, planTodo := range plan.Spec.Todos {
283-
id := strings.TrimSpace(planTodo.ID)
284-
if id == "" {
285-
continue
286-
}
287-
if _, exists := session.FindTodo(id); exists {
288-
continue
289-
}
290-
if err := session.AddTodo(planTodo); err != nil {
291-
return false
292-
}
293-
}
294275
session.CurrentPlan = plan
295276
session.PlanApprovalPendingFullAlign = false
296277
session.PlanCompletionPendingFullReview = false

internal/runtime/planning_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,55 @@ func TestApplyCurrentPlanRevisionNilGuards(t *testing.T) {
459459
}
460460
}
461461

462+
func TestApplyCurrentPlanRevisionDoesNotMutateExecutionTodos(t *testing.T) {
463+
t.Parallel()
464+
465+
session := agentsession.New("plan revision keeps execution todos")
466+
session.Todos = []agentsession.TodoItem{
467+
{ID: "todo-exec", Content: "current build work", Status: agentsession.TodoStatusInProgress, Revision: 1},
468+
}
469+
session.CurrentPlan = &agentsession.PlanArtifact{
470+
ID: "plan-1",
471+
Revision: 1,
472+
Status: agentsession.PlanStatusDraft,
473+
Spec: agentsession.PlanSpec{
474+
Goal: "old plan",
475+
Steps: []string{"old step"},
476+
},
477+
Summary: agentsession.SummaryView{
478+
Goal: "old plan",
479+
KeySteps: []string{"old step"},
480+
ActiveTodoIDs: []string{"todo-old-plan"},
481+
},
482+
}
483+
484+
next := &agentsession.PlanArtifact{
485+
ID: "plan-1",
486+
Revision: 2,
487+
Status: agentsession.PlanStatusDraft,
488+
Spec: agentsession.PlanSpec{
489+
Goal: "new plan",
490+
Steps: []string{"new step"},
491+
Todos: []agentsession.TodoItem{
492+
{ID: "todo-plan-only", Content: "legacy plan todo", Status: agentsession.TodoStatusPending},
493+
},
494+
},
495+
Summary: agentsession.SummaryView{
496+
Goal: "new plan",
497+
KeySteps: []string{"new step"},
498+
ActiveTodoIDs: []string{"todo-plan-only"},
499+
},
500+
}
501+
502+
if !applyCurrentPlanRevision(&session, next) {
503+
t.Fatal("expected plan revision to apply")
504+
}
505+
if len(session.Todos) != 1 || session.Todos[0].ID != "todo-exec" ||
506+
session.Todos[0].Status != agentsession.TodoStatusInProgress {
507+
t.Fatalf("expected execution todos to remain untouched, got %+v", session.Todos)
508+
}
509+
}
510+
462511
func TestApproveCurrentPlanValidationErrors(t *testing.T) {
463512
t.Parallel()
464513

internal/runtime/runtime_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3954,6 +3954,9 @@ func TestServiceRunPlanModePersistsDraftPlan(t *testing.T) {
39543954
if saved.CurrentPlan.Status != agentsession.PlanStatusDraft {
39553955
t.Fatalf("Status = %q, want %q", saved.CurrentPlan.Status, agentsession.PlanStatusDraft)
39563956
}
3957+
if len(saved.Todos) != 0 {
3958+
t.Fatalf("expected plan mode not to create execution todos, got %+v", saved.Todos)
3959+
}
39573960
if saved.CurrentPlan.Spec.Goal != "为 runtime 引入 plan/build 模式" {
39583961
t.Fatalf("Goal = %q", saved.CurrentPlan.Spec.Goal)
39593962
}
@@ -4019,6 +4022,9 @@ func TestServiceRunPlanModeShowsExplanationTextOutsidePlanningJSON(t *testing.T)
40194022
if saved.CurrentPlan == nil || saved.CurrentPlan.Spec.Goal != "Preserve prose around planning JSON" {
40204023
t.Fatalf("expected current plan to be updated, got %+v", saved.CurrentPlan)
40214024
}
4025+
if len(saved.Todos) != 0 {
4026+
t.Fatalf("expected plan prose turn not to create execution todos, got %+v", saved.Todos)
4027+
}
40224028
if len(saved.Messages) != 3 {
40234029
t.Fatalf("message count = %d, want 3", len(saved.Messages))
40244030
}

internal/runtime/todo_bootstrap.go

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ const todoBootstrapRequiredReason = "todo_bootstrap_required"
1010

1111
const todoBootstrapRequiredReminder = `[Runtime Control]
1212
13-
todo_bootstrap_required: This build run has no current plan and no active todos.
13+
todo_bootstrap_required: This build run has no active todos.
1414
1515
Before project analysis, documentation writing, code changes, multi-step debugging, or verification work, call todo_write with action=plan or action=add to create required todos for this run.
1616
17+
If a Current Plan is attached, use it only as planning context. Create current-run execution todos explicitly instead of assuming plan steps already exist as todos.
18+
1719
Do not update or complete old todo IDs that are not present in the current Todo State.`
1820

19-
// maybeAppendTodoBootstrapReminder 在 direct build 缺少 plan/todo 时注入一次结构化提醒。
21+
// maybeAppendTodoBootstrapReminder 在 build 缺少执行态 todo 时注入一次结构化提醒。
2022
func (s *Service) maybeAppendTodoBootstrapReminder(ctx context.Context, state *runState) error {
2123
if !shouldInjectTodoBootstrapReminder(state) {
2224
return nil
@@ -36,25 +38,12 @@ func shouldInjectTodoBootstrapReminder(state *runState) bool {
3638
if agentsession.NormalizeAgentMode(session.AgentMode) != agentsession.AgentModeBuild {
3739
return false
3840
}
39-
if hasActivePlanForTodoBootstrap(session.CurrentPlan) || len(session.Todos) > 0 {
41+
if len(session.Todos) > 0 {
4042
return false
4143
}
4244
return true
4345
}
4446

45-
// hasActivePlanForTodoBootstrap 判断当前 plan 是否仍可为 build 继承 todo 所有权。
46-
func hasActivePlanForTodoBootstrap(plan *agentsession.PlanArtifact) bool {
47-
if plan == nil {
48-
return false
49-
}
50-
switch agentsession.NormalizePlanStatus(plan.Status) {
51-
case agentsession.PlanStatusDraft, agentsession.PlanStatusApproved:
52-
return true
53-
default:
54-
return false
55-
}
56-
}
57-
5847
const planBootstrapRequiredReason = "plan_bootstrap_required"
5948

6049
const planBootstrapRequiredReminder = `[Runtime Control]
@@ -65,7 +54,7 @@ Before research, analysis, or conversational response, you MUST complete the fol
6554
6655
1. Research the codebase as needed using read-only tools.
6756
2. Output a JSON object containing "plan_spec" and "summary_candidate" that defines the current plan.
68-
3. plan_spec.todos must be non-empty — include major actionable items with unique IDs and status "pending".
57+
3. Focus plan_spec on goal, steps, constraints, and open_questions. Do not create execution todos in plan mode.
6958
7059
Do not end this turn without producing a plan.`
7160

internal/runtime/todo_bootstrap_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestShouldInjectTodoBootstrapReminder(t *testing.T) {
3838
want: true,
3939
},
4040
{
41-
name: "active plan skips",
41+
name: "active plan without execution todos injects",
4242
state: runState{
4343
session: agentsession.Session{
4444
AgentMode: agentsession.AgentModeBuild,
@@ -49,7 +49,7 @@ func TestShouldInjectTodoBootstrapReminder(t *testing.T) {
4949
userGoal: "请分析项目并写文档",
5050
planningEnabled: true,
5151
},
52-
want: false,
52+
want: true,
5353
},
5454
{
5555
name: "existing todo skips",

0 commit comments

Comments
 (0)