Preserve the single in_progress contract for phase chains

The first level-aware pass required a phase and its active sub-step to
both be in_progress, contradicting the provider-visible todo_write
contract (exactly one in_progress item). The active sub-step is now the
only in_progress item: its phase stays pending while sub-steps complete
in order, then the phase becomes the single in_progress item and signs
off last, so the tool description and schema stay true byte-for-byte.

todo_write now rejects a level-1 sub-step with no phase above it, and
AdvanceSerialTodo advances legacy orphan sub-steps as standalone steps
instead of stalling the list with no current item.
This commit is contained in:
SivanCola committed 2026-07-15 13:43:06 +08:00
1 parent fb37b9b5b6
commit c8723be962
10 files changed
+253 -120

No files matched your search

+4 -3
View File
@@ -31,9 +31,10 @@ branch.
stalled current item receives a recovery nudge after 8 tool-call rounds with
no new completion, unique read, command, or mutation, and pauses with saved
work after 16. Exact repeats do not renew the progress lease; real work does.
Two-level task lists keep one current chain: the active phase stays
in_progress with at most one active sub-step, and a phase signs off only
after all of its sub-steps are completed.
Two-level task lists keep the single in_progress contract: the active
sub-step is the only current item while its phase stays pending, and the
phase becomes in_progress to sign off only after all of its sub-steps are
completed. A level-1 sub-step with no phase header above it is rejected.
Executor and planner rounds now use automatic progress management. Retired
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
but are ignored and removed by a one-time migration so stale hidden limits
+14 -3
View File
@@ -178,7 +178,7 @@ func TestHistoryMessagesGatePhaseSignoffOnSubSteps(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "todo-1", Name: "todo_write",
Arguments: `{"todos":[{"content":"Port the parser","status":"in_progress"},{"content":"move files","status":"in_progress","level":1},{"content":"fix imports","status":"pending","level":1}]}`,
Arguments: `{"todos":[{"content":"Port the parser","status":"pending"},{"content":"move files","status":"in_progress","level":1},{"content":"fix imports","status":"pending","level":1}]}`,
}}},
{Role: provider.RoleTool, ToolCallID: "todo-1", Name: "todo_write", Content: "Todos updated"},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
@@ -192,8 +192,8 @@ func TestHistoryMessagesGatePhaseSignoffOnSubSteps(t *testing.T) {
}
payload := restoredTodoPayload(t, msgs, "todo-1")
if got := payload.Todos[0].Status; got != "in_progress" {
t.Fatalf("phase sign-off with unfinished sub-steps replayed to %q, want in_progress", got)
if got := payload.Todos[0].Status; got != "pending" {
t.Fatalf("phase sign-off with unfinished sub-steps replayed to %q, want pending", got)
}
if got := payload.Todos[1].Status; got != "completed" {
t.Fatalf("signed-off sub-step replayed to %q, want completed", got)
@@ -201,4 +201,15 @@ func TestHistoryMessagesGatePhaseSignoffOnSubSteps(t *testing.T) {
if got := payload.Todos[2].Status; got != "in_progress" {
t.Fatalf("next sub-step should be promoted, got %q", got)
}
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "step-3", Name: "complete_step", Arguments: `{"step":"fix imports"}`,
}}},
provider.Message{Role: provider.RoleTool, ToolCallID: "step-3", Name: "complete_step", Content: "signed off"},
)
payload = restoredTodoPayload(t, msgs, "todo-1")
if got := payload.Todos[0].Status; got != "in_progress" {
t.Fatalf("phase should become signable after its sub-steps, got %q", got)
}
}
+5 -4
View File
@@ -881,10 +881,11 @@ normal execution automatically: if an active todo produces no new completion,
unique read, command, or mutation for 8 tool-call rounds, the host asks the
executor to reassess. After 16 no-progress rounds it pauses with saved work that
can be resumed in the next user turn. Exact repeats do not count as progress;
new host-observed work renews the lease. Two-level task lists follow the same
serial contract as one chain: the current level-0 phase stays `in_progress`
while exactly one of its level-1 sub-steps is worked and signed off in order,
and the phase itself signs off last, after every sub-step has completed.
new host-observed work renews the lease. Two-level task lists keep the same
single-current contract: the active level-1 sub-step is the one `in_progress`
item while its level-0 phase stays `pending`; sub-steps are worked and signed
off in order, and once every sub-step has completed the phase itself becomes
`in_progress` for its own final sign-off.
Existing `[agent].max_steps` and `planner_max_steps` keys remain syntactically
accepted during upgrades, but their values are ignored and removed with a
+3 -2
View File
@@ -699,8 +699,9 @@ Planner 会看到已加载的 `REASONIX.md` / `AGENTS.md` 记忆,并拿到一
Reasonix 会自动管理正常执行:活跃 Todo 连续 8 个工具调用轮次没有新的完成项、唯一读取、
命令或修改时,宿主会要求执行器重新评估;连续 16 个无进展轮次后暂停并保存工作,可在
下一轮用户消息中继续。完全重复的操作不算进展,新的宿主可观测工作会自动续期。两级任务
列表遵循同一串行契约:当前 level-0 阶段保持 `in_progress`,其 level-1 子步骤按顺序逐个
推进并签核,全部子步骤完成后阶段本身最后签核。
列表保持同一"唯一当前项"契约:唯一的 `in_progress` 是活跃的 level-1 子步骤,其 level-0
阶段保持 `pending`;子步骤按顺序推进并签核,全部完成后阶段本身转为 `in_progress` 做
最后签核。
升级时仍可解析已有的 `[agent].max_steps` 和 `planner_max_steps`,但其值会被忽略,并在一次性
迁移提示后从配置中移除,避免隐藏的旧上限截断自动进度管理或子 Agent 的继承任务。确实需要
+6 -4
View File
@@ -648,10 +648,12 @@ The executor tracks an adaptive progress lease while a todo is active. A new
completion, unique successful read, command, or mutation renews the lease;
exact repeats do not. After 8 no-progress tool-call rounds the host appends a
one-shot reassessment nudge. After 16 it pauses and preserves work for a later
user turn. The serial contract is level-aware: a two-level list forms one
current chain — the active level-0 phase is `in_progress` together with at
most one of its level-1 sub-steps, sub-steps complete in order, and the phase
completes only after all of its sub-steps. Retired `[agent].max_steps` and `planner_max_steps` keys remain
user turn. The serial contract is level-aware while preserving the
single-in_progress rule: in a two-level list the active level-1 sub-step is
the only `in_progress` item and its level-0 phase stays `pending`; sub-steps
complete in order, and the phase becomes `in_progress` — and signs off — only
after all of its sub-steps have completed. A level-1 item with no phase above
it is rejected. Retired `[agent].max_steps` and `planner_max_steps` keys remain
parseable for upgrade compatibility, but are ignored and removed by a one-time
migration. The CLI `--max-steps` flag and `[bot].max_steps` remain separate,
explicit controls for one-off and unattended execution.
+6 -6
View File
@@ -237,7 +237,7 @@ func TestSeedTodoStateAllowsAdvanceAfterSeed(t *testing.T) {
func TestAdvanceCanonicalTodoWalksPhaseChain(t *testing.T) {
a := &Agent{sink: event.Discard, todoState: []evidence.TodoItem{
{Content: "Port the parser", Status: "in_progress"},
{Content: "Port the parser", Status: "pending"},
{Content: "move files", Status: "in_progress", Level: 1},
{Content: "fix imports", Status: "pending", Level: 1},
{Content: "Ship it", Status: "pending"},
@@ -245,8 +245,8 @@ func TestAdvanceCanonicalTodoWalksPhaseChain(t *testing.T) {
}}
a.advanceCanonicalTodo("Port the parser")
if a.todoState[0].Status != "in_progress" {
t.Fatalf("phase completed before its sub-steps: %+v", a.todoState)
if a.todoState[0].Status != "pending" {
t.Fatalf("pending phase advanced ahead of its sub-steps: %+v", a.todoState)
}
a.advanceCanonicalTodo("move files")
@@ -256,11 +256,11 @@ func TestAdvanceCanonicalTodoWalksPhaseChain(t *testing.T) {
a.advanceCanonicalTodo("fix imports")
if a.todoState[2].Status != "completed" || a.todoState[0].Status != "in_progress" {
t.Fatalf("after the last sub-step the phase should stay current: %+v", a.todoState)
t.Fatalf("after the last sub-step the phase should become the signable item: %+v", a.todoState)
}
a.advanceCanonicalTodo("Port the parser")
if a.todoState[0].Status != "completed" || a.todoState[3].Status != "in_progress" || a.todoState[4].Status != "in_progress" {
t.Fatalf("phase sign-off should promote the next phase with its first sub-step: %+v", a.todoState)
if a.todoState[0].Status != "completed" || a.todoState[3].Status != "pending" || a.todoState[4].Status != "in_progress" {
t.Fatalf("phase sign-off should promote the next phase's first sub-step: %+v", a.todoState)
}
}
+105 -55
View File
@@ -28,13 +28,30 @@ type TodoItem struct {
}
// ValidateSerialTodos enforces the task-list state machine promised by
// todo_write. Flat lists keep completed items as a prefix, then exactly one
// in_progress item, then pending work. Two-level lists apply the same rule to
// segments — a level-0 phase together with its level-1 sub-steps — so the
// current phase may be in_progress while exactly one of its own sub-steps is
// also in_progress (one current chain), and a phase may complete only after
// every sub-step has completed. A fully completed or empty list is also valid.
// todo_write: at most one item in the whole list is in_progress, completed
// work forms a serial prefix, and pending work follows the current item. The
// rule is segment-aware for two-level lists: a level-0 phase owns the level-1
// sub-steps after it, sub-steps complete in order while their phase stays
// pending, and the phase becomes the single in_progress item only after every
// sub-step has completed — the phase signs off last. A fully completed or
// empty list is also valid.
func ValidateSerialTodos(todos []TodoItem) error {
ipSeen := false
for i, todo := range todos {
switch todoStatus(todo.Status) {
case "completed", "pending":
case "in_progress":
if ipSeen {
return fmt.Errorf("todo %d %q is a second in_progress item; serial task lists allow exactly one current item", i+1, todo.Content)
}
ipSeen = true
default:
return fmt.Errorf("todo %d %q has invalid status %q", i+1, todo.Content, todo.Status)
}
}
if len(todos) > 0 && todos[0].Level == 1 {
return fmt.Errorf("todo 1 %q is a level-1 sub-step with no phase above it; add a level-0 phase header or use level 0", todos[0].Content)
}
seenCurrent := false
seenPending := false
for _, seg := range serialTodoSegments(todos) {
@@ -42,22 +59,37 @@ func ValidateSerialTodos(todos []TodoItem) error {
if err != nil {
return err
}
head := todos[seg.head]
switch state {
case "completed":
if seenCurrent || seenPending {
return fmt.Errorf("todo %d %q is completed after unfinished work; serial task lists require completed items to form a prefix", seg.head+1, head.Content)
return fmt.Errorf("todo %d %q is completed after unfinished work; serial task lists require completed items to form a prefix", seg.head+1, todos[seg.head].Content)
}
case "in_progress":
if seenCurrent {
return fmt.Errorf("todo %d %q is a second in_progress item; serial task lists allow exactly one current item", seg.head+1, head.Content)
}
if seenPending {
return fmt.Errorf("todo %d %q is in_progress after pending work; the current item must be the first unfinished item", seg.head+1, head.Content)
ip := seg.head
for i := seg.head; i < seg.end; i++ {
if todoStatus(todos[i].Status) == "in_progress" {
ip = i
break
}
}
return fmt.Errorf("todo %d %q is in_progress after pending work; the current item must be the first unfinished item", ip+1, todos[ip].Content)
}
seenCurrent = true
case "pending":
seenPending = true
default: // stale: partially completed with no current item
if seenCurrent {
first := seg.head
for i := seg.head; i < seg.end; i++ {
if todoStatus(todos[i].Status) == "completed" {
first = i
break
}
}
return fmt.Errorf("todo %d %q is completed after unfinished work; serial task lists require completed items to form a prefix", first+1, todos[first].Content)
}
seenPending = true
}
}
if len(todos) > 0 && seenPending && !seenCurrent {
@@ -93,49 +125,41 @@ func serialTodoSegments(todos []TodoItem) []todoSegment {
}
// validateSerialSegment checks one segment's internal shape and returns its
// serial state (completed, in_progress, or pending), taken from the head item.
// serial state: "completed" (every item completed), "in_progress" (the
// segment holds the current item), "pending" (untouched), or "stale"
// (partially completed with no current item). Item statuses and the global
// single-in_progress rule are already validated by the caller.
func validateSerialSegment(todos []TodoItem, seg todoSegment) (string, error) {
head := todos[seg.head]
headStatus := todoStatus(head.Status)
switch headStatus {
case "completed", "in_progress", "pending":
default:
return "", fmt.Errorf("todo %d %q has invalid status %q", seg.head+1, head.Content, head.Status)
}
if seg.end == seg.head+1 {
return headStatus, nil
}
seenCurrent := false
seenPending := false
started := false
seenSubCurrent := false
seenSubPending := false
completedSubs := 0
unfinished := -1
for i := seg.head + 1; i < seg.end; i++ {
sub := todos[i]
switch todoStatus(sub.Status) {
case "completed":
if seenCurrent || seenPending {
if seenSubCurrent || seenSubPending {
return "", fmt.Errorf("todo %d %q is completed after unfinished work; serial task lists require completed items to form a prefix", i+1, sub.Content)
}
started = true
completedSubs++
case "in_progress":
if seenCurrent {
return "", fmt.Errorf("todo %d %q is a second in_progress sub-step; a phase allows exactly one current sub-step", i+1, sub.Content)
}
if seenPending {
if seenSubPending {
return "", fmt.Errorf("todo %d %q is in_progress after pending work; the current item must be the first unfinished item", i+1, sub.Content)
}
seenCurrent = true
started = true
seenSubCurrent = true
if unfinished < 0 {
unfinished = i
}
case "pending":
seenPending = true
default: // pending
seenSubPending = true
if unfinished < 0 {
unfinished = i
}
default:
return "", fmt.Errorf("todo %d %q has invalid status %q", i+1, sub.Content, sub.Status)
}
}
switch headStatus {
@@ -143,20 +167,29 @@ func validateSerialSegment(todos []TodoItem, seg todoSegment) (string, error) {
if unfinished >= 0 {
return "", fmt.Errorf("phase %d %q is completed but sub-step %d %q is unfinished; complete every sub-step, then sign the phase off with complete_step", seg.head+1, head.Content, unfinished+1, todos[unfinished].Content)
}
case "pending":
if started {
return "", fmt.Errorf("phase %d %q is pending while its sub-steps have started; keep the active phase in_progress", seg.head+1, head.Content)
return "completed", nil
case "in_progress":
if unfinished >= 0 {
return "", fmt.Errorf("phase %d %q cannot be in_progress while sub-step %d %q is unfinished; keep the phase pending, finish its sub-steps in order, then mark the phase in_progress to sign it off", seg.head+1, head.Content, unfinished+1, todos[unfinished].Content)
}
return "in_progress", nil
default: // pending head: its sub-steps carry the segment's progress
if seenSubCurrent {
return "in_progress", nil
}
if completedSubs == 0 {
return "pending", nil
}
return "stale", nil
}
return headStatus, nil
}
// NormalizeSerialTodos repairs legacy host state that predates
// ValidateSerialTodos. It preserves the leading run of fully completed
// segments and makes the first unfinished segment current: an unfinished
// phase returns to in_progress, keeps its completed sub-step prefix, and its
// first unfinished sub-step (when one exists) becomes in_progress. Every
// later segment returns to pending.
// segments and makes the first unfinished segment current: its completed
// sub-step prefix is kept and its first unfinished sub-step becomes the
// single in_progress item — or the phase itself when every sub-step is
// already completed. Every later segment returns to pending.
func NormalizeSerialTodos(todos []TodoItem) []TodoItem {
out := append([]TodoItem(nil), todos...)
unfinished := false
@@ -171,11 +204,13 @@ func NormalizeSerialTodos(todos []TodoItem) []TodoItem {
continue
}
unfinished = true
out[seg.head].Status = "in_progress"
if seg.end == seg.head+1 {
out[seg.head].Status = "in_progress"
continue
}
subUnfinished := false
for i := seg.head + 1; i < seg.end; i++ {
if !subUnfinished && todoStatus(out[i].Status) == "completed" {
out[i].Status = "completed"
continue
}
if !subUnfinished {
@@ -185,6 +220,11 @@ func NormalizeSerialTodos(todos []TodoItem) []TodoItem {
}
out[i].Status = "pending"
}
if subUnfinished {
out[seg.head].Status = "pending"
} else {
out[seg.head].Status = "in_progress"
}
}
return out
}
@@ -218,12 +258,14 @@ func FirstUnfinishedSubStep(todos []TodoItem, index int) (int, bool) {
}
// AdvanceSerialTodo completes the in_progress item at index (0-based) as a
// signed-off step and promotes the next serial item so the list remains one
// current chain. A phase with unfinished sub-steps does not complete.
// Completing a sub-step promotes its next pending sibling; when none remain,
// its phase stays in_progress as the next signable item. Completing a phase or
// plain step promotes the next pending item — a phase together with its first
// pending sub-step. It reports whether the item was completed.
// signed-off step and promotes the next serial item so exactly one item stays
// current. A phase with unfinished sub-steps does not complete. Completing a
// sub-step promotes its next pending sibling, or returns its phase to
// in_progress for sign-off once every sibling is completed. Completing a
// phase or plain step promotes the next pending unit — a phase's first
// pending sub-step (the phase itself stays pending until its sub-steps
// finish), or the plain step itself. A level-1 item with no phase above it
// advances as a standalone step. It reports whether the item was completed.
func AdvanceSerialTodo(todos []TodoItem, index int) bool {
if index < 0 || index >= len(todos) {
return false
@@ -246,10 +288,15 @@ func AdvanceSerialTodo(todos []TodoItem, index int) bool {
for head >= 0 && todos[head].Level == 1 {
head--
}
if head >= 0 && todoStatus(todos[head].Status) != "completed" {
todos[head].Status = "in_progress"
if head >= 0 {
if todoStatus(todos[head].Status) != "completed" {
todos[head].Status = "in_progress"
}
return true
}
return true
// No phase above: an orphan sub-step falls through and promotes the
// next pending unit like a plain step, so the list keeps one current
// item.
}
for i := range todos {
if todoStatus(todos[i].Status) == "in_progress" {
@@ -260,10 +307,13 @@ func AdvanceSerialTodo(todos []TodoItem, index int) bool {
if todoStatus(todos[i].Status) != "pending" {
continue
}
todos[i].Status = "in_progress"
if sub, ok := FirstUnfinishedSubStep(todos, i); ok && sub >= 0 && todoStatus(todos[sub].Status) == "pending" {
todos[sub].Status = "in_progress"
if sub, ok := FirstUnfinishedSubStep(todos, i); ok && sub >= 0 {
if todoStatus(todos[sub].Status) == "pending" {
todos[sub].Status = "in_progress"
}
return true
}
todos[i].Status = "in_progress"
return true
}
return true
+80 -32
View File
@@ -562,16 +562,16 @@ func TestValidateSerialTodosAcceptsPhaseChains(t *testing.T) {
todos []TodoItem
}{
{
name: "entered phase with pending sub-steps",
name: "entered phase with an active sub-step",
todos: []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "sub one", Status: "pending", Level: 1},
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "in_progress", Level: 1},
},
},
{
name: "one current chain",
name: "single current item mid-chain",
todos: []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "completed", Level: 1},
{Content: "sub two", Status: "in_progress", Level: 1},
{Content: "sub three", Status: "pending", Level: 1},
@@ -593,7 +593,7 @@ func TestValidateSerialTodosAcceptsPhaseChains(t *testing.T) {
todos: []TodoItem{
{Content: "Phase", Status: "completed"},
{Content: "sub one", Status: "completed", Level: 1},
{Content: "Second phase", Status: "in_progress"},
{Content: "Second phase", Status: "pending"},
{Content: "sub two", Status: "in_progress", Level: 1},
},
},
@@ -622,42 +622,50 @@ func TestValidateSerialTodosRejectsInvalidPhaseChains(t *testing.T) {
want: "sub-step 2 \"sub one\" is unfinished",
},
{
name: "active sub-step under a pending phase",
name: "phase in_progress while sub-steps are unfinished",
todos: []TodoItem{
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "Phase", Status: "in_progress"},
{Content: "sub one", Status: "pending", Level: 1},
},
want: "keep the active phase in_progress",
want: "cannot be in_progress while sub-step 2",
},
{
name: "two current sub-steps in one phase",
name: "phase and sub-step both in_progress",
todos: []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "sub two", Status: "in_progress", Level: 1},
},
want: "second in_progress sub-step",
},
{
name: "two current chains",
todos: []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "Second phase", Status: "in_progress"},
{Content: "sub two", Status: "pending", Level: 1},
},
want: "second in_progress item",
},
{
name: "orphan sub-step with no phase above it",
todos: []TodoItem{
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "Next", Status: "pending"},
},
want: "no phase above it",
},
{
name: "completed segment after the current chain",
todos: []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "sub one", Status: "pending", Level: 1},
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "Second phase", Status: "completed"},
{Content: "sub two", Status: "completed", Level: 1},
},
want: "completed after unfinished",
},
{
name: "stale sub-step progress before the current item",
todos: []TodoItem{
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "completed", Level: 1},
{Content: "sub two", Status: "pending", Level: 1},
{Content: "Second phase", Status: "pending"},
{Content: "sub three", Status: "in_progress", Level: 1},
},
want: "in_progress after pending work",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -676,17 +684,26 @@ func TestNormalizeSerialTodosRepairsPhaseChains(t *testing.T) {
{Content: "Second phase", Status: "completed"},
{Content: "sub three", Status: "completed", Level: 1},
})
want := []string{"in_progress", "completed", "in_progress", "pending", "pending"}
want := []string{"pending", "completed", "in_progress", "pending", "pending"}
for i := range want {
if got[i].Status != want[i] {
t.Fatalf("todo %d status = %q, want %q: %+v", i+1, got[i].Status, want[i], got)
}
}
signable := NormalizeSerialTodos([]TodoItem{
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "completed", Level: 1},
{Content: "sub two", Status: "completed", Level: 1},
})
if signable[0].Status != "in_progress" {
t.Fatalf("phase with completed sub-steps should normalize to in_progress for sign-off: %+v", signable)
}
}
func TestAdvanceSerialTodoWalksPhaseChain(t *testing.T) {
todos := []TodoItem{
{Content: "Phase", Status: "in_progress"},
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "in_progress", Level: 1},
{Content: "sub two", Status: "pending", Level: 1},
{Content: "Second phase", Status: "pending"},
@@ -701,12 +718,12 @@ func TestAdvanceSerialTodoWalksPhaseChain(t *testing.T) {
}
if AdvanceSerialTodo(todos, 0) {
t.Fatalf("phase with unfinished sub-steps completed: %v", statuses())
t.Fatalf("pending phase completed ahead of its sub-steps: %v", statuses())
}
if !AdvanceSerialTodo(todos, 1) {
t.Fatal("current sub-step did not complete")
}
if got, want := statuses(), []string{"in_progress", "completed", "in_progress", "pending", "pending"}; !reflect.DeepEqual(got, want) {
if got, want := statuses(), []string{"pending", "completed", "in_progress", "pending", "pending"}; !reflect.DeepEqual(got, want) {
t.Fatalf("after first sub-step statuses = %v, want %v", got, want)
}
if !AdvanceSerialTodo(todos, 2) {
@@ -718,11 +735,17 @@ func TestAdvanceSerialTodoWalksPhaseChain(t *testing.T) {
if !AdvanceSerialTodo(todos, 0) {
t.Fatal("phase with completed sub-steps did not complete")
}
if got, want := statuses(), []string{"completed", "completed", "completed", "in_progress", "in_progress"}; !reflect.DeepEqual(got, want) {
t.Fatalf("after phase sign-off statuses = %v, want next phase and first sub-step promoted: %v", got, want)
if got, want := statuses(), []string{"completed", "completed", "completed", "pending", "in_progress"}; !reflect.DeepEqual(got, want) {
t.Fatalf("after phase sign-off statuses = %v, want next sub-step promoted under its pending phase: %v", got, want)
}
if !AdvanceSerialTodo(todos, 4) || !AdvanceSerialTodo(todos, 3) {
t.Fatal("second chain did not complete")
if !AdvanceSerialTodo(todos, 4) {
t.Fatal("second chain sub-step did not complete")
}
if got := todos[3].Status; got != "in_progress" {
t.Fatalf("second phase after its sub-step = %q, want in_progress", got)
}
if !AdvanceSerialTodo(todos, 3) {
t.Fatal("second phase did not sign off")
}
for i, todo := range todos {
if todo.Status != "completed" {
@@ -731,6 +754,31 @@ func TestAdvanceSerialTodoWalksPhaseChain(t *testing.T) {
}
}
func TestAdvanceSerialTodoAdvancesOrphanSubStep(t *testing.T) {
todos := []TodoItem{
{Content: "orphan sub", Status: "in_progress", Level: 1},
{Content: "Next step", Status: "pending"},
}
if !AdvanceSerialTodo(todos, 0) {
t.Fatal("orphan sub-step did not complete")
}
if todos[0].Status != "completed" || todos[1].Status != "in_progress" {
t.Fatalf("orphan completion must promote the next pending unit: %+v", todos)
}
chained := []TodoItem{
{Content: "orphan sub", Status: "in_progress", Level: 1},
{Content: "Phase", Status: "pending"},
{Content: "sub one", Status: "pending", Level: 1},
}
if !AdvanceSerialTodo(chained, 0) {
t.Fatal("orphan sub-step before a phase did not complete")
}
if chained[1].Status != "pending" || chained[2].Status != "in_progress" {
t.Fatalf("orphan completion before a phase must promote the phase's first sub-step: %+v", chained)
}
}
func TestSuccessfulProgressSignaturesIgnoreExactRepeatsAndBookkeeping(t *testing.T) {
ledger := NewLedger()
read := ReceiptFromToolCall("read_file", json.RawMessage(`{"path":"a.go"}`), true, true)
+1 -1
View File
@@ -701,7 +701,7 @@ func TestCompleteStepPendingHintNamesActiveSubStep(t *testing.T) {
ToolName: "todo_write",
Success: true,
Todos: []evidence.TodoItem{
{Content: "Port the parser", Status: "in_progress"},
{Content: "Port the parser", Status: "pending"},
{Content: "move files", Status: "in_progress", Level: 1},
{Content: "fix imports", Status: "pending", Level: 1},
},
+29 -10
View File
@@ -11,8 +11,8 @@ import (
func TestTodoWriteAcceptsLevels(t *testing.T) {
args := json.RawMessage(`{"todos":[` +
`{"content":"Phase","status":"in_progress","level":0},` +
`{"content":"sub","status":"pending","level":1}]}`)
`{"content":"Phase","status":"pending","level":0},` +
`{"content":"sub","status":"in_progress","level":1}]}`)
if _, err := (todoWrite{}).Execute(context.Background(), args); err != nil {
t.Fatalf("levels 0/1 should be accepted: %v", err)
}
@@ -342,21 +342,22 @@ func TestTodoWriteAcceptsPhaseChainProgress(t *testing.T) {
ToolName: "todo_write",
Success: true,
Todos: []evidence.TodoItem{
{Content: "Port the parser", Status: "in_progress"},
{Content: "move files", Status: "pending", Level: 1},
{Content: "Port the parser", Status: "pending"},
{Content: "move files", Status: "in_progress", Level: 1},
{Content: "fix imports", Status: "pending", Level: 1},
},
})
ctx := evidence.WithLedger(context.Background(), ledger)
out, err := (todoWrite{}).Execute(ctx, json.RawMessage(`{"todos":[
{"content":"Port the parser","status":"in_progress"},
{"content":"Port the parser","status":"pending"},
{"content":"move files","status":"in_progress","level":1},
{"content":"fix imports","status":"pending","level":1}]}`))
{"content":"fix imports","status":"pending","level":1},
{"content":"update docs","status":"pending","level":1}]}`))
if err != nil {
t.Fatalf("starting a sub-step under the current phase should be accepted: %v", err)
t.Fatalf("narrowing work under the current phase should be accepted: %v", err)
}
if !strings.Contains(out, "1 in progress") && !strings.Contains(out, "in progress") {
if !strings.Contains(out, "in progress") {
t.Fatalf("unexpected todo_write output: %q", out)
}
}
@@ -370,13 +371,31 @@ func TestTodoWriteRejectsPhaseCompletedBeforeSubSteps(t *testing.T) {
}
}
func TestTodoWriteRejectsPhaseInProgressBeforeSubSteps(t *testing.T) {
_, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(`{"todos":[
{"content":"Port the parser","status":"in_progress"},
{"content":"move files","status":"pending","level":1}]}`))
if err == nil || !strings.Contains(err.Error(), "cannot be in_progress while sub-step") {
t.Fatalf("phase in_progress before its sub-steps finish should be rejected: %v", err)
}
}
func TestTodoWriteRejectsOrphanSubStep(t *testing.T) {
_, err := (todoWrite{}).Execute(context.Background(), json.RawMessage(`{"todos":[
{"content":"move files","status":"in_progress","level":1},
{"content":"Port the parser","status":"pending"}]}`))
if err == nil || !strings.Contains(err.Error(), "no phase above it") {
t.Fatalf("a level-1 sub-step with no phase should be rejected: %v", err)
}
}
func TestTodoWriteRejectsDroppingActiveSubStep(t *testing.T) {
ledger := evidence.NewLedger()
ledger.Record(evidence.Receipt{
ToolName: "todo_write",
Success: true,
Todos: []evidence.TodoItem{
{Content: "Port the parser", Status: "in_progress"},
{Content: "Port the parser", Status: "pending"},
{Content: "move files", Status: "in_progress", Level: 1},
{Content: "fix imports", Status: "pending", Level: 1},
},
@@ -384,7 +403,7 @@ func TestTodoWriteRejectsDroppingActiveSubStep(t *testing.T) {
ctx := evidence.WithLedger(context.Background(), ledger)
_, err := (todoWrite{}).Execute(ctx, json.RawMessage(`{"todos":[
{"content":"Port the parser","status":"in_progress"},
{"content":"Port the parser","status":"pending"},
{"content":"rewrite everything","status":"in_progress","level":1}]}`))
if err == nil || !strings.Contains(err.Error(), "cannot be removed or replaced") {
t.Fatalf("dropping the active sub-step should be rejected: %v", err)