Compare commits

...
Author SHA1 Message Date
KRATOS f009491e0b fix(tui): address PR review for incomplete run replay
Use EventRunIncomplete instead of EventError for stalled plan runs so
resume replays a system notice rather than a hard error. Stamp
completedAt when markIncompleteRemaining leaves a terminal plan, tighten
incomplete plan assertions, and add hydration coverage.
2026-07-15 12:53:01 +05:30
KRATOS b97cf36bb4 fix(agent): prevent plan execution from stalling after update_plan
Apply the plan-aware completion gate to interactive runs while update_plan
still has pending items, count reasoning-only turns as non-productive during
an active plan, surface Incomplete results in the TUI, and mark the active
plan step failed instead of force-completing a stalled run.
2026-07-15 12:53:01 +05:30
12 changed files with 423 additions and 31 deletions

No files matched your search

+140 -3
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"errors"
"strings"
"testing"
@@ -126,9 +127,107 @@ func TestCompletionGateContinuesOnCueThenSucceeds(t *testing.T) {
}
}
// With the gate OFF (the interactive/TUI default), a continuation-cue turn is
// accepted as the final answer exactly as before — guaranteeing no behavior
// change for non-headless callers.
// Issue #666: the plan-aware gate applies even when RequireCompletionSignal is
// off (interactive/TUI). A model that creates a plan then stalls on a
// continuation-cue text turn must be re-prompted and eventually INCOMPLETE, not
// accepted as success while the plan panel stays on step one.
func TestPlanPendingGateAppliesWithoutRequireCompletionSignal(t *testing.T) {
cue := "Now I need to configure the SSH server. Let me check the current SSH configuration:"
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending", "pending"),
textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue), textTurn(cue),
}}
result, err := Run(context.Background(), "set up a git server", provider, Options{
Registry: registry,
MaxTurns: 10,
// RequireCompletionSignal deliberately off — interactive/TUI default.
})
if err != nil {
t.Fatal(err)
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true (plan stalled without headless gate), got false; final=%q turns=%d", result.FinalAnswer, result.Turns)
}
if len(provider.requests) != 1+maxContinueNudges+1 {
t.Fatalf("expected %d provider turns (1 plan + %d nudges + 1 final), got %d",
1+maxContinueNudges+1, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge (%q) to be injected", continueNudgeMarker)
}
}
// A pending plan must nudge non-colon continuation announcements too — not only
// colon-terminated cues — so the first mid-step text turn cannot finalize while
// steps remain. A later clean final answer is accepted after the bounded nudges.
func TestPlanPendingNudgesNonColonContinuationText(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
midStep := "Let me inspect the configuration."
final := "Provider profiles are loaded from config and merged with defaults."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending", "pending"),
textTurn(midStep),
textTurn(final), textTurn(final), textTurn(final), textTurn(final),
}}
result, err := Run(context.Background(), "inspect and configure", provider, Options{
Registry: registry,
MaxTurns: 10,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("expected success after nudge + final answer, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != final {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, final)
}
if len(provider.requests) != 1+1+maxContinueNudges {
t.Fatalf("expected %d turns (plan + mid-step + %d nudged finals), got %d",
1+1+maxContinueNudges, maxContinueNudges, len(provider.requests))
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected a continue nudge after the mid-step text with pending plan")
}
}
// After bounded nudges, a confident final answer with stale plan bookkeeping must
// still succeed on the interactive/TUI path (no infinite loop, no false incomplete).
func TestPlanPendingAcceptsFinalAnswerAfterBoundedNudgesWithoutHeadlessGate(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
done := "All provider profiles are documented above."
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
planTurn("in_progress", "pending"),
textTurn(done), textTurn(done), textTurn(done), textTurn(done),
}}
result, err := Run(context.Background(), "explain provider loading", provider, Options{
Registry: registry,
MaxTurns: 10,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("stale plan alone must not force Incomplete on TUI path; reason=%q", result.IncompleteReason)
}
if result.FinalAnswer != done {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, done)
}
if !someRequestContains(provider.requests, continueNudgeMarker) {
t.Fatalf("expected at least one continue nudge before accepting completion")
}
}
// With the gate OFF and NO pending plan, a continuation-cue turn is still
// accepted as the final answer — no behavior change for short single-step tasks.
func TestCompletionGateOffPreservesLegacyBehavior(t *testing.T) {
cue := "Let me check the config:"
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
@@ -177,6 +276,44 @@ func TestContinuationCueMatching(t *testing.T) {
}
}
// Cancellation during a plan-pending stall must return immediately without extra
// nudges or incomplete synthesis.
func TestRunCancellationDuringPlanPendingStopsImmediately(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
ctx, cancel := context.WithCancel(context.Background())
_, err := Run(ctx, "do work", cancelMidStreamProvider{cancel: cancel}, Options{
Registry: registry,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
// Repeated identical update_plan calls must still hit the max-turns ceiling rather
// than looping silently forever.
func TestRunRepeatedIdenticalUpdatePlanHitsMaxTurns(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
same := toolTurn("plan", "update_plan", `{"plan":[{"content":"step","status":"in_progress"}]}`)
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{same, same, same, textTurn("halted summary")}}
result, err := Run(context.Background(), "keep planning", provider, Options{
Registry: registry,
MaxTurns: 3,
})
if err != nil {
t.Fatal(err)
}
// MaxTurns=3 tool turns, then finalAnswerAfterMaxTurns issues one more request.
if len(provider.requests) != 4 {
t.Fatalf("expected 3 tool turns + 1 max-turns final-answer call, got %d", len(provider.requests))
}
if result.FinalAnswer == "" {
t.Fatal("expected a max-turns final answer")
}
}
// review #4: a run that loops to the MaxTurns ceiling (always calling a tool, so it
// never reaches the no-tool-call gate) was reported as success. Under the headless
// gate, a max-turns cutoff is INCOMPLETE — the agent was stopped mid-run, not done.
+12 -2
View File
@@ -508,9 +508,19 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText || hasReasoning {
productive := hasToolCalls || hasVisibleText
switch {
case productive:
state.emptyTurns = 0
} else {
case hasReasoning && state.planItemsPending > 0:
// Reasoning alone does not prove progress while plan steps remain — weaker
// models (e.g. HY3 via OpenAI-compatible gateways) can stream long
// reasoning-only turns without tools or visible text, burning the turn
// budget while the plan panel stays on the first in_progress step.
state.emptyTurns++
case hasReasoning:
state.emptyTurns = 0
default:
state.emptyTurns++
}
if hasToolCalls && !hasVisibleText {
+109 -1
View File
@@ -30,6 +30,16 @@ func reasoningTurn(content string) []zeroruntime.StreamEvent {
}
}
// multiReasoningTurn streams several reasoning chunks inside ONE provider turn.
func multiReasoningTurn(chunks ...string) []zeroruntime.StreamEvent {
events := make([]zeroruntime.StreamEvent, 0, len(chunks)+1)
for _, chunk := range chunks {
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: chunk})
}
events = append(events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
return events
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -109,6 +119,95 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunStopsReasoningOnlyTurnsWhilePlanPending(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("should never reach here"),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != maxEmptyTurns+1 {
t.Fatalf("expected %d turns (1 plan + %d reasoning-only stalls), got %d", maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true after reasoning-only stall with pending plan, got false")
}
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
t.Fatalf("expected no-output stop answer, got %q", result.FinalAnswer)
}
}
func TestRunMultiChunkReasoningCountsAsOneNonProductiveTurnWhilePlanPending(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
multiReasoningTurn("chunk-1 ", "chunk-2 ", "chunk-3 ", "chunk-4 ", "chunk-5 "),
multiReasoningTurn("more-1 ", "more-2 ", "more-3 "),
multiReasoningTurn("final-1 ", "final-2 "),
textTurn("should never reach here"),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if len(provider.requests) != maxEmptyTurns+1 {
t.Fatalf("expected %d turns (1 plan + %d multi-chunk reasoning stalls), got %d",
maxEmptyTurns+1, maxEmptyTurns, len(provider.requests))
}
if !result.Incomplete {
t.Fatalf("expected Incomplete=true, got false")
}
}
func TestRunReasoningThenToolCallSucceedsWithPendingPlan(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(tools.NewUpdatePlanTool())
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
toolTurn("plan", "update_plan", `{"plan":[{"content":"step 1","status":"in_progress"},{"content":"step 2","status":"pending"}]}`),
multiReasoningTurn("thinking ", "more thinking "),
toolTurn("read", "read_file", `{"path":"notes.txt"}`),
toolTurn("plan2", "update_plan", `{"plan":[{"content":"step 1","status":"completed"},{"content":"step 2","status":"completed"}]}`),
textTurn("Both steps are done."),
}}
result, err := Run(context.Background(), "multi-step task", provider, Options{
Registry: registry,
MaxTurns: 12,
Cwd: root,
})
if err != nil {
t.Fatal(err)
}
if result.Incomplete {
t.Fatalf("run should succeed after productive tool call, got Incomplete (%q)", result.IncompleteReason)
}
if result.FinalAnswer != "Both steps are done." {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
@@ -293,7 +392,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one"}]}`),
toolTurn("call-1", "update_plan", `{"plan":[{"content":"step one","status":"completed"}]}`),
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`),
textTurn("done"),
},
@@ -329,6 +428,12 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
for i := 0; i < staleToolCallThreshold+2; i++ {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
// The plan-aware completion gate re-prompts up to maxContinueNudges when the
// model stops with text while plan items remain; supply those turns before
// the final answer.
for i := 0; i < maxContinueNudges; i++ {
turns = append(turns, textTurn("done."))
}
turns = append(turns, textTurn("done"))
provider := &mockProvider{turns: turns}
@@ -363,6 +468,9 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
for i := 0; i < staleToolCallThreshold*2; i++ {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
for i := 0; i < maxContinueNudges; i++ {
turns = append(turns, textTurn("done."))
}
turns = append(turns, textTurn("done"))
provider := &mockProvider{turns: turns}
+11 -5
View File
@@ -416,6 +416,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// counted toward the runaway cap so we stop before burning maxTurns.
if guards.observeTurn(collected) {
result.FinalAnswer = noOutputStopAnswer(result.Turns)
if guards.pendingPlanItems() {
result.Incomplete = true
result.IncompleteReason = "stopped after non-productive turns while plan items remained"
}
result.Messages = copyMessages(messages)
return result, nil
}
@@ -429,10 +433,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
})
continue
}
// Completion gate (headless): a turn with text but no tool call is the
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
// Completion gate: a turn with text but no tool call is the model's
// final answer ONLY when the work is actually done. Headless exec opts in
// via RequireCompletionSignal; interactive runs also apply the gate while
// update_plan still has pending/in_progress items so a plan cannot stall
// silently after the first step is marked active.
planPending := guards.pendingPlanItems()
if options.RequireCompletionSignal || planPending {
// (1) Self-report downgrade (strongest, unambiguous): the model's own
// final message admits it guessed / could not meet the objective. Checked
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
@@ -455,7 +462,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// false-fail a completed run with stale bookkeeping) — fall through to the
// acceptance check / success.
cue := endsWithContinuationCue(collected.Text)
planPending := guards.pendingPlanItems()
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
+7 -7
View File
@@ -296,13 +296,13 @@ type Options struct {
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
// final answer. With it, a no-tool-call turn is NOT treated as "done" while
// work clearly remains — pending update_plan items, or a message that ends on a
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
// final answer — except while update_plan still has pending/in_progress items,
// when the plan-aware completion gate always applies (interactive and headless).
// With RequireCompletionSignal, a no-tool-call turn is also gated on
// continuation cues ("…Let me check the config:"). The loop nudges the model to
// continue instead, bounded by maxContinueNudges (and still by MaxTurns and the
// run deadline); if the model keeps stalling, the run finalizes as INCOMPLETE
// (Result.Incomplete) rather than success.
RequireCompletionSignal bool
runPermissions *permissionRunState
+15 -10
View File
@@ -35,16 +35,21 @@ const (
EventProviderUsage EventType = "provider_usage"
EventUsage EventType = EventProviderUsage
EventError EventType = "error"
EventSessionCheckpoint EventType = "session_checkpoint"
EventSessionRewind EventType = "session_rewind"
EventCompaction EventType = "session_compaction"
EventSessionFork EventType = "session_fork"
EventSessionChild EventType = "session_child"
EventSpecialistStart EventType = "specialist_start"
EventSpecialistStop EventType = "specialist_stop"
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
// EventRunIncomplete records a run that stopped with work clearly unfinished
// (plan stall, mid-step stop, max-turns cutoff) without a provider/tool error.
// Distinct from EventError so TUI resume replays it as a system notice, not a
// hard failure.
EventRunIncomplete EventType = "run_incomplete"
EventSessionCheckpoint EventType = "session_checkpoint"
EventSessionRewind EventType = "session_rewind"
EventCompaction EventType = "session_compaction"
EventSessionFork EventType = "session_fork"
EventSessionChild EventType = "session_child"
EventSpecialistStart EventType = "specialist_start"
EventSpecialistStop EventType = "specialist_stop"
EventSpecDraft EventType = "spec_draft"
EventSpecApproved EventType = "spec_approved"
EventSpecRejected EventType = "spec_rejected"
)
type SessionKind string
+25 -3
View File
@@ -510,6 +510,11 @@ type agentResponseMsg struct {
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
// incomplete reports a run the agent marked unfinished (plan stall, mid-step
// stop, max-turns cutoff under the completion gate). Surfaced to the user
// instead of force-completing the plan panel.
incomplete bool
incompleteReason string
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
@@ -2166,7 +2171,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// reconcile it to complete here. Read pendingAskUser/pendingPermission
// BEFORE the reset below clears them, and skip spec-draft reviews — those
// are legitimate mid-plan err==nil yields where the plan is NOT done.
if msg.err == nil && msg.specReview == nil &&
if msg.incomplete {
m.plan.markIncompleteRemaining(m.now())
} else if msg.err == nil && msg.specReview == nil &&
m.pendingAskUser == nil && m.pendingPermission == nil {
m.plan.completeRemaining(m.now())
}
@@ -2250,7 +2257,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// default first-message title, generate a concise one in the background
// (one-shot per session). A failed turn is skipped — there's nothing to name.
var titleCmd, recapCmd tea.Cmd
if msg.err == nil {
if msg.err == nil && !msg.incomplete {
m, titleCmd = m.maybeAutoTitleActiveSession()
// Post-turn recap (gated on the recaps preference): one short sentence
// summarizing the turn's final answer, shown as a "※ recap:" footnote.
@@ -5168,6 +5175,17 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
if notice := result.TruncationNotice(); notice != "" {
rows = append(rows, transcriptRow{kind: rowSystem, text: notice})
}
incompleteReason := result.IncompleteReason
if result.Incomplete {
if incompleteReason == "" {
incompleteReason = "run stopped with work unfinished"
}
rows = append(rows, transcriptRow{kind: rowSystem, text: "Run incomplete: " + incompleteReason})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventRunIncomplete,
Payload: map[string]any{"message": incompleteReason},
})
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: map[string]any{
@@ -5175,7 +5193,11 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
return agentResponseMsg{
runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID,
sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft,
incomplete: result.Incomplete, incompleteReason: incompleteReason,
}
}
}
+21
View File
@@ -1351,6 +1351,27 @@ func TestAgentResponseCompletesStuckPlan(t *testing.T) {
}
})
t.Run("incomplete turn marks the active plan step failed", func(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.plan = runningPlan()
updated, _ := m.Update(agentResponseMsg{
runID: 7, incomplete: true, incompleteReason: "your message ended mid-step",
rows: []transcriptRow{{kind: rowAssistant, text: "stalled", final: true}},
})
next := updated.(model)
if next.plan.isComplete() {
t.Fatal("incomplete turn must not force-complete the plan")
}
if next.plan.steps[1].status != "failed" {
t.Fatalf("expected the active step to be marked failed, got %+v", next.plan.steps)
}
if next.plan.steps[2].status != "pending" {
t.Fatalf("expected the remaining pending step untouched, got %+v", next.plan.steps)
}
})
t.Run("errored turn leaves the plan incomplete", func(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
+30
View File
@@ -166,6 +166,36 @@ func (s planPanelState) isComplete() bool {
// invoke this ONLY when the run genuinely finished (no error, no mid-plan yield
// for ask_user/permission/spec-review), since it asserts the remaining work was
// actually done.
// markIncompleteRemaining marks the active plan step failed when a run stopped
// with work clearly unfinished (Result.Incomplete). Pending steps stay pending so
// the panel shows what was never reached; in_progress becomes failed with a
// completion timestamp. No-op on empty or already-terminal plans.
func (s *planPanelState) markIncompleteRemaining(now time.Time) {
if len(s.steps) == 0 || s.isComplete() {
return
}
for i := range s.steps {
switch s.steps[i].status {
case "in_progress":
s.steps[i].status = "failed"
if s.steps[i].startedAt.IsZero() {
s.steps[i].startedAt = now
}
s.steps[i].completedAt = now
case "pending":
// Leave pending — shows remaining work that was not attempted.
default:
// completed/failed: preserve.
}
}
// When every step is now terminal (e.g. a single-step plan failed), stamp
// completedAt so the finished panel ages out like completeRemaining() does.
// Leave it unset while pending steps remain — the run is not finished.
if s.isComplete() && s.completedAt.IsZero() {
s.completedAt = now
}
}
func (s *planPanelState) completeRemaining(now time.Time) {
if len(s.steps) == 0 || s.isComplete() {
return
+36
View File
@@ -49,6 +49,42 @@ func TestPlanReconcileClearsStaleCompletion(t *testing.T) {
}
}
func TestPlanMarkIncompleteRemaining(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
started := now.Add(-time.Minute)
s := planPanelState{steps: []planStep{
{content: "a", status: "completed", startedAt: started, completedAt: now.Add(-30 * time.Second)},
{content: "b", status: "in_progress", startedAt: started},
{content: "c", status: "pending"},
}}
s.markIncompleteRemaining(now)
if s.steps[1].status != "failed" || s.steps[1].completedAt != now {
t.Fatalf("in_progress step should be failed: %+v", s.steps[1])
}
if s.steps[2].status != "pending" {
t.Fatalf("pending step should stay pending, got %q", s.steps[2].status)
}
if s.isComplete() {
t.Fatal("incomplete run must not mark the plan complete")
}
if !s.completedAt.IsZero() {
t.Fatal("completedAt must stay unset while pending steps remain")
}
t.Run("terminal single-step plan stamps completedAt", func(t *testing.T) {
s := planPanelState{steps: []planStep{
{content: "only step", status: "in_progress", startedAt: started},
}}
s.markIncompleteRemaining(now)
if !s.isComplete() {
t.Fatal("single failed step should be terminal")
}
if s.completedAt != now {
t.Fatalf("completedAt = %v, want %v", s.completedAt, now)
}
})
}
// TestPlanCompleteRemaining: force-completing a stuck plan flips every
// non-terminal step to completed, backfills timestamps, preserves a failed step,
// and is a clean no-op on empty / already-complete plans.
+13
View File
@@ -7,6 +7,19 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
)
func TestHydrationRunIncompleteIsSystemNotice(t *testing.T) {
rows := transcriptRowsFromSessionEvents([]sessions.Event{{
Type: sessions.EventRunIncomplete,
Payload: json.RawMessage(`{"message":"your message ended mid-step"}`),
}})
if len(rows) != 1 || rows[0].kind != rowSystem {
t.Fatalf("run_incomplete must replay as a system row, got %#v", rows)
}
if rows[0].text != "Run incomplete: your message ended mid-step" {
t.Fatalf("unexpected replay text: %q", rows[0].text)
}
}
func TestHydrationKeepsFailedTaskWithoutSpecialist(t *testing.T) {
ev := func(typ sessions.EventType, payload string) sessions.Event {
return sessions.Event{Type: typ, Payload: json.RawMessage(payload)}
+4
View File
@@ -606,6 +606,10 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
if message := payloadString(payload, "message"); message != "" {
rows = append(rows, transcriptRow{kind: rowError, text: message})
}
case sessions.EventRunIncomplete:
if message := payloadString(payload, "message"); message != "" {
rows = append(rows, transcriptRow{kind: rowSystem, text: "Run incomplete: " + message})
}
case sessions.EventCompaction:
if summary := payloadString(payload, "summary"); summary != "" {
rows = append(rows, transcriptRow{kind: rowSystem, text: summary})