From c4b749b235c628c6d21895ae495ecd0a3ca943d2 Mon Sep 17 00:00:00 2001 From: Dennison Date: Fri, 31 Jul 2026 01:51:01 +0200 Subject: [PATCH 1/3] fix TUI waiting conversation overlay --- .../tui/askuser_sse_acceptance_test.go | 260 ++++++++++++++++++ cmd/harnesscli/tui/askuser_test.go | 12 +- cmd/harnesscli/tui/bridge.go | 3 +- cmd/harnesscli/tui/messages.go | 1 + cmd/harnesscli/tui/messages_test.go | 5 +- cmd/harnesscli/tui/model.go | 10 +- docs/logs/engineering-log.md | 26 ++ docs/logs/long-term-thinking-log.md | 17 ++ docs/logs/observational-log.md | 14 + docs/logs/system-log.md | 17 ++ ...sue-1058-tui-waiting-overlay-impact-map.md | 82 ++++++ ...-31-issue-1058-tui-waiting-overlay-plan.md | 68 +++++ docs/plans/INDEX.md | 2 + docs/plans/active-plan.md | 6 + 14 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 cmd/harnesscli/tui/askuser_sse_acceptance_test.go create mode 100644 docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md create mode 100644 docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md diff --git a/cmd/harnesscli/tui/askuser_sse_acceptance_test.go b/cmd/harnesscli/tui/askuser_sse_acceptance_test.go new file mode 100644 index 000000000..e0851d9af --- /dev/null +++ b/cmd/harnesscli/tui/askuser_sse_acceptance_test.go @@ -0,0 +1,260 @@ +package tui_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + tui "go-agent-harness/cmd/harnesscli/tui" +) + +// TestAskUser_WaitingEnvelopeThroughBridge_ShowsSubmitsAndContinues crosses +// the production SSE decoder, Bubble Tea model, pending-input API, visible +// overlay, answer API, resume event, and later assistant output. In particular, +// run_id exists only at the event-envelope level, matching harness.Event. +func TestAskUser_WaitingEnvelopeThroughBridge_ShowsSubmitsAndContinues(t *testing.T) { + const ( + runID = "run-waiting-envelope" + callID = "call-waiting-envelope" + ) + + answerAccepted := make(chan struct{}) + var answerOnce sync.Once + releaseAnswer := func() { answerOnce.Do(func() { close(answerAccepted) }) } + submittedBody := make(chan []byte, 1) + var eventRequests atomic.Int32 + var inputGets atomic.Int32 + var inputPosts atomic.Int32 + + mux := http.NewServeMux() + mux.HandleFunc("/v1/runs/"+runID+"/events", func(w http.ResponseWriter, r *http.Request) { + eventRequests.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "id: %s:7\n", runID) + fmt.Fprint(w, "event: run.waiting_for_user\n") + fmt.Fprintf( + w, + "data: {\"id\":%q,\"run_id\":%q,\"type\":\"run.waiting_for_user\",\"payload\":{\"call_id\":%q}}\n\n", + runID+":7", + runID, + callID, + ) + w.(http.Flusher).Flush() + + select { + case <-answerAccepted: + case <-r.Context().Done(): + return + } + + fmt.Fprintf( + w, + "id: %s:8\nevent: run.resumed\ndata: {\"id\":%q,\"run_id\":%q,\"type\":\"run.resumed\",\"payload\":{\"call_id\":%q}}\n\n", + runID, + runID+":8", + runID, + callID, + ) + fmt.Fprintf( + w, + "id: %s:9\nevent: assistant.message.delta\ndata: {\"id\":%q,\"run_id\":%q,\"type\":\"assistant.message.delta\",\"payload\":{\"content\":\"Continuation after answer\"}}\n\n", + runID, + runID+":9", + runID, + ) + fmt.Fprintf( + w, + "id: %s:10\nevent: run.completed\ndata: {\"id\":%q,\"run_id\":%q,\"type\":\"run.completed\",\"payload\":{}}\n\n", + runID, + runID+":10", + runID, + ) + w.(http.Flusher).Flush() + }) + mux.HandleFunc("/v1/runs/"+runID+"/input", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + inputGets.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf( + w, + `{"run_id":%q,"call_id":%q,"tool":"AskUserQuestion","questions":[{"question":"Continue this conversation?","header":"Continue","options":[{"label":"Proceed","description":"Resume the run"}],"multiSelect":false}],"deadline_at":"2099-01-01T00:00:00Z"}`, + runID, + callID, + ) + case http.MethodPost: + inputPosts.Add(1) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read submitted answer: %v", err) + } + submittedBody <- body + releaseAnswer() + w.WriteHeader(http.StatusAccepted) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + defer releaseAnswer() + + cfg := tui.DefaultTUIConfig() + cfg.BaseURL = srv.URL + model := tui.New(cfg) + next, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = next.(tui.Model) + next, cmd := model.Update(tui.RunStartedMsg{RunID: runID}) + model = next.(tui.Model) + + msgCh := make(chan tea.Msg, 32) + deadline := time.Now().Add(5 * time.Second) + var dispatch func(tea.Cmd) + dispatch = func(pending tea.Cmd) { + if pending == nil { + return + } + go func() { + msg := pending() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, child := range batch { + dispatch(child) + } + return + } + if msg == nil { + return + } + select { + case msgCh <- msg: + case <-time.After(time.Until(deadline) + time.Second): + } + }() + } + dispatch(cmd) + + overlayObserved := false + for time.Now().Before(deadline) { + select { + case msg := <-msgCh: + next, followup := model.Update(msg) + model = next.(tui.Model) + dispatch(followup) + + if !overlayObserved && model.AskUserActive() && + strings.Contains(model.View(), "Continue this conversation?") && + strings.Contains(model.View(), "Proceed") { + overlayObserved = true + next, submit := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = next.(tui.Model) + dispatch(submit) + } + + if overlayObserved && !model.RunActive() && + strings.Contains(model.View(), "Continuation after answer") { + goto verified + } + case <-time.After(time.Until(deadline)): + } + } + t.Fatalf( + "waiting conversation did not complete: overlay=%t input_gets=%d input_posts=%d view=%q", + overlayObserved, + inputGets.Load(), + inputPosts.Load(), + model.View(), + ) + +verified: + if got := eventRequests.Load(); got != 1 { + t.Errorf("event stream requests = %d, want 1", got) + } + if got := inputGets.Load(); got != 1 { + t.Errorf("pending input GETs = %d, want 1", got) + } + if got := inputPosts.Load(); got != 1 { + t.Errorf("answer POSTs = %d, want 1", got) + } + select { + case raw := <-submittedBody: + var submitted struct { + Answers map[string]string `json:"answers"` + } + if err := json.Unmarshal(raw, &submitted); err != nil { + t.Fatalf("decode submitted answer: %v", err) + } + if got := submitted.Answers["Continue this conversation?"]; got != "Proceed" { + t.Errorf("submitted answer = %q, want Proceed", got) + } + default: + t.Fatal("answer request body was not captured") + } +} + +func TestAskUser_WaitingEnvelopeReconnectRetainsCanonicalRunID(t *testing.T) { + const ( + runID = "run-waiting-replay" + lastEventID = "run-waiting-replay:6" + waitingEvent = "run-waiting-replay:7" + ) + + headerSeen := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headerSeen <- r.Header.Get("Last-Event-ID") + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf( + w, + "id: %s\nevent: run.waiting_for_user\ndata: {\"id\":%q,\"run_id\":%q,\"type\":\"run.waiting_for_user\",\"payload\":{\"call_id\":\"call-replay\"}}\n\n", + waitingEvent, + waitingEvent, + runID, + ) + w.(http.Flusher).Flush() + })) + defer srv.Close() + + ch, stop := tui.StartSSEBridgeWithOptions( + t.Context(), + srv.URL, + tui.SSEBridgeOptions{LastEventID: lastEventID}, + ) + defer stop() + + select { + case msg := <-ch: + event, ok := msg.(tui.SSEEventMsg) + if !ok { + t.Fatalf("bridge message = %T, want SSEEventMsg", msg) + } + if event.RunID != runID { + t.Errorf("decoded run id = %q, want %q", event.RunID, runID) + } + if event.ID != waitingEvent { + t.Errorf("decoded event id = %q, want %q", event.ID, waitingEvent) + } + if strings.Contains(string(event.Raw), "run_id") { + t.Errorf("payload unexpectedly duplicates envelope run_id: %s", event.Raw) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for replayed waiting event") + } + + select { + case got := <-headerSeen: + if got != lastEventID { + t.Errorf("Last-Event-ID = %q, want %q", got, lastEventID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for replay request") + } +} diff --git a/cmd/harnesscli/tui/askuser_test.go b/cmd/harnesscli/tui/askuser_test.go index ef8a6c63b..2f6f15f7d 100644 --- a/cmd/harnesscli/tui/askuser_test.go +++ b/cmd/harnesscli/tui/askuser_test.go @@ -33,7 +33,8 @@ func TestAskUser_WaitingForUserSSE_SetsOverlayActive(t *testing.T) { m3, _ := model.Update(tui.SSEEventMsg{ EventType: "run.waiting_for_user", - Raw: []byte(`{"run_id":"run-ask-1","call_id":"call-q1"}`), + RunID: "run-ask-1", + Raw: []byte(`{"call_id":"call-q1"}`), }) model = m3.(tui.Model) @@ -82,7 +83,8 @@ func TestAskUser_WaitingForUserSSE_FetchesPendingQuestions(t *testing.T) { // Deliver waiting_for_user event m4, cmd := model.Update(tui.SSEEventMsg{ EventType: "run.waiting_for_user", - Raw: []byte(`{"run_id":"run-fetch-1","call_id":"call-q2"}`), + RunID: "run-fetch-1", + Raw: []byte(`{"call_id":"call-q2"}`), }) model = m4.(tui.Model) @@ -406,7 +408,8 @@ func TestRegression_WaitingForUser_SSEEventType_IsHandled(t *testing.T) { m3, _ := model.Update(tui.SSEEventMsg{ EventType: "run.waiting_for_user", - Raw: []byte(`{"run_id":"run-reg-wait","call_id":"call-reg1"}`), + RunID: "run-reg-wait", + Raw: []byte(`{"call_id":"call-reg1"}`), }) model = m3.(tui.Model) @@ -681,7 +684,8 @@ func TestAskUser_TUI_FetchURL_EscapesRunID(t *testing.T) { // Trigger the waiting_for_user SSE event which internally calls fetchAskUserPendingCmd m4, cmd := model.Update(tui.SSEEventMsg{ EventType: "run.waiting_for_user", - Raw: []byte(`{"run_id":"run/with/slashes","call_id":"call-escape-1"}`), + RunID: "run/with/slashes", + Raw: []byte(`{"call_id":"call-escape-1"}`), }) model = m4.(tui.Model) diff --git a/cmd/harnesscli/tui/bridge.go b/cmd/harnesscli/tui/bridge.go index 3a59ff412..922991532 100644 --- a/cmd/harnesscli/tui/bridge.go +++ b/cmd/harnesscli/tui/bridge.go @@ -290,6 +290,7 @@ func nonRetryableSSEError(status int, body []byte) error { type sseEnvelope struct { Type string `json:"type"` + RunID string `json:"run_id"` Payload json.RawMessage `json:"payload"` } @@ -313,7 +314,7 @@ func decodeSSE(event, data, id string) tea.Msg { } // Unknown event types are forwarded as SSEEventMsg so that consumers // can inspect EventType and Raw. No silent discard. - return SSEEventMsg{EventType: env.Type, Raw: env.Payload, ID: id} + return SSEEventMsg{EventType: env.Type, Raw: env.Payload, ID: id, RunID: env.RunID} } // toolDeltaCallID extracts the call_id field from a tool.output.delta diff --git a/cmd/harnesscli/tui/messages.go b/cmd/harnesscli/tui/messages.go index a05459771..36d8cdd8d 100644 --- a/cmd/harnesscli/tui/messages.go +++ b/cmd/harnesscli/tui/messages.go @@ -23,6 +23,7 @@ type SSEEventMsg struct { EventType string Raw json.RawMessage ID string + RunID string } // SSEErrorMsg signals a stream read/parse error. diff --git a/cmd/harnesscli/tui/messages_test.go b/cmd/harnesscli/tui/messages_test.go index 559a7dd85..93c5beef0 100644 --- a/cmd/harnesscli/tui/messages_test.go +++ b/cmd/harnesscli/tui/messages_test.go @@ -31,7 +31,7 @@ func TestTUI009_MsgTypeCoverage(t *testing.T) { func TestTUI009_SSEEventMsgRoundTrip(t *testing.T) { payload := json.RawMessage(`{"delta":"hello world"}`) - orig := tui.SSEEventMsg{EventType: "assistant.message.delta", Raw: payload} + orig := tui.SSEEventMsg{EventType: "assistant.message.delta", Raw: payload, RunID: "run-1"} // Verify fields preserved if orig.EventType != "assistant.message.delta" { t.Error("EventType not preserved") @@ -39,6 +39,9 @@ func TestTUI009_SSEEventMsgRoundTrip(t *testing.T) { if string(orig.Raw) != `{"delta":"hello world"}` { t.Errorf("Raw not preserved: %s", orig.Raw) } + if orig.RunID != "run-1" { + t.Errorf("RunID not preserved: %s", orig.RunID) + } } func TestTUI009_ZeroValueMsgsSafe(t *testing.T) { diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index 8591687a1..6548ec7bf 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -4396,14 +4396,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.costDisplay = m.costDisplay.Update(costSnapshotFromModel(&m)) } case "run.waiting_for_user": - // Extract run_id from the event payload, then fetch pending questions. + // Run identity belongs to the SSE envelope; the payload owns only + // event-specific fields such as call_id. var p struct { - RunID string `json:"run_id"` CallID string `json:"call_id"` } - if err := json.Unmarshal(msg.Raw, &p); err == nil && p.RunID != "" { - m.askUser = askUserState{active: true, runID: p.RunID, callID: p.CallID} - cmds = append(cmds, fetchAskUserPendingCmd(m.config.BaseURL, p.RunID, m.config.APIKey)) + if err := json.Unmarshal(msg.Raw, &p); err == nil && msg.RunID != "" { + m.askUser = askUserState{active: true, runID: msg.RunID, callID: p.CallID} + cmds = append(cmds, fetchAskUserPendingCmd(m.config.BaseURL, msg.RunID, m.config.APIKey)) } case "run.resumed": // Dismiss the ask-user overlay when the run resumes. diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index ef9c2f2d6..dd506058e 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -1,5 +1,31 @@ # Engineering Log +## 2026-07-31 (TUI Waiting Conversation Overlay — Issue #1058) + +- Symptom: an exact live-shape `run.waiting_for_user` SSE event reaches the TUI + bridge, but the overlay remains inactive and no pending-input GET occurs. +- Cause: `sseEnvelope` omits top-level `run_id`; `decodeSSE` forwards only the + payload, while the model waiting handler expects `run_id` inside that payload. + Existing tests injected that non-production nested shape after decoding. +- Reproduction: the public bridge/model path on `fedcf607` produced + `raw={"call_id":"call-1"} overlay_active=false`. +- Fix: `sseEnvelope` decodes top-level `run_id` into `SSEEventMsg.RunID`; + `Raw` stays payload-only, and the waiting handler uses the canonical message + field. Synthetic model tests now match the production split. +- TDD evidence: the corrected acceptance test failed before the fix with + `overlay=false input_gets=0 input_posts=0`, then passed in 0.01s after the + three-line seam repair. It proves the visible prompt/options, exact answer + POST, resume dismissal, and later assistant continuation. +- Replay evidence: a `Last-Event-ID` bridge request retains the same top-level + run identity and does not duplicate it into payload bytes. +- Verification: focused AskUser/SSE normal and race pass; complete TUI normal + and race pass; the full gate passes normal, complete race, and + `coveragegate: PASS (total=85.6%, min=80.0%, zero-functions=0)`. +- Environment learning: detached tmux made the two real-Keychain tests time out + at 15 seconds. The required full script was rerun—not waived—in the logged-in + foreground context, where `internal/modelstore` passed in normal and race + phases and the entire gate completed green. + ## 2026-07-30 (Workflow Failure-Event Test Timeout — Issue #1049) - Symptom: the full race gate reached a stored failed workflow state but timed diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 3f0efddc6..bef59393f 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -1,5 +1,22 @@ # Long-Term Thinking Log +## 2026-07-31 (TUI Waiting Conversation Overlay — Issue #1058) + +- Command intent: independently confirm and repair the native TUI failure where + a valid `run.waiting_for_user` event cannot open its answer overlay. +- User intent: a waiting multi-message conversation must visibly continue in + the TUI without a manual input-API workaround. +- Success definition: a production-shape top-level-`run_id` SSE event crosses + the real bridge/model, fetches and renders the pending question, submits the + answer, resumes, and renders the later conversation turn; reconnect/replay, + focused race, full regression, and hosted checks remain green. +- Guardrails: Issue #1058 only; one canonical run-ID normalization; no server, + native GUI, callback, cron, provider/model/tool, or epic-completion scope. +- Outcome: `SSEEventMsg` now retains envelope run identity while `Raw` remains + payload-only. The production-shape acceptance and replay control pass, as do + complete TUI normal/race tests and the repository gate at 85.6% coverage + with zero uncovered functions. + ## 2026-07-30 (Workflow Failure-Event Test Timeout — Issue #1049) - Command intent: clear the exact full-gate timeout blocking the verified diff --git a/docs/logs/observational-log.md b/docs/logs/observational-log.md index e5c81e794..de6a26176 100644 --- a/docs/logs/observational-log.md +++ b/docs/logs/observational-log.md @@ -2,6 +2,20 @@ Use this file for observations about system behavior without immediately prescribing code changes. +## 2026-07-31 (TUI SSE Envelope Identity) + +- Wire observation: harness run identity is top-level event-envelope metadata; + AskUserQuestion payload data contains the call identity, not a duplicate run + ID. +- Testing observation: model-only tests that construct payload bytes can pass + while the production bridge drops envelope metadata. Client acceptance tests + for lifecycle events must start at the real SSE envelope boundary. +- Ownership observation: replay and live delivery share `decodeSSE`, so keeping + run identity on the decoded message fixes both paths without payload mutation. +- Verification observation: the same localhost fixture can prove rendered + overlay text, answer transport, resume, and continued transcript without + replacing Bubble Tea's production model or SSE bridge with a test double. + ## 2026-07-30 (Scheduled Conversation Continuation Re-entry) - Native GUI observation: while Chat stayed visible, a later cron recurrence diff --git a/docs/logs/system-log.md b/docs/logs/system-log.md index f7b532598..6707de117 100644 --- a/docs/logs/system-log.md +++ b/docs/logs/system-log.md @@ -1,5 +1,22 @@ # System Log +## 2026-07-31 (TUI Run-Event Identity Boundary — Issue #1058) + +- System/component: run-scoped SSE envelope, TUI `decodeSSE`/`SSEEventMsg`, + `Model.Update`, and AskUserQuestion GET/POST input commands. +- Source of truth: `run_id` belongs to the event envelope; event-specific + fields remain in `payload`. +- Intended flow: decode envelope metadata once -> waiting handler uses the + decoded run ID -> GET pending input -> visible overlay -> POST answer -> + `run.resumed` and later assistant/terminal events continue on the same bridge. +- Lifecycle: initial delivery and `Last-Event-ID` replay use the same decoder. +- Security: existing SSE and input bearer authentication is unchanged; answers + and credentials are not logged. +- Failure boundary: losing envelope identity prevents the first input GET even + when the server broker, pending state, answer endpoint, and persistence work. +- Implemented boundary: `SSEEventMsg.RunID` carries envelope identity for every + non-terminal decoded event; `Raw` remains the unmodified event payload. + ## 2026-07-30 (Conversation Event Replay and GUI Reconciliation) - System/components: `store.ConversationEventReader`, the memory and SQLite run diff --git a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md new file mode 100644 index 000000000..219a92e03 --- /dev/null +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md @@ -0,0 +1,82 @@ +# Issue #1058 TUI waiting-overlay impact map + +## Task + +- Task / issue: TUI loses top-level SSE `run_id` (#1058). +- Plan: `2026-07-31-issue-1058-tui-waiting-overlay-plan.md` +- Owner: current issue worktree. +- Status: implemented and locally verified; review promotion pending. + +## Current Ownership, Callers, and Data Flow + +- Entry: run-scoped SSE from `internal/server/http_runs.go`. +- Owners: `tui.sseEnvelope`/`decodeSSE` -> `SSEEventMsg` -> + `Model.Update` -> `fetchAskUserPendingCmd`/`submitAskUserAnswerCmd`. +- Source of truth: top-level event-envelope `run_id`; payload owns call data. +- Searches: `rg` across `cmd/harnesscli`, `internal/server`, + `internal/harness`, and e2e tests for waiting/input/run-ID symbols. +- Similar paths: tool/plan approvals use model `RunID`; non-TUI AskUser uses + the known run ID outside this decoder. +- Conclusion: normalize once in `SSEEventMsg`; do not mutate every payload. + +## Config, API, CLI, and Tools + +- Config/defaults: none. +- API/wire: no public change to SSE or GET/POST `/input`. +- CLI: TUI behavior only; headless/non-TUI behavior unchanged. +- Errors: existing fetch/submit status messages remain authoritative. + +## Persistence and Compatibility + +- Schema/cache: none. +- Compatibility: additive internal field preserves current server envelopes. + Payload remains byte-for-byte payload-only. Replay uses the same decoder. +- Mixed versions: old TUI remains stalled; new TUI can answer already-pending + runs after reconnect. No data repair. + +## Lifecycle, Security, and Reliability + +- Preserve backpressure, bounded reconnect, `Last-Event-ID`, deadline, + cancellation, resume dismissal, and terminal handling. +- Preserve bearer auth on SSE and input requests. +- No prompt, answer, token, or credential logging. +- A fetch/submit failure remains recoverable through current status handling. + +## Product and Integration Surfaces + +- Server/runtime: wire producer unchanged. +- TUI: bridge, message, waiting handler, overlay/answer acceptance. +- Web/macOS/ACP: none; they do not consume this decoder. +- Provider/model/tool catalogs: none; AskUserQuestion schema is unchanged. +- Callbacks/cron/automation: none. +- UX: visible question/options, keyboard selection, resume, continuation. + +## Deployment and Operations + +- Deploy as the next `harnesscli` client build; no migration or flag. +- Observe stalled-wait reports and fetch-input errors. +- Roll back the isolated client commit if unrelated SSE decoding/reconnect + regresses. +- Operator/public runbooks: none. + +## Regression Tests + +- Red: production-shape SSE through bridge/model shows no overlay/no GET. +- Acceptance: overlay visible, exact GET/POST paths/body, resume dismissal, + later assistant output and terminal continuation. +- Replay: top-level run ID survives the resumed decoder path without a payload + copy or duplicate fetch. +- Commands: focused normal, focused race, package normal/race, and + `./scripts/test-regression.sh`. + +## Documentation and Handoff + +- Before code: issue, plan, impact map, active plan, long-term intent, plan + index, and diagnostic log entries. +- After code: final engineering/observational/system evidence and PR handoff. +- Public docs/release notes: none; intended behavior is unchanged. + +## Warning Check + +All required surfaces were searched and reconciled; unaffected surfaces carry +an explicit rationale above. diff --git a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md new file mode 100644 index 000000000..057975948 --- /dev/null +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md @@ -0,0 +1,68 @@ +# Issue #1058: Restore the TUI waiting-conversation overlay + +## Context + +- Governing issue: https://github.com/dennisonbertram/go-code/issues/1058 +- Problem: the TUI SSE decoder drops the envelope's top-level `run_id`, while + the `run.waiting_for_user` model handler expects that ID inside `payload`. +- User impact: a correctly paused AskUserQuestion run is invisible and cannot + be continued through the TUI. +- Constraints: repair the existing bridge/model seam only; preserve the public + SSE and input APIs, reconnect behavior, authentication, and other clients. + +## Scope + +- In scope: canonical run-ID retention in `SSEEventMsg`, the waiting handler, + a production-shape SSE-to-visible-overlay-to-answer-to-continuation + regression, replay/reconnect compatibility, and durable logs. +- Out of scope: PR #1055's server ordering work, native GUI, web, ACP, + approvals, callbacks, cron execution, provider/model/tool changes, and + completion claims for epics #1000 or #1010. + +## Documentation Contract + +- Feature status: implemented and locally verified; review promotion pending. +- Public docs affected: none; the intended AskUserQuestion behavior is already + documented. +- Implementation notes: update engineering, observational, and system logs + with red/green/full evidence. + +## Test Plan (TDD) + +- First red: drive a real top-level-`run_id` waiting envelope through + `StartSSEBridge`, require GET `/input`, visible options, Enter submission, + `run.resumed`, and later assistant output. +- Replay control: exercise the same decoder with a resume event ID and prove + the canonical run ID survives without a payload copy. +- Green/race: focused AskUser/SSE tests, complete TUI package normal and race, + then `./scripts/test-regression.sh`. +- Real path: localhost SSE/input smoke through the production bridge/model and + inspect the view, answer request, and continued transcript. + +## Cross-Surface Impact Map + +See `2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md`. + +## Implementation Checklist + +- [x] Contract-complete bug issue created. +- [x] Current architecture and open PR ownership searched. +- [x] Production-shape failure independently reproduced. +- [x] Cross-surface impact map completed. +- [x] Write and observe the expected failing acceptance regression. +- [x] Retain and consume the envelope run ID once. +- [x] Keep replay/reconnect and adjacent event decoding green. +- [x] Update durable logs with final evidence. +- [x] Pass focused normal/race and full regression. +- [ ] Commit, push, open a PR with `Closes #1058`, and verify hosted checks. + +## Risks and Mitigations + +- Risk: introducing a second run-ID source or changing payloads. + Mitigation: add `RunID` to the internal decoded message and leave `Raw` as + the untouched event payload. +- Risk: reconnect replays fetch the prompt twice. + Mitigation: pin exact event delivery/fetch counts and existing + `Last-Event-ID` behavior. +- Risk: synthetic tests continue encoding the wrong wire shape. + Mitigation: the acceptance fixture starts at the actual SSE envelope. diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index d88d3da9e..f17a9524c 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -1,5 +1,7 @@ # Plans Index +- `2026-07-31-issue-1058-tui-waiting-overlay-plan.md` — Issue #1058 test-first repair for the TUI losing top-level SSE run identity before AskUserQuestion rendering. +- `2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md` — Cross-surface impact map for Issue #1058's bridge/model/input lifecycle. - `2026-07-30-issue-1049-workflow-failure-timeout-plan.md` — Issue #1049 planned contention-tolerant workflow failure-event regression wait. - `2026-07-30-issue-1049-workflow-failure-timeout-impact-map.md` — Cross-surface impact map for Issue #1049. - `2026-07-30-issue-1044-ask-status-race-plan.md` — Issue #1044 planned synchronization of the AskUserQuestion status regression fixture. diff --git a/docs/plans/active-plan.md b/docs/plans/active-plan.md index afccb67d4..3f0d60358 100644 --- a/docs/plans/active-plan.md +++ b/docs/plans/active-plan.md @@ -1,5 +1,10 @@ # Active Plan +Current status: Issue #1058's TUI SSE run-identity repair is implemented +test-first. The visible overlay/answer/resume/continuation acceptance, reconnect +control, focused/package normal and race checks, and the full regression gate +pass; commit, PR, and hosted checks remain. + Current status: Issue #1023 anytime contextual `/feedback` intake is implemented test-first and verified in its isolated worktree; targeted, full normal/race, coverage-gate, and real TUI bundle checks pass, with merge pending. @@ -11,6 +16,7 @@ Remaining work before merge is final verification and any requested review/cleanup. Current active plans: +- `2026-07-31-issue-1058-tui-waiting-overlay-plan.md` - `2026-07-30-issue-1023-feedback-intake-plan.md` - `2026-06-26-adapter-first-eval-harness-plan.md` - `2026-04-05-orchestration-program-plan.md` From daed3581062a6be95fc32f45c78ec3c96c72a61d Mon Sep 17 00:00:00 2001 From: Dennison Date: Fri, 31 Jul 2026 01:56:04 +0200 Subject: [PATCH 2/3] record hosted verification for issue 1058 --- docs/logs/engineering-log.md | 2 ++ docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md | 2 +- docs/plans/active-plan.md | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index dd506058e..33bbd8805 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -25,6 +25,8 @@ at 15 seconds. The required full script was rerun—not waived—in the logged-in foreground context, where `internal/modelstore` passed in normal and race phases and the entire gate completed green. +- Hosted verification: PR #1061 is mergeable; its first hosted `test-race` + check passed in 2m11s and `test-fast` passed in 3m14s. ## 2026-07-30 (Workflow Failure-Event Test Timeout — Issue #1049) diff --git a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md index 057975948..ac6124308 100644 --- a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md @@ -54,7 +54,7 @@ See `2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md`. - [x] Keep replay/reconnect and adjacent event decoding green. - [x] Update durable logs with final evidence. - [x] Pass focused normal/race and full regression. -- [ ] Commit, push, open a PR with `Closes #1058`, and verify hosted checks. +- [x] Commit, push, open PR #1061 with `Closes #1058`, and verify hosted checks. ## Risks and Mitigations diff --git a/docs/plans/active-plan.md b/docs/plans/active-plan.md index 3f0d60358..86f70286c 100644 --- a/docs/plans/active-plan.md +++ b/docs/plans/active-plan.md @@ -3,7 +3,8 @@ Current status: Issue #1058's TUI SSE run-identity repair is implemented test-first. The visible overlay/answer/resume/continuation acceptance, reconnect control, focused/package normal and race checks, and the full regression gate -pass; commit, PR, and hosted checks remain. +pass. PR #1061 is open and its first hosted `test-fast` and `test-race` runs +are green; no merge was requested or performed. Current status: Issue #1023 anytime contextual `/feedback` intake is implemented test-first and verified in its isolated worktree; targeted, full normal/race, From 221ab05da62ae6b16968ccc6dabb7472b6b67866 Mon Sep 17 00:00:00 2001 From: Dennison Date: Fri, 31 Jul 2026 02:14:34 +0200 Subject: [PATCH 3/3] fix(tui): discard stale pending input fetches --- cmd/harnesscli/tui/api_auth_test.go | 2 +- cmd/harnesscli/tui/askuser.go | 44 +++-- .../tui/askuser_pending_race_test.go | 160 ++++++++++++++++++ cmd/harnesscli/tui/askuser_test.go | 52 +++--- cmd/harnesscli/tui/model.go | 47 +++-- docs/logs/engineering-log.md | 20 +++ docs/logs/long-term-thinking-log.md | 8 + docs/logs/observational-log.md | 7 + docs/logs/system-log.md | 5 + ...sue-1058-tui-waiting-overlay-impact-map.md | 10 +- ...-31-issue-1058-tui-waiting-overlay-plan.md | 14 +- 11 files changed, 319 insertions(+), 50 deletions(-) create mode 100644 cmd/harnesscli/tui/askuser_pending_race_test.go diff --git a/cmd/harnesscli/tui/api_auth_test.go b/cmd/harnesscli/tui/api_auth_test.go index 11a96723f..f6e1a2b3a 100644 --- a/cmd/harnesscli/tui/api_auth_test.go +++ b/cmd/harnesscli/tui/api_auth_test.go @@ -155,7 +155,7 @@ func harnessAuthCases() []harnessAuthCase { { name: "fetchAskUserPendingCmd", call: func(ts *httptest.Server, apiKey string) any { - return fetchAskUserPendingCmd(ts.URL, "run-1", apiKey)() + return fetchAskUserPendingCmd(ts.URL, "run-1", "call-1", 1, apiKey)() }, }, { diff --git a/cmd/harnesscli/tui/askuser.go b/cmd/harnesscli/tui/askuser.go index 55363ddc0..bff6f0d0a 100644 --- a/cmd/harnesscli/tui/askuser.go +++ b/cmd/harnesscli/tui/askuser.go @@ -41,10 +41,12 @@ type AskUserQuestion struct { // AskUserPendingMsg is sent to the model when pending questions have been // fetched from GET /v1/runs/{id}/input and are ready to display. type AskUserPendingMsg struct { - RunID string - CallID string - Questions []AskUserQuestion - DeadlineAt time.Time + RunID string + WaitingCallID string + Generation uint64 + CallID string + Questions []AskUserQuestion + DeadlineAt time.Time } // AskUserSubmittedMsg is sent when the POST /v1/runs/{id}/input succeeds. @@ -68,7 +70,10 @@ type AskUserTimeoutMsg struct { // askUserFetchErrorMsg is sent when GET /v1/runs/{id}/input fails. // This is unexported — it is handled inside the model to set a status message. type askUserFetchErrorMsg struct { - err string + runID string + waitingCallID string + generation uint64 + err string } // ─── Ask User State (stored on Model) ──────────────────────────────────────── @@ -79,6 +84,7 @@ type askUserState struct { active bool runID string callID string + generation uint64 questions []AskUserQuestion deadlineAt time.Time // qIdx is the index of the question currently displayed (for multi-question sets). @@ -91,20 +97,28 @@ type askUserState struct { // fetchAskUserPendingCmd fetches the pending AskUserQuestion for the given runID // via GET /v1/runs/{id}/input and returns an AskUserPendingMsg or askUserFetchErrorMsg. -func fetchAskUserPendingCmd(baseURL, runID, apiKey string) tea.Cmd { +func fetchAskUserPendingCmd(baseURL, runID, waitingCallID string, generation uint64, apiKey string) tea.Cmd { return func() tea.Msg { + fetchError := func(err string) askUserFetchErrorMsg { + return askUserFetchErrorMsg{ + runID: runID, + waitingCallID: waitingCallID, + generation: generation, + err: err, + } + } fetchURL := strings.TrimRight(baseURL, "/") + "/v1/runs/" + url.PathEscape(runID) + "/input" req, err := newHarnessRequest(context.Background(), http.MethodGet, fetchURL, nil, apiKey) if err != nil { - return askUserFetchErrorMsg{err: fmt.Sprintf("fetch pending input: %s", err.Error())} + return fetchError(fmt.Sprintf("fetch pending input: %s", err.Error())) } resp, err := httpClientWithTimeout.Do(req) if err != nil { - return askUserFetchErrorMsg{err: fmt.Sprintf("fetch pending input: %s", err.Error())} + return fetchError(fmt.Sprintf("fetch pending input: %s", err.Error())) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return askUserFetchErrorMsg{err: fmt.Sprintf("fetch pending input: HTTP %d", resp.StatusCode)} + return fetchError(fmt.Sprintf("fetch pending input: HTTP %d", resp.StatusCode)) } // Parse the AskUserQuestionPending payload from the server. @@ -115,13 +129,15 @@ func fetchAskUserPendingCmd(baseURL, runID, apiKey string) tea.Cmd { DeadlineAt time.Time `json:"deadline_at"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return askUserFetchErrorMsg{err: fmt.Sprintf("decode pending input: %s", err.Error())} + return fetchError(fmt.Sprintf("decode pending input: %s", err.Error())) } return AskUserPendingMsg{ - RunID: payload.RunID, - CallID: payload.CallID, - Questions: payload.Questions, - DeadlineAt: payload.DeadlineAt, + RunID: payload.RunID, + WaitingCallID: waitingCallID, + Generation: generation, + CallID: payload.CallID, + Questions: payload.Questions, + DeadlineAt: payload.DeadlineAt, } } } diff --git a/cmd/harnesscli/tui/askuser_pending_race_test.go b/cmd/harnesscli/tui/askuser_pending_race_test.go new file mode 100644 index 000000000..7b81574b9 --- /dev/null +++ b/cmd/harnesscli/tui/askuser_pending_race_test.go @@ -0,0 +1,160 @@ +package tui_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "go-agent-harness/cmd/harnesscli/tui" +) + +func TestAskUser_LatePendingAfterResumeIsDiscarded(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + writePendingQuestion(w, "run-resume-race", "call-resume-race", "Already answered?") + })) + defer srv.Close() + + model := newAskUserRaceModel(t, srv.URL, "run-resume-race") + next, fetchCmd := model.Update(tui.SSEEventMsg{ + EventType: "run.waiting_for_user", + RunID: "run-resume-race", + Raw: []byte(`{"call_id":"call-resume-race"}`), + }) + model = next.(tui.Model) + if fetchCmd == nil { + t.Fatal("expected waiting event to start pending-input fetch") + } + + fetched := make(chan tea.Msg, 1) + go func() { fetched <- fetchCmd() }() + waitForAskUserRaceSignal(t, started, "pending GET to start") + + next, _ = model.Update(tui.SSEEventMsg{ + EventType: "run.resumed", + RunID: "run-resume-race", + Raw: []byte(`{"call_id":"call-resume-race"}`), + }) + model = next.(tui.Model) + close(release) + + select { + case msg := <-fetched: + next, _ = model.Update(msg) + model = next.(tui.Model) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for released pending GET") + } + + if model.AskUserActive() { + t.Fatal("late pending result resurrected overlay after run.resumed") + } + if strings.Contains(model.View(), "Already answered?") { + t.Fatal("late pending result rendered an already-answered question") + } +} + +func TestAskUser_SupersededPendingFetchIsDiscarded(t *testing.T) { + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + switch requests.Add(1) { + case 1: + close(firstStarted) + <-releaseFirst + writePendingQuestion(w, "run-superseded", "call-old", "Old question?") + case 2: + writePendingQuestion(w, "run-superseded", "call-new", "New question?") + default: + http.Error(w, "unexpected pending fetch", http.StatusInternalServerError) + } + })) + defer srv.Close() + + model := newAskUserRaceModel(t, srv.URL, "run-superseded") + next, oldFetchCmd := model.Update(tui.SSEEventMsg{ + EventType: "run.waiting_for_user", + RunID: "run-superseded", + Raw: []byte(`{"call_id":"call-old"}`), + }) + model = next.(tui.Model) + if oldFetchCmd == nil { + t.Fatal("expected first waiting event to start pending-input fetch") + } + oldFetched := make(chan tea.Msg, 1) + go func() { oldFetched <- oldFetchCmd() }() + waitForAskUserRaceSignal(t, firstStarted, "first pending GET to start") + + next, newFetchCmd := model.Update(tui.SSEEventMsg{ + EventType: "run.waiting_for_user", + RunID: "run-superseded", + Raw: []byte(`{"call_id":"call-new"}`), + }) + model = next.(tui.Model) + if newFetchCmd == nil { + t.Fatal("expected newer waiting event to start pending-input fetch") + } + next, _ = model.Update(newFetchCmd()) + model = next.(tui.Model) + if !strings.Contains(model.View(), "New question?") { + t.Fatalf("newer wait did not render before old GET completed; view=%q", model.View()) + } + + close(releaseFirst) + select { + case msg := <-oldFetched: + next, _ = model.Update(msg) + model = next.(tui.Model) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for old pending GET") + } + + view := model.View() + if !strings.Contains(view, "New question?") { + t.Fatalf("late old fetch overwrote the newer question; view=%q", view) + } + if strings.Contains(view, "Old question?") { + t.Fatalf("late old fetch rendered superseded question; view=%q", view) + } +} + +func newAskUserRaceModel(t *testing.T, baseURL, runID string) tui.Model { + t.Helper() + cfg := tui.DefaultTUIConfig() + cfg.BaseURL = baseURL + model := tui.New(cfg) + next, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + model = next.(tui.Model).WithCancelRun(func() {}) + next, _ = model.Update(tui.RunStartedMsg{RunID: runID}) + return next.(tui.Model) +} + +func waitForAskUserRaceSignal(t *testing.T, signal <-chan struct{}, description string) { + t.Helper() + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s", description) + } +} + +func writePendingQuestion(w http.ResponseWriter, runID, callID, question string) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf( + w, + `{"run_id":%q,"call_id":%q,"tool":"AskUserQuestion","questions":[{"question":%q,"header":"Race","options":[{"label":"Answer","description":"Continue"}],"multiSelect":false}],"deadline_at":"2099-01-01T00:00:00Z"}`, + runID, + callID, + question, + ) +} diff --git a/cmd/harnesscli/tui/askuser_test.go b/cmd/harnesscli/tui/askuser_test.go index 2f6f15f7d..3910ce7d4 100644 --- a/cmd/harnesscli/tui/askuser_test.go +++ b/cmd/harnesscli/tui/askuser_test.go @@ -133,8 +133,7 @@ func TestAskUser_Overlay_RendersQuestionAndOptions(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) view := model.View() if !strings.Contains(view, "Where should I look first?") { @@ -171,8 +170,7 @@ func TestAskUser_Overlay_ArrowKeysNavigateOptions(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) // Initially the first option should be selected (index 0) if model.AskUserSelectedIdx() != 0 { @@ -240,8 +238,7 @@ func TestAskUser_Enter_SubmitsAnswerAndDismissesOverlay(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m4, _ := model.Update(pending) - model = m4.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) // Press Enter to confirm first option "Left" m5, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -305,8 +302,7 @@ func TestAskUser_RunResumed_DismissesOverlay(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) if !model.AskUserActive() { t.Fatal("prerequisite: expected overlay to be active before run.resumed") @@ -353,8 +349,7 @@ func TestAskUser_DeadlineExpired_ShowsTimeoutAndDismisses(t *testing.T) { }, DeadlineAt: time.Now().Add(-1 * time.Second), // already past } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) // Send the deadline tick — callID must match so the overlay is dismissed m4, _ := model.Update(tui.AskUserTimeoutMsg{RunID: "run-timeout-1", CallID: "call-t1"}) @@ -442,8 +437,7 @@ func TestRegression_RunResumed_SSEEventType_DismissesOverlay(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) m4, _ := model.Update(tui.SSEEventMsg{ EventType: "run.resumed", @@ -480,8 +474,7 @@ func TestRegression_AskUser_OverlayKeyPriorityBeforeOtherKeys(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) // Down arrow should navigate overlay (change selected index), not scroll viewport idxBefore := model.AskUserSelectedIdx() @@ -527,8 +520,7 @@ func TestAskUser_StaleTimeout_DoesNotDismissNewerPrompt(t *testing.T) { }, DeadlineAt: time.Now().Add(10 * time.Minute), } - m3, _ := model.Update(firstPending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, firstPending, 1) // Second question (different callID) — replaces the first secondPending := tui.AskUserPendingMsg{ @@ -546,8 +538,7 @@ func TestAskUser_StaleTimeout_DoesNotDismissNewerPrompt(t *testing.T) { }, DeadlineAt: time.Now().Add(10 * time.Minute), } - m4, _ := model.Update(secondPending) - model = m4.(tui.Model) + model = activateAskUserPending(t, model, secondPending, 2) // Sanity check: second question is active if !model.AskUserActive() { @@ -589,8 +580,7 @@ func TestAskUser_CurrentTimeout_DismissesCurrentPrompt(t *testing.T) { }, DeadlineAt: time.Now().Add(10 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) // Fire timeout with the CURRENT callID m4, _ := model.Update(tui.AskUserTimeoutMsg{ @@ -632,8 +622,7 @@ func TestAskUser_MultiSelect_ShowsWarningIndicator(t *testing.T) { }, DeadlineAt: time.Now().Add(5 * time.Minute), } - m3, _ := model.Update(pending) - model = m3.(tui.Model) + model = activateAskUserPending(t, model, pending, 1) view := model.View() if !strings.Contains(view, "multi-select not supported") { @@ -704,3 +693,22 @@ func TestAskUser_TUI_FetchURL_EscapesRunID(t *testing.T) { t.Errorf("expected percent-escaped runID in path; got: %q", receivedPath) } } + +func activateAskUserPending( + t *testing.T, + model tui.Model, + pending tui.AskUserPendingMsg, + generation uint64, +) tui.Model { + t.Helper() + next, _ := model.Update(tui.SSEEventMsg{ + EventType: "run.waiting_for_user", + RunID: pending.RunID, + Raw: []byte(fmt.Sprintf(`{"call_id":%q}`, pending.CallID)), + }) + model = next.(tui.Model) + pending.WaitingCallID = pending.CallID + pending.Generation = generation + next, _ = model.Update(pending) + return next.(tui.Model) +} diff --git a/cmd/harnesscli/tui/model.go b/cmd/harnesscli/tui/model.go index 6548ec7bf..e912da787 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -381,7 +381,8 @@ type Model struct { // askUser holds the state for an in-progress AskUserQuestion interaction. // askUser.active is true when the overlay is shown. - askUser askUserState + askUser askUserState + askUserGeneration uint64 // toolApproval holds the state for an in-progress tool-approval decision. // toolApproval.active is true when the overlay is shown. @@ -4401,12 +4402,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var p struct { CallID string `json:"call_id"` } - if err := json.Unmarshal(msg.Raw, &p); err == nil && msg.RunID != "" { - m.askUser = askUserState{active: true, runID: msg.RunID, callID: p.CallID} - cmds = append(cmds, fetchAskUserPendingCmd(m.config.BaseURL, msg.RunID, m.config.APIKey)) + if err := json.Unmarshal(msg.Raw, &p); err == nil && msg.RunID != "" && p.CallID != "" { + m.askUserGeneration++ + m.askUser = askUserState{ + active: true, + runID: msg.RunID, + callID: p.CallID, + generation: m.askUserGeneration, + } + cmds = append(cmds, fetchAskUserPendingCmd( + m.config.BaseURL, + msg.RunID, + p.CallID, + m.askUserGeneration, + m.config.APIKey, + )) } case "run.resumed": - // Dismiss the ask-user overlay when the run resumes. + // Dismiss the ask-user overlay and invalidate any pending GET. + m.askUserGeneration++ m.askUser = askUserState{} case "steering.received": // Server-confirmed steering injection (harness drainSteering): the @@ -4515,13 +4529,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case AskUserPendingMsg: // Pending questions fetched — populate the overlay and start deadline timer. - // Accept both when already activated by run.waiting_for_user and when - // delivered directly (e.g. in tests or future code paths). - if (!m.askUser.active || m.askUser.runID == msg.RunID) && len(msg.Questions) > 0 { + // A late result must not resurrect a resumed wait or overwrite a newer + // call in the same run. + if m.askUser.active && + m.askUser.runID == msg.RunID && + m.askUser.callID == msg.WaitingCallID && + m.askUser.callID == msg.CallID && + m.askUser.generation == msg.Generation && + len(msg.Questions) > 0 { m.askUser = askUserState{ active: true, runID: msg.RunID, callID: msg.CallID, + generation: msg.Generation, questions: msg.Questions, deadlineAt: msg.DeadlineAt, selectedIdx: 0, @@ -4559,9 +4579,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case askUserFetchErrorMsg: - // Failed to fetch pending input — show error and clear overlay. - m.askUser = askUserState{} - cmds = append(cmds, m.setStatusMsg("fetch input failed: "+msg.err)) + // A stale failure from a resumed or superseded wait is irrelevant. + if m.askUser.active && + m.askUser.runID == msg.runID && + m.askUser.callID == msg.waitingCallID && + m.askUser.generation == msg.generation { + m.askUser = askUserState{} + cmds = append(cmds, m.setStatusMsg("fetch input failed: "+msg.err)) + } case ModelsFetchedMsg: currentStarred := m.modelSwitcher.StarredIDs() diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 33bbd8805..cc79e75ea 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -2,6 +2,26 @@ ## 2026-07-31 (TUI Waiting Conversation Overlay — Issue #1058) +- Review finding: PR #1061 comment 3687057509 showed that the first fix + correctly opens the overlay but leaves pending GET completion correlated + only by run ID. A resume can clear state before the GET returns, after which + the late result reactivates the answered prompt; an older result can also + overwrite a newer same-run call. +- Expanded TDD contract: hold the production pending GET in flight across a + resume and across a superseding waiting call. Both regressions must fail + before implementation, then pass using exact call/generation correlation for + both success and error messages. +- Review-fix red evidence: the focused command failed with + `late pending result resurrected overlay after run.resumed` and showed the + old question replacing the newer one. +- Review fix: every waiting event advances a model-owned generation; its GET + captures the run, call, and generation. Pending success and error messages + mutate state only when all captured values still match the active wait, and + the success response call ID must also match the originating call. +- Review-fix verification: focused AskUser/SSE normal and race passed; the + complete TUI package passed normal in 37.926s and race in 40.596s; the full + foreground `./scripts/test-regression.sh` passed normal, complete race, and + `coveragegate` at 85.6% with zero uncovered functions. - Symptom: an exact live-shape `run.waiting_for_user` SSE event reaches the TUI bridge, but the overlay remains inactive and no pending-input GET occurs. - Cause: `sseEnvelope` omits top-level `run_id`; `decodeSSE` forwards only the diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index bef59393f..63a783fd2 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -1608,3 +1608,11 @@ Decision rule: when uncertain, default to `command intent` and `user intent` bel - Next verification step: write the attached-image/direct-publication tests, confirm their expected failures, implement the smallest publisher and selective cleanup path, then run live GitHub proof. +## 2026-07-31 — Issue #1058 review expansion + +- Command intent: address PR #1061 review comment 3687057509 before handoff by + preventing late pending-input GET results from resurrecting or overwriting + an AskUserQuestion overlay. +- Success: pending success/error messages mutate TUI state only for the exact + active run, call ID, and wait generation; resume and supersession races have + deterministic red/green coverage while normal and replay flows remain green. diff --git a/docs/logs/observational-log.md b/docs/logs/observational-log.md index de6a26176..c6fa70b32 100644 --- a/docs/logs/observational-log.md +++ b/docs/logs/observational-log.md @@ -4,6 +4,13 @@ Use this file for observations about system behavior without immediately prescri ## 2026-07-31 (TUI SSE Envelope Identity) +- Lifecycle observation: run identity alone is insufficient for asynchronous + pending-input responses because one run can resume or enter a newer waiting + call before an earlier GET completes. The stable acceptance key is the + active run, originating call ID, response call ID, and model generation. +- Test observation: a blocked localhost handler makes both races deterministic: + release the response only after resume, or let a second request finish before + releasing the first. Neither test depends on scheduler timing. - Wire observation: harness run identity is top-level event-envelope metadata; AskUserQuestion payload data contains the call identity, not a duplicate run ID. diff --git a/docs/logs/system-log.md b/docs/logs/system-log.md index 6707de117..c452085b8 100644 --- a/docs/logs/system-log.md +++ b/docs/logs/system-log.md @@ -16,6 +16,11 @@ when the server broker, pending state, answer endpoint, and persistence work. - Implemented boundary: `SSEEventMsg.RunID` carries envelope identity for every non-terminal decoded event; `Raw` remains the unmodified event payload. +- Wait correlation: each waiting event advances a model-owned generation and + launches GET with the envelope run ID plus payload call ID. Success and error + messages are ignored unless that exact run/call/generation is still active; + successful payload call ID must also match. Resume invalidates the generation + before clearing the overlay, and a newer wait supersedes the prior one. ## 2026-07-30 (Conversation Event Replay and GUI Reconciliation) diff --git a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md index 219a92e03..afd483b66 100644 --- a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md @@ -5,7 +5,7 @@ - Task / issue: TUI loses top-level SSE `run_id` (#1058). - Plan: `2026-07-31-issue-1058-tui-waiting-overlay-plan.md` - Owner: current issue worktree. -- Status: implemented and locally verified; review promotion pending. +- Status: correlation fix locally verified; review promotion pending. ## Current Ownership, Callers, and Data Flow @@ -13,6 +13,9 @@ - Owners: `tui.sseEnvelope`/`decodeSSE` -> `SSEEventMsg` -> `Model.Update` -> `fetchAskUserPendingCmd`/`submitAskUserAnswerCmd`. - Source of truth: top-level event-envelope `run_id`; payload owns call data. +- Wait-instance source of truth: each `run.waiting_for_user` activation owns a + model generation and call ID. Async GET success/error messages are valid + only while that exact wait instance remains active. - Searches: `rg` across `cmd/harnesscli`, `internal/server`, `internal/harness`, and e2e tests for waiting/input/run-ID symbols. - Similar paths: tool/plan approvals use model `RunID`; non-TUI AskUser uses @@ -38,6 +41,9 @@ - Preserve backpressure, bounded reconnect, `Last-Event-ID`, deadline, cancellation, resume dismissal, and terminal handling. +- A resume invalidates the active wait generation. A newer wait in the same run + supersedes the prior generation. Late successes and errors must not + resurrect or overwrite the overlay. - Preserve bearer auth on SSE and input requests. - No prompt, answer, token, or credential logging. - A fetch/submit failure remains recoverable through current status handling. @@ -66,6 +72,8 @@ later assistant output and terminal continuation. - Replay: top-level run ID survives the resumed decoder path without a payload copy or duplicate fetch. +- Concurrency: deterministically block a real GET, then deliver resume or a + newer call before releasing it; only the current active generation may render. - Commands: focused normal, focused race, package normal/race, and `./scripts/test-regression.sh`. diff --git a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md index ac6124308..18ae011db 100644 --- a/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md @@ -14,7 +14,8 @@ - In scope: canonical run-ID retention in `SSEEventMsg`, the waiting handler, a production-shape SSE-to-visible-overlay-to-answer-to-continuation - regression, replay/reconnect compatibility, and durable logs. + regression, exact wait call/generation correlation for asynchronous pending + fetches, replay/reconnect compatibility, and durable logs. - Out of scope: PR #1055's server ordering work, native GUI, web, ACP, approvals, callbacks, cron execution, provider/model/tool changes, and completion claims for epics #1000 or #1010. @@ -34,6 +35,9 @@ `run.resumed`, and later assistant output. - Replay control: exercise the same decoder with a resume event ID and prove the canonical run ID survives without a payload copy. +- Concurrency regressions: hold a real pending GET open across `run.resumed` + and across a newer same-run waiting call; require the late result to be + discarded in both cases. - Green/race: focused AskUser/SSE tests, complete TUI package normal and race, then `./scripts/test-regression.sh`. - Real path: localhost SSE/input smoke through the production bridge/model and @@ -55,6 +59,11 @@ See `2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md`. - [x] Update durable logs with final evidence. - [x] Pass focused normal/race and full regression. - [x] Commit, push, open PR #1061 with `Closes #1058`, and verify hosted checks. +- [x] Observe deterministic red regressions for resume and supersession races. +- [x] Correlate pending success/error messages to exact wait call/generation. +- [x] Rerun focused/package normal and race plus full foreground regression. +- [ ] Push the review fix, resolve/reply to comment 3687057509, and request + Codex re-review at the new exact head. ## Risks and Mitigations @@ -64,5 +73,8 @@ See `2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md`. - Risk: reconnect replays fetch the prompt twice. Mitigation: pin exact event delivery/fetch counts and existing `Last-Event-ID` behavior. +- Risk: an asynchronous GET completes after resume or after a newer wait. + Mitigation: model-owned generations plus exact run/call checks gate both + pending successes and failures before they can mutate overlay state. - Risk: synthetic tests continue encoding the wrong wire shape. Mitigation: the acceptance fixture starts at the actual SSE envelope.