mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
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:
1 parent
fb37b9b5b6
commit
c8723be962
10 files changed
+253
-120
No files matched your search
+4
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user