diff --git a/.github/workflows/gui-cron-marker.yml b/.github/workflows/gui-cron-marker.yml new file mode 100644 index 00000000..a2d637b4 --- /dev/null +++ b/.github/workflows/gui-cron-marker.yml @@ -0,0 +1,10 @@ +name: gui-cron-marker +on: + schedule: + - cron: '* * * * *' +jobs: + gui-cron: + runs-on: ubuntu-latest + steps: + - name: Print GUI_CRON_MARKER + run: echo "GUI_CRON_MARKER" diff --git a/cmd/harnesscli/tui/api_auth_test.go b/cmd/harnesscli/tui/api_auth_test.go index 11a96723..f6e1a2b3 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 55363ddc..bff6f0d0 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 00000000..7b81574b --- /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_sse_acceptance_test.go b/cmd/harnesscli/tui/askuser_sse_acceptance_test.go new file mode 100644 index 00000000..e0851d9a --- /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 ef8a6c63..3910ce7d 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) @@ -131,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?") { @@ -169,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 { @@ -238,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}) @@ -303,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") @@ -351,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"}) @@ -406,7 +403,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) @@ -439,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", @@ -477,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() @@ -524,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{ @@ -543,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() { @@ -586,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{ @@ -629,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") { @@ -681,7 +673,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) @@ -700,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/messages_test.go b/cmd/harnesscli/tui/messages_test.go index 559a7dd8..93c5beef 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 be0d1f1f..abfae283 100644 --- a/cmd/harnesscli/tui/model.go +++ b/cmd/harnesscli/tui/model.go @@ -426,7 +426,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. @@ -4750,17 +4751,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "usage.delta": m.applyUsageDelta(msg.Raw) 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 != "" && 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 "run.completed", "run.failed": // A conversation stream deliberately remains alive after an @@ -4999,13 +5013,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, @@ -5043,9 +5063,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 dc840192..4a151531 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -2152,6 +2152,53 @@ polling is opportunistic after local waiter registration: a transient poll read error is retried instead of unregistering the waiter or masking a later local/remote resolution; the caller context remains the termination bound. +## 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 + 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. +- 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/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 5734bcd6..46d25718 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -1055,6 +1055,22 @@ - Non-goals: Redesigning structured questions or approval behavior. - Next verification step: Add a deterministic gated-broker regression, confirm the ordering bug, then introduce post-registration notification. +## 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) @@ -3112,3 +3128,11 @@ Decision rule: when uncertain, default to `command intent` and `user intent` bel canonical executable bytes. - Fail closed for relative, symlink/noncanonical, missing, unreadable, or digest-mismatched paths before any live inventory request or coverage claim. +## 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 46885f8b..4549b111 100644 --- a/docs/logs/observational-log.md +++ b/docs/logs/observational-log.md @@ -1022,6 +1022,26 @@ Use this file for observations about system behavior without immediately prescri deterministic regression. Injecting the already-captured outcome signals into a pure arbitration seam proves precedence without sleeps while existing subprocess tests retain the real process/wait integration coverage. +## 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. +- 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) diff --git a/docs/logs/system-log.md b/docs/logs/system-log.md index 0bc47add..57702c14 100644 --- a/docs/logs/system-log.md +++ b/docs/logs/system-log.md @@ -943,6 +943,27 @@ daemon, and workspace-isolation metadata. to API/CLI/TUI/web/macOS clients; no client-specific error mapping changes. - Failure boundary: a close-only failure remains visible, while simultaneous close and wait failures report the process exit and bounded stderr. +## 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. +- 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 new file mode 100644 index 00000000..afd483b6 --- /dev/null +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-impact-map.md @@ -0,0 +1,90 @@ +# 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: correlation fix 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. +- 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 + 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. +- 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. + +## 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. +- 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`. + +## 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 00000000..18ae011d --- /dev/null +++ b/docs/plans/2026-07-31-issue-1058-tui-waiting-overlay-plan.md @@ -0,0 +1,80 @@ +# 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, 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. + +## 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. +- 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 + 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. +- [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 + +- 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: 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. diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index 626a4891..2616786f 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -411,6 +411,8 @@ - `2026-07-30-issue-1054-waiting-pending-order-impact-map.md` — Cross-surface impact map for Issue #1054. - `2026-07-31-issue-1003-remote-cronsd-plan.md` — Issue #1003 authenticated, fresh-store-ready, restart-replay-safe remote cronsd harness dispatch plan. - `2026-07-31-issue-1003-remote-cronsd-impact-map.md` — Cross-surface impact map for Issue #1003 durable remote-start and API-key bootstrap boundary. +- `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 3dd46da5..c34cd30a 100644 --- a/docs/plans/active-plan.md +++ b/docs/plans/active-plan.md @@ -402,6 +402,11 @@ Current status: Issue #1054 now makes pending AskUserQuestion input readable before `waiting_for_user` status/events become visible. Focused repeated normal/race, complete harness normal/race, and repository normal/race/coverage gates pass; promotion is pending before the cron/callback repair chain. +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. 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, @@ -437,6 +442,7 @@ Current active plans: - `2026-07-31-issue-1068-dispatcher-shutdown-isolation-plan.md` - `2026-07-30-issue-1052-provider-key-capture-sync-plan.md` - `2026-07-30-issue-1054-waiting-pending-order-plan.md` +- `2026-07-31-issue-1058-tui-waiting-overlay-plan.md` - `2026-07-30-issue-1023-feedback-intake-plan.md` - `2026-07-31-issue-1003-remote-cronsd-plan.md` - `2026-06-26-adapter-first-eval-harness-plan.md`