Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/gui-cron-marker.yml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion cmd/harnesscli/tui/api_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)()
},
},
{
Expand Down
44 changes: 30 additions & 14 deletions cmd/harnesscli/tui/askuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) ────────────────────────────────────────
Expand All @@ -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).
Expand All @@ -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.
Expand All @@ -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,
}
}
}
Expand Down
160 changes: 160 additions & 0 deletions cmd/harnesscli/tui/askuser_pending_race_test.go
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading
Loading