mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f009491e0b | ||
|
|
b97cf36bb4 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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++
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user