feat(evidence): tell plan mutation apart from plan advancement

The host held one fact about its task list: that it changed. A turn that
rewrote the same two steps every round therefore produced the same signal
as one that finished them, and the session behind this work spent twelve
hours and 1065 task-list writes at a fixed 22 completed steps.

ClassifyTodoTransition reads what a write did from the lists themselves —
which step identities exist, and which are complete. Identity is a stable
step_id where the list carries one and the normalized text where it does
not, never position, so an insertion does not rename every step after it.
Six verdicts follow, and three counters move independently: content for a
write, plan for a change of steps, progress for execution moving.

Replan is deliberately not advancement. Counting it as such would let a
stalled turn renew itself by rewriting its own list, which is the loop this
distinction exists to make visible. Renewal — a sign-off landing on a step
already complete — is named by advanceCanonicalTodo rather than by the
classifier: the list does not move, so no comparison of it could find one.

This records the distinction and nothing else. No gate reads the counters,
no prompt byte moves with them, and no call is refused because of one.

The verdict crosses the wire, because the last runaway could only be read
from an exported trajectory. eventwire and the desktop's hand-written
mirror both carry it, and both are now held to the kernel's shape by
wire-parity — which, added for this, immediately reported that PR 280dfd8c9
had introduced the same defect: the maintenance code and boundary existed
in the kernel, were declared by the desktop, and were dropped in between.
That is fixed here with the pairing that would have caught it.

advanceCanonicalTodo moves to todo_state.go, where the canonical list lives.
This commit is contained in:
esengine committed 2026-08-30 05:48:21 -07:00
1 parent d5255c3471
commit 2d379d4119
15 files changed
+465 -20

No files matched your search

