diff --git a/internal/agent/completion_gate_test.go b/internal/agent/completion_gate_test.go index fae260ad..6ccc6fba 100644 --- a/internal/agent/completion_gate_test.go +++ b/internal/agent/completion_gate_test.go @@ -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. diff --git a/internal/agent/guardrails.go b/internal/agent/guardrails.go index 3b540e20..73307e4b 100644 --- a/internal/agent/guardrails.go +++ b/internal/agent/guardrails.go @@ -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 { diff --git a/internal/agent/guardrails_test.go b/internal/agent/guardrails_test.go index bc48e43b..a6755c7e 100644 --- a/internal/agent/guardrails_test.go +++ b/internal/agent/guardrails_test.go @@ -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} diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 2ea4ac0b..dcacd3ed 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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++ diff --git a/internal/agent/types.go b/internal/agent/types.go index 1b12e0c7..5073d40f 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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 diff --git a/internal/sessions/store.go b/internal/sessions/store.go index 3eb98f14..b15a44f9 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -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 diff --git a/internal/tui/model.go b/internal/tui/model.go index eceebfc2..39e3db91 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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, + } } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e9fd5de2..0606761a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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 diff --git a/internal/tui/plan_panel.go b/internal/tui/plan_panel.go index 088a38a5..6d40a56e 100644 --- a/internal/tui/plan_panel.go +++ b/internal/tui/plan_panel.go @@ -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 diff --git a/internal/tui/plan_panel_progress_test.go b/internal/tui/plan_panel_progress_test.go index ba49ab68..b23d6eba 100644 --- a/internal/tui/plan_panel_progress_test.go +++ b/internal/tui/plan_panel_progress_test.go @@ -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. diff --git a/internal/tui/resume_task_test.go b/internal/tui/resume_task_test.go index 9dd841df..4dc1d0ab 100644 --- a/internal/tui/resume_task_test.go +++ b/internal/tui/resume_task_test.go @@ -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)} diff --git a/internal/tui/session.go b/internal/tui/session.go index c5bd6266..f7bca80b 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -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})