+18
View File
@@ -27,6 +27,7 @@ export type Kind =
| "extension_status"
| "stream_attempt"
| "context_maintenance"
| "todo_progress"
| "workspace_changed"
| "completion_summary"
| "inbox_changed"
@@ -305,6 +306,18 @@ export interface ExtensionSurface {
view?: ExtensionView;
}
// One task-list transition. kind is the structural verdict; the three revisions
// count writes, plan changes, and execution movement separately, because a turn
// can produce any number of the first two without the third ever moving.
export interface TodoProgress {
kind: string;
steps?: number;
completed?: number;
contentRevision?: number;
planRevision?: number;
progressRevision?: number;
}
// One context-maintenance transaction. status "noop" carries code: the attempt
// ran and freed nothing, and code is what tells "already folded this turn" from
// "nothing left to fold" — they read alike and mean opposite things about what
@@ -313,12 +326,16 @@ export interface ContextMaintenance {
status?: string;
action?: string;
trigger?: string;
operationId?: string;
code?: string;
boundary?: string;
triggerTokens?: number;
inputTokens?: number;
resultTokens?: number;
savedTokens?: number;
affectedToolResults?: number;
projectionVersion?: number;
cacheBreak?: boolean;
reason?: string;
}
@@ -484,6 +501,7 @@ export interface WireEvent {
extension?: ExtensionSurface;
compaction?: Compaction;
maintenance?: ContextMaintenance;
todoProgress?: TodoProgress;
streamAttempt?: StreamAttempt;
completion?: CompletionSummary;
graph?: GraphDelta;
@@ -179,6 +179,21 @@ function record(ev: WireEvent): Made | null {
case "steer":
return { kind: "memory_recall", payload: [{ t: "steer delivered · " }, { b: ev.text ?? "" }] };
case "todo_progress": {
const p = ev.todoProgress;
if (!p) return null;
// The three revisions are the point: a kind alone reads as activity.
return {
kind: "outcome_progress",
payload: [
{ t: "todo " }, { b: p.kind },
{ t: " · content " }, { n: String(p.contentRevision ?? 0) },
{ t: " · plan " }, { n: String(p.planRevision ?? 0) },
{ t: " · progress " }, { n: String(p.progressRevision ?? 0) },
],
};
}
case "context_maintenance": {
const m = ev.maintenance;
if (!m) return { kind: "outcome_progress", payload: [{ t: ev.text || "context maintenance" }] };
-17
View File
@@ -1136,23 +1136,6 @@ func (a *Agent) hasActiveCanonicalTodo() bool {
return false
}
func (a *Agent) advanceCanonicalTodo(step string) {
a.sess.todoMu.Lock()
if len(a.sess.todoState) == 0 {
a.sess.todoMu.Unlock()
return
}
m, ok := evidence.MatchStep(step, a.sess.todoState)
if !ok || !evidence.AdvanceSerialTodo(a.sess.todoState, m.Index-1) {
a.sess.todoMu.Unlock()
return
}
snapshot := append([]evidence.TodoItem(nil), a.sess.todoState...)
a.sess.todoMu.Unlock()
a.recordTodoState(snapshot)
a.emitTodoState(snapshot, m.Index)
}
// emitTodoState emits a synthetic todo_write event so the frontend task panel
// reflects a host-advanced completion without the model re-sending the list.
// itemIndex is the 1-based position of the completed todo in the panel.
+84
View File
@@ -9,6 +9,7 @@ import (
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/provider"
"reasonix/internal/tool"
@@ -293,3 +294,86 @@ func TestE2EFinalSignOffDoesNotAskForANextStep(t *testing.T) {
}
}
}
func todoTransitions(sink *recordSink) []event.TodoProgress {
var out []event.TodoProgress
for _, e := range sink.kinds(event.TodoProgressEvent) {
if e.TodoProgress != nil {
out = append(out, *e.TodoProgress)
}
}
return out
}
// The shape the 15.6-hour session spent twelve hours in: the task list is
// rewritten round after round while execution stands still. Counting those
// writes as progress is what made the turn look busy; here they raise the
// content revision and leave the progress revision where it was.
func TestE2ERewritingThePlanIsNotAdvancingIt(t *testing.T) {
restate := func(id string, content string) testutil.Turn {
return testutil.Turn{ToolCalls: []provider.ToolCall{{ID: id, Name: "todo_write",
Arguments: `{"todos":[{"content":"` + content + `","status":"in_progress","step_id":"s1"},` +
`{"content":"add tests","status":"pending","step_id":"s2"}]}`}}}
}
mp := testutil.NewMock("m",
restate("t0", "check login expiry"),
restate("t1", "look closer at login expiry"),
restate("t2", "verify the login expiry path"),
restate("t3", "confirm login expiry handling"),
testutil.Turn{Text: "still looking"},
)
sink := &recordSink{}
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, sink)
_ = a.Run(context.Background(), "work the plan")
revs := todoTransitions(sink)
if len(revs) < 4 {
t.Fatalf("recorded %d task-list transitions, want one per write", len(revs))
}
last := revs[len(revs)-1]
if last.ProgressRevision != 0 {
t.Errorf("progress revision = %d after four rewrites, want 0 — restating the plan advanced it",
last.ProgressRevision)
}
if last.ContentRevision < 4 {
t.Errorf("content revision = %d, want at least one per write", last.ContentRevision)
}
// The first write is the plan appearing; the rest change no step identity.
if last.PlanRevision != 1 {
t.Errorf("plan revision = %d, want 1 — only the first write introduced steps", last.PlanRevision)
}
for _, r := range revs[1:] {
if r.Kind != string(evidence.TodoRewrite) {
t.Errorf("transition kind = %q, want %q", r.Kind, evidence.TodoRewrite)
}
}
}
// And the transition that does move it: a sign-off the host acted on.
func TestE2ESigningOffAdvancesThePlanRevision(t *testing.T) {
mp := testutil.NewMock("m",
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "t0", Name: "todo_write",
Arguments: `{"todos":[{"content":"test","status":"in_progress"},{"content":"vet","status":"pending"}]}`}}},
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "b1", Name: "bash",
Arguments: `{"command":"go test ./..."}`}}},
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "complete_step",
Arguments: `{"step":"test","result":"tests pass","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`}}},
testutil.Turn{Text: "one down"},
)
sink := &recordSink{}
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, sink)
// The turn ends short of the plan on purpose: readiness holds it there, and
// the transition under test already happened.
_ = a.Run(context.Background(), "work the plan")
revs := todoTransitions(sink)
if len(revs) == 0 {
t.Fatal("no task-list transitions recorded")
}
last := revs[len(revs)-1]
if last.Kind != string(evidence.TodoAdvance) {
t.Fatalf("last transition = %q, want %q", last.Kind, evidence.TodoAdvance)
}
if last.ProgressRevision != 1 {
t.Fatalf("progress revision = %d after one sign-off, want 1", last.ProgressRevision)
}
}
+12
View File
@@ -20,6 +20,18 @@ type taskRuntime struct {
// have shown that change. It is working state, not evidence: the verdict
// lands on the receipt, so the ledger never holds file content.
witness map[string][]string
// todoRevs counts what the task list did, on three axes because they answer
// different questions and one number for all three reads a rewrite as work.
todoRevs todoRevisions
}
// todoRevisions separates writing the plan from changing it and from executing
// it. progress counts only host-observed execution movement, so a turn cannot
// renew it by restating its own list.
type todoRevisions struct {
content int
plan int
progress int
}
// restartLedger begins a new task's accounting. It is written as one assignment
+2
View File
@@ -60,6 +60,8 @@ var taskRestarted = map[string]bool{
// A new task begins under whatever the tree says then, so what an earlier
// one was held to is not carried into it.
"baselineCriteria": true,
// Revisions count one plan's history. A new task plans again from nothing.
"todoRevs": true,
}
// restartLedger is one assignment, so an unlisted field resets by default —
+43
View File
@@ -0,0 +1,43 @@
package agent
import (
"reasonix/internal/event"
"reasonix/internal/evidence"
)
// observeTodoTransition records what one task-list write did to the plan. It
// decides nothing: no gate reads these counters and no prompt byte moves with
// them. What it separates is the distinction the host never held — a plan that
// was rewritten from a plan that advanced.
func (a *Agent) observeTodoTransition(before, after []evidence.TodoItem) {
if kind := evidence.ClassifyTodoTransition(before, after); kind != evidence.TodoUnchanged {
a.observeTodoKind(kind, after)
}
}
// observeTodoKind records a verdict the caller already holds. A renewal is one:
// the list did not move, so no comparison of it could have found the sign-off.
func (a *Agent) observeTodoKind(kind evidence.TodoTransition, after []evidence.TodoItem) {
switch kind {
case evidence.TodoUnchanged, evidence.TodoRenewal:
case evidence.TodoReplan:
a.task.todoRevs.plan++
a.task.todoRevs.content++
case evidence.TodoAdvance, evidence.TodoTerminal:
a.task.todoRevs.progress++
a.task.todoRevs.content++
default:
a.task.todoRevs.content++
}
completed := 0
for _, todo := range after {
if canonicalTodoStatus(todo.Status) == "completed" {
completed++
}
}
a.svc.sink.Emit(event.Event{Kind: event.TodoProgressEvent, TodoProgress: &event.TodoProgress{
Kind: string(kind), Steps: len(after), Completed: completed,
ContentRevision: a.task.todoRevs.content, PlanRevision: a.task.todoRevs.plan,
ProgressRevision: a.task.todoRevs.progress,
}})
}
+30
View File
@@ -17,14 +17,18 @@ func (a *Agent) SeedTodoState(todos []evidence.TodoItem) {
if len(todos) == 0 {
return
}
before := a.CanonicalTodoState()
a.setTodoState(todos)
a.observeTodoTransition(before, a.CanonicalTodoState())
}
// ReplaceTodoState mirrors a host-generated todo list into the canonical state.
// It is used when the host, rather than the model, owns the full state transition.
func (a *Agent) ReplaceTodoState(todos []evidence.TodoItem) {
before := a.CanonicalTodoState()
a.setTodoState(todos)
a.recordTodoState(a.CanonicalTodoState())
a.observeTodoTransition(before, a.CanonicalTodoState())
}
// CanonicalTodoState returns a copy of the host-reconstructed task list.
@@ -49,6 +53,32 @@ func (a *Agent) hasIncompleteCanonicalCriteria() bool {
return len(a.sess.todoState) > 0 && len(evidence.IncompleteTodos(a.sess.todoState)) > 0
}
func (a *Agent) advanceCanonicalTodo(step string) {
a.sess.todoMu.Lock()
if len(a.sess.todoState) == 0 {
a.sess.todoMu.Unlock()
return
}
before := append([]evidence.TodoItem(nil), a.sess.todoState...)
m, ok := evidence.MatchStep(step, a.sess.todoState)
if !ok {
a.sess.todoMu.Unlock()
return
}
if !evidence.AdvanceSerialTodo(a.sess.todoState, m.Index-1) {
a.sess.todoMu.Unlock()
// A sign-off the list did not move for is a renewal. Nothing about the
// list records it, which is what made it read as an advance.
a.observeTodoKind(evidence.TodoRenewal, before)
return
}
snapshot := append([]evidence.TodoItem(nil), a.sess.todoState...)
a.sess.todoMu.Unlock()
a.recordTodoState(snapshot)
a.emitTodoState(snapshot, m.Index)
a.observeTodoTransition(before, snapshot)
}
// recordTodoState logs the host-advanced list as a synthetic todo_write receipt
// so the per-turn final gate (which reads the ledger's latest todo_write) sees
// the advance — the model no longer has to re-send a todo_write to mark the
+2
View File
@@ -40,7 +40,9 @@ func (a *Agent) recordToolReceipts(plan *toolCallPlan, result string, execution
a.reviewCoverageOf(&rec, plan, result)
a.task.ledger.Record(rec)
if err == nil && call.Name == "todo_write" {
before := a.CanonicalTodoState()
a.setTodoState(rec.Todos)
a.observeTodoTransition(before, a.CanonicalTodoState())
if len(rec.Todos) > 0 {
a.turn.deliveryCriteriaEstablished = true
}
+15
View File
@@ -113,6 +113,9 @@ const (
// blocked/noop outcome. It is separate from CompactionStarted/Done so UIs do
// not render a paid-summary card for a cache-preserving view update.
ContextMaintenanceEvent
// TodoProgressEvent reports what one task-list write did to the plan:
// rewritten, replanned, or advanced. Content-free, and decides nothing.
TodoProgressEvent
// WorkspaceChanged reports a debounced host-side workspace mutation.
WorkspaceChanged
// TurnPhase reports a host-side work phase for the active turn (working |
@@ -409,6 +412,17 @@ type ContextMaintenance struct {
TriggerTokens int `json:"triggerTokens,omitempty"`
}
// TodoProgress is one task-list transition: a structural verdict plus counters
// for writes, plan changes, and execution movement, which move independently.
type TodoProgress struct {
Kind string `json:"kind"`
Steps int `json:"steps,omitempty"`
Completed int `json:"completed,omitempty"`
ContentRevision int `json:"contentRevision,omitempty"`
PlanRevision int `json:"planRevision,omitempty"`
ProgressRevision int `json:"progressRevision,omitempty"`
}
// GuardianResult carries the outcome of a guardian sub-agent safety review.
// Emitted with Kind=GuardianAssessment after each review completes.
type GuardianResult struct {
@@ -503,6 +517,7 @@ type Event struct {
CheckpointTurn *int // TurnDone: authoritative checkpoint for this turn's visible user message
Compaction Compaction // Compaction
Maintenance *ContextMaintenance // ContextMaintenanceEvent
TodoProgress *TodoProgress // TodoProgressEvent
Guardian GuardianResult
DecisionReceipt *provider.DecisionReceipt // Notice: durable user decision receipt
RetryAttempt int // Retrying: 1-based attempt about to be made
+30 -1
View File
@@ -28,6 +28,7 @@ type Event struct {
Ask *Ask `json:"ask,omitempty"`
Compaction *Compaction `json:"compaction,omitempty"`
Maintenance *ContextMaintenance `json:"maintenance,omitempty"`
TodoProgress *TodoProgress `json:"todoProgress,omitempty"`
Guardian *Guardian `json:"guardian,omitempty"`
DecisionReceipt *DecisionReceipt `json:"decisionReceipt,omitempty"`
Extension *ExtensionSurface `json:"extension,omitempty"`
@@ -165,9 +166,12 @@ func ToWire(e event.Event) Event {
ResultTokens: m.ResultTokens, SavedTokens: m.SavedTokens,
AffectedToolResults: m.AffectedToolResults,
ProjectionVersion: m.ProjectionVersion, CacheBreak: m.CacheBreak,
Reason: m.Reason,
Reason: m.Reason, Code: m.Code, Boundary: m.Boundary,
TriggerTokens: m.TriggerTokens,
}
}
case event.TodoProgressEvent:
w.TodoProgress = toWireTodoProgress(e.TodoProgress)
case event.GuardianAssessment:
w.Guardian = ToWireGuardian(e.Guardian)
case event.ExtensionSurface, event.ExtensionStatus:
@@ -694,6 +698,7 @@ var kindNames = map[event.Kind]string{
event.ExtensionStatus: "extension_status",
event.StreamAttempt: "stream_attempt",
event.ContextMaintenanceEvent: "context_maintenance",
event.TodoProgressEvent: "todo_progress",
event.WorkspaceChanged: "workspace_changed",
event.TurnPhase: "turn_phase",
event.CompletionSummary: "completion_summary",
@@ -701,6 +706,27 @@ var kindNames = map[event.Kind]string{
event.GraphDelta: "graph_delta",
}
func toWireTodoProgress(p *event.TodoProgress) *TodoProgress {
if p == nil {
return nil
}
return &TodoProgress{
Kind: p.Kind, Steps: p.Steps, Completed: p.Completed,
ContentRevision: p.ContentRevision, PlanRevision: p.PlanRevision,
ProgressRevision: p.ProgressRevision,
}
}
// TodoProgress is the JSON form of event.TodoProgress.
type TodoProgress struct {
Kind string `json:"kind"`
Steps int `json:"steps,omitempty"`
Completed int `json:"completed,omitempty"`
ContentRevision int `json:"contentRevision,omitempty"`
PlanRevision int `json:"planRevision,omitempty"`
ProgressRevision int `json:"progressRevision,omitempty"`
}
// ContextMaintenance is the JSON form of event.ContextMaintenance.
type ContextMaintenance struct {
Status string `json:"status,omitempty"`
@@ -714,4 +740,7 @@ type ContextMaintenance struct {
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
CacheBreak bool `json:"cacheBreak,omitempty"`
Reason string `json:"reason,omitempty"`
Code string `json:"code,omitempty"`
Boundary string `json:"boundary,omitempty"`
TriggerTokens int `json:"triggerTokens,omitempty"`
}
+126
View File
@@ -0,0 +1,126 @@
package evidence
// Plan mutation and plan advancement are different facts: only the execution
// position moving says the plan went somewhere. One number for both let 1065
// task-list writes stand in for progress across twelve hours that changed no file.
import "strings"
// TodoTransition is what one task-list write did to the plan, read from the
// list's structure: which steps exist, and where execution stands among them.
type TodoTransition string
const (
// TodoUnchanged: the same steps in the same state.
TodoUnchanged TodoTransition = "unchanged"
// TodoRewrite: the same steps in the same state, said differently.
TodoRewrite TodoTransition = "rewrite"
// TodoReplan: the set of steps itself changed — added, dropped, split.
TodoReplan TodoTransition = "replan"
// TodoAdvance: a step completed and execution moved on.
TodoAdvance TodoTransition = "advance"
// TodoTerminal: the advance that completed the last step.
TodoTerminal TodoTransition = "terminal"
// TodoRegression: work that was complete is open again.
TodoRegression TodoTransition = "regression"
// TodoRenewal: a sign-off landing on a step already complete. The list does
// not move, so no structural comparison can find it — the caller that knows
// a sign-off was attempted names it.
TodoRenewal TodoTransition = "renewal"
)
// Advances reports whether the transition moved execution forward. Replan is
// deliberately not one: changing the plan is a strategy change, and counting it
// as advancement lets a stalled turn renew itself by rewriting its own list.
func (t TodoTransition) Advances() bool { return t == TodoAdvance || t == TodoTerminal }
// stepIdentity is what makes two entries the same step across a write: a
// stable id where the list carries one, and the normalized text where it does
// not. Position is not identity — an insertion would rename every step after it.
func stepIdentity(todo TodoItem) string {
if id := strings.TrimSpace(todo.StepID); id != "" {
return "id:" + id
}
return "text:" + normalizeStepText(todo.Content)
}
func stepIdentities(todos []TodoItem) map[string]bool {
out := make(map[string]bool, len(todos))
for _, todo := range todos {
out[stepIdentity(todo)] = true
}
return out
}
func completedIdentities(todos []TodoItem) map[string]bool {
out := map[string]bool{}
for _, todo := range todos {
if todoStatus(todo.Status) == "completed" {
out[stepIdentity(todo)] = true
}
}
return out
}
func sameIdentitySet(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func containsAll(outer, inner map[string]bool) bool {
for k := range inner {
if !outer[k] {
return false
}
}
return true
}
func sameWording(a, b []TodoItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Content != b[i].Content || a[i].ActiveForm != b[i].ActiveForm || a[i].Status != b[i].Status {
return false
}
}
return true
}
// ClassifyTodoTransition reads what changed between two canonical task lists.
// It answers from the lists alone — no wording is interpreted, and no caller's
// intent is consulted — so the same write is classified the same way whether
// the model or the host produced it.
func ClassifyTodoTransition(before, after []TodoItem) TodoTransition {
before, after = NormalizeSerialTodos(before), NormalizeSerialTodos(after)
if len(before) == 0 {
if len(after) == 0 {
return TodoUnchanged
}
return TodoReplan
}
if !sameIdentitySet(stepIdentities(before), stepIdentities(after)) {
return TodoReplan
}
doneBefore, doneAfter := completedIdentities(before), completedIdentities(after)
switch {
case !containsAll(doneAfter, doneBefore):
return TodoRegression
case len(doneAfter) > len(doneBefore):
if len(doneAfter) == len(stepIdentities(after)) {
return TodoTerminal
}
return TodoAdvance
case sameWording(before, after):
return TodoUnchanged
}
return TodoRewrite
}
+79
View File
@@ -0,0 +1,79 @@
package evidence
import "testing"
func todos(items ...TodoItem) []TodoItem { return items }
func step(id, content, status string) TodoItem {
return TodoItem{StepID: id, Content: content, Status: status}
}
// The classification reads the list's structure. Every case below is a shape a
// real turn produced; the rewrite cases are the ones the 15.6-hour session
// spent twelve hours on.
func TestClassifyTodoTransition(t *testing.T) {
for _, tc := range []struct {
name string
before []TodoItem
after []TodoItem
want TodoTransition
}{
{"first list is a plan",
nil,
todos(step("s1", "add parser", "in_progress")),
TodoReplan},
{"same list, same words",
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
TodoUnchanged},
{"restated more precisely, execution unmoved",
todos(step("s1", "check login expiry", "in_progress"), step("s2", "add tests", "pending")),
todos(step("s1", "verify the login expiry path", "in_progress"), step("s2", "add tests", "pending")),
TodoRewrite},
{"a step completed and the next became current",
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
todos(step("s1", "add parser", "completed"), step("s2", "add tests", "in_progress")),
TodoAdvance},
{"the last step completed",
todos(step("s1", "add parser", "completed"), step("s2", "add tests", "in_progress")),
todos(step("s1", "add parser", "completed"), step("s2", "add tests", "completed")),
TodoTerminal},
{"a step was added",
todos(step("s1", "add parser", "in_progress")),
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
TodoReplan},
{"a step was dropped",
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
todos(step("s1", "add parser", "in_progress")),
TodoReplan},
{"completed work was reopened",
todos(step("s1", "add parser", "completed"), step("s2", "add tests", "in_progress")),
todos(step("s1", "add parser", "in_progress"), step("s2", "add tests", "pending")),
TodoRegression},
{"lists without ids match on text",
todos(step("", "add parser", "in_progress")),
todos(step("", "add parser", "completed")),
TodoTerminal},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ClassifyTodoTransition(tc.before, tc.after); got != tc.want {
t.Errorf("ClassifyTodoTransition = %q, want %q", got, tc.want)
}
})
}
}
// Rewriting the plan is how a stalled turn would renew a progress lease it has
// not earned, so replan must never read as advancement.
func TestOnlyExecutionMovementAdvances(t *testing.T) {
for _, tr := range []TodoTransition{TodoUnchanged, TodoRewrite, TodoReplan, TodoRegression} {
if tr.Advances() {
t.Errorf("%q reports as advancement", tr)
}
}
for _, tr := range []TodoTransition{TodoAdvance, TodoTerminal} {
if !tr.Advances() {
t.Errorf("%q does not report as advancement", tr)
}
}
}
+2 -2
View File
@@ -6,7 +6,7 @@
"complexity": 1513,
"error-text": 12,
"essay": 3245,
"file-size": 41735,
"file-size": 41718,
"frontend-parity": 150,
"function-size": 6596,
"layering": 0,
@@ -105,7 +105,7 @@
"internal/agent/agent.go": {
"complexity": 37,
"essay": 45,
"file-size": 1415,
"file-size": 1398,
"function-size": 102
},
"internal/agent/agent_options.go": {
+7
View File
@@ -31,6 +31,13 @@ var mirroredWireTypes = []wireMirror{
// The completion summary is the turn's own verdict on itself; a gap kind the
// desktop cannot read is a turn it shows as clean.
{"internal/eventwire/wire.go", "CompletionSummary", tsWireFile, "CompletionSummary"},
// A maintenance verdict the desktop cannot read is a session that shows no
// attempt to bound its context — which is what its trajectory export said
// about the run that motivated the second boundary.
{"internal/eventwire/wire.go", "ContextMaintenance", tsWireFile, "ContextMaintenance"},
// Plan rewriting and plan advancement are told apart by three counters; a
// desktop that can read only some of them reads churn as work.
{"internal/eventwire/wire.go", "TodoProgress", tsWireFile, "TodoProgress"},
// RemoteHostEdit is left out on purpose: the kernel still takes the single
// `workspace` an old row was saved with, which the page deliberately does
// not send. An under-filled request is not a picture that cannot be read.