package controllers_test import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strconv" "strings" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) type fakeSessionService struct { sessions map[domain.SessionID]domain.Session sent string cleanupProjects []domain.ProjectID cleanupResult []domain.SessionID cleanupSkipped []sessionsvc.CleanupSkipped spawnErr error claimErr error listPRErr error } func newFakeSessionService() *fakeSessionService { now := time.Now().UTC() s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle, TerminalHandleID: "ao-1/terminal_0"} return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} } func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) { var out []domain.Session for _, s := range f.sessions { if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { continue } if filter.Active != nil && s.IsTerminated == *filter.Active { continue } if filter.OrchestratorOnly && s.Kind != domain.KindOrchestrator { continue } out = append(out, s) } return out, nil } func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { if f.spawnErr != nil { return domain.Session{}, f.spawnErr } now := time.Now().UTC() s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} f.sessions[s.ID] = s return s, nil } func (f *fakeSessionService) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { if clean { active := true existing, err := f.List(ctx, sessionsvc.ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) if err != nil { return domain.Session{}, err } for _, o := range existing { if _, err := f.Kill(ctx, o.ID); err != nil { return domain.Session{}, err } } } return f.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) } func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain.Session, error) { s, ok := f.sessions[id] if !ok { return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s, nil } func (f *fakeSessionService) SetPreview(_ context.Context, id domain.SessionID, previewURL string) (domain.Session, error) { s, ok := f.sessions[id] if !ok { return domain.Session{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } s.Metadata.PreviewURL = previewURL // Mirror the store: every set bumps the revision, even when the URL is // unchanged, so the controller's refresh contract can be exercised here. s.Metadata.PreviewRevision++ f.sessions[id] = s return s, nil } func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) { s := f.sessions[id] s.IsTerminated = false s.Status = domain.StatusIdle f.sessions[id] = s return s, nil } func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) { s := f.sessions[id] s.IsTerminated = true s.Status = domain.StatusTerminated f.sessions[id] = s return true, nil } func (f *fakeSessionService) RollbackSpawn(_ context.Context, id domain.SessionID) (sessionsvc.RollbackOutcome, error) { if _, ok := f.sessions[id]; ok { delete(f.sessions, id) return sessionsvc.RollbackOutcome{Deleted: true}, nil } return sessionsvc.RollbackOutcome{}, nil } func (f *fakeSessionService) Cleanup(_ context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error) { f.cleanupProjects = append(f.cleanupProjects, project) cleaned := f.cleanupResult if cleaned == nil { cleaned = []domain.SessionID{"ao-1"} } return sessionsvc.CleanupOutcome{Cleaned: cleaned, Skipped: f.cleanupSkipped}, nil } func (f *fakeSessionService) Rename(_ context.Context, id domain.SessionID, displayName string) error { s, ok := f.sessions[id] if !ok { return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } s.DisplayName = displayName f.sessions[id] = s return nil } func (f *fakeSessionService) Send(_ context.Context, _ domain.SessionID, message string) error { f.sent = message return nil } func (f *fakeSessionService) ListPRs(_ context.Context, id domain.SessionID) ([]domain.PRFacts, error) { if f.listPRErr != nil { return nil, f.listPRErr } if _, ok := f.sessions[id]; !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return []domain.PRFacts{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142", Number: 142, CI: domain.CIPassing, Review: domain.ReviewRequired, Mergeability: domain.MergeMergeable, UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC)}}, nil } func (f *fakeSessionService) ListPRSummaries(_ context.Context, id domain.SessionID) ([]sessionsvc.PRSummary, error) { if f.listPRErr != nil { return nil, f.listPRErr } if _, ok := f.sessions[id]; !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return []sessionsvc.PRSummary{{ URL: "https://github.com/aoagents/agent-orchestrator/pull/142", HTMLURL: "https://github.com/aoagents/agent-orchestrator/pull/142", Number: 142, Title: "Wire SCM summaries", State: domain.PRStateOpen, Provider: "github", Repo: "aoagents/agent-orchestrator", Author: "ada", SourceBranch: "codex/scm-observer-v1", TargetBranch: "main", HeadSHA: "abc123", CI: sessionsvc.PRCISummary{State: domain.CIFailing, FailingChecks: []sessionsvc.PRFailingCheck{{ Name: "unit", Status: domain.PRCheckFailed, Conclusion: "failure", URL: "https://github.com/aoagents/agent-orchestrator/actions/runs/1", }}}, Review: sessionsvc.PRReviewSummary{ Decision: domain.ReviewChangesRequest, HasUnresolvedHumanComments: true, UnresolvedBy: []sessionsvc.PRUnresolvedReviewer{{ ReviewerID: "reviewer-a", Count: 1, ReviewURL: "https://github.com/aoagents/agent-orchestrator/pull/142#pullrequestreview-1", Links: []sessionsvc.PRReviewCommentLink{{URL: "https://github.com/aoagents/agent-orchestrator/pull/142#discussion_r1", File: "main.go", Line: 12}}, }}, }, Mergeability: sessionsvc.PRMergeabilitySummary{ State: domain.MergeConflicting, Reasons: []string{"conflicts"}, PRURL: "https://github.com/aoagents/agent-orchestrator/pull/142", }, UpdatedAt: time.Date(2026, 6, 4, 12, 0, 0, 0, time.UTC), }}, nil } func (f *fakeSessionService) ClaimPR(_ context.Context, id domain.SessionID, ref string, opts sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) { if f.claimErr != nil { return sessionsvc.ClaimPRResult{}, f.claimErr } if _, ok := f.sessions[id]; !ok { return sessionsvc.ClaimPRResult{}, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } prs, _ := f.ListPRs(context.Background(), id) return sessionsvc.ClaimPRResult{PRs: prs, TakenOverFrom: []domain.SessionID{}, BranchChanged: true}, nil } func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Server { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Sessions: svc}, httpd.ControlDeps{})) t.Cleanup(srv.Close) return srv } func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{})) t.Cleanup(srv.Close) body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions", "") assertJSON(t, headers) assertErrorCode(t, body, status, http.StatusNotImplemented, "NOT_IMPLEMENTED") } func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { svc := newFakeSessionService() s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{Branch: "qa/modal-worker", WorkspacePath: "/tmp/private-worktree", RuntimeHandleID: "runtime-1", Prompt: "private prompt"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "") if status != http.StatusOK { t.Fatalf("GET sessions = %d, want 200; body=%s", status, body) } var list struct { Sessions []sessionBody `json:"sessions"` } mustJSON(t, body, &list) if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) || list.Sessions[0].TerminalHandleID != "ao-1/terminal_0" { t.Fatalf("list = %#v", list) } if list.Sessions[0].Branch != "qa/modal-worker" { t.Fatalf("branch = %q, want qa/modal-worker", list.Sessions[0].Branch) } var rawList struct { Sessions []map[string]any `json:"sessions"` } mustJSON(t, body, &rawList) if _, ok := rawList.Sessions[0]["metadata"]; ok { t.Fatalf("list leaked metadata: %s", body) } if _, ok := rawList.Sessions[0]["workspacePath"]; ok { t.Fatalf("list leaked workspacePath: %s", body) } if _, ok := rawList.Sessions[0]["prompt"]; ok { t.Fatalf("list leaked prompt: %s", body) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix","displayName":"my worker"}`) if status != http.StatusCreated { t.Fatalf("POST session = %d, want 201; body=%s", status, body) } var spawned struct { Session sessionBody `json:"session"` } mustJSON(t, body, &spawned) if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { t.Fatalf("spawned = %#v", spawned) } if spawned.Session.DisplayName != "my worker" { t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker") } body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") if status != http.StatusOK { t.Fatalf("GET session = %d, want 200; body=%s", status, body) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/send", "{\"message\":\"con\\u0000tinue\"}") if status != http.StatusOK || svc.sent != "continue" { t.Fatalf("send status=%d sent=%q body=%s", status, svc.sent, body) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/kill", "") if status != http.StatusOK { t.Fatalf("kill = %d, want 200; body=%s", status, body) } var killed struct { SessionID string `json:"sessionId"` Freed bool `json:"freed"` } mustJSON(t, body, &killed) if killed.SessionID != "ao-2" || !killed.Freed { t.Fatalf("kill response = %#v", killed) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-2/restore", "") if status != http.StatusOK { t.Fatalf("restore = %d, want 200; body=%s", status, body) } body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-2", `{"displayName":"Renamed"}`) if status != http.StatusOK { t.Fatalf("rename = %d, want 200; body=%s", status, body) } var renamed struct { OK bool `json:"ok"` SessionID string `json:"sessionId"` DisplayName string `json:"displayName"` } mustJSON(t, body, &renamed) if !renamed.OK || renamed.SessionID != "ao-2" || renamed.DisplayName != "Renamed" { t.Fatalf("rename response = %#v", renamed) } if svc.sessions["ao-2"].DisplayName != "Renamed" { t.Fatalf("session displayName not updated: %+v", svc.sessions["ao-2"]) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/orchestrators", `{"projectId":"ao"}`) if status != http.StatusCreated { t.Fatalf("orchestrator = %d, want 201; body=%s", status, body) } } func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write index: %v", err) } if err := os.WriteFile(filepath.Join(workspace, "styles.css"), []byte(`body { color: red; }`), 0o644); err != nil { t.Fatalf("write css: %v", err) } s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/preview", "") if status != http.StatusOK { t.Fatalf("preview = %d, want 200; body=%s", status, body) } var preview struct { SessionID string `json:"sessionId"` PreviewURL string `json:"previewUrl"` Entry string `json:"entry"` } mustJSON(t, body, &preview) if preview.SessionID != "ao-1" || preview.Entry != "index.html" || preview.PreviewURL == "" { t.Fatalf("preview response = %#v", preview) } if strings.Contains(preview.PreviewURL, workspace) { t.Fatalf("preview leaked workspace path: %s", preview.PreviewURL) } if !strings.Contains(preview.PreviewURL, "/index.html") { t.Fatalf("preview URL = %q, want index.html asset path", preview.PreviewURL) } parsed, err := url.Parse(preview.PreviewURL) if err != nil { t.Fatalf("parse preview URL: %v", err) } body, status, headers := doRequest(t, srv, "GET", parsed.RequestURI(), "") if status != http.StatusOK { t.Fatalf("preview file = %d, want 200; body=%s", status, body) } if !strings.Contains(headers.Get("Content-Type"), "text/html") { t.Fatalf("content type = %q, want text/html", headers.Get("Content-Type")) } if !strings.Contains(string(body), "styles.css") { t.Fatalf("preview body did not serve index: %s", body) } } func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) { svc := newFakeSessionService() srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"http://localhost:5173/"}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if resp.Session.PreviewURL != "http://localhost:5173/" { t.Fatalf("response previewUrl = %q, want explicit url", resp.Session.PreviewURL) } if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:5173/" { t.Fatalf("persisted previewUrl = %q, want explicit url", got) } } func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write index: %v", err) } s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if !strings.Contains(resp.Session.PreviewURL, "/index.html") { t.Fatalf("response previewUrl = %q, want autodetected index.html URL", resp.Session.PreviewURL) } if strings.Contains(resp.Session.PreviewURL, workspace) { t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL) } } func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() // An index.html exists, so bare `ao preview` returns to the workspace entry // instead of sticking to the last explicit target. if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write index: %v", err) } s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, PreviewURL: "http://localhost:4321/docs"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { t.Fatalf("response previewUrl = %q, want workspace index files URL", resp.Session.PreviewURL) } } func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write index: %v", err) } s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, PreviewURL: "index.html"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") { t.Fatalf("response previewUrl = %q, want index.html files URL", resp.Session.PreviewURL) } if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != resp.Session.PreviewURL { t.Fatalf("persisted previewUrl = %q, want normalized response URL %q", got, resp.Session.PreviewURL) } } func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t *testing.T) { svc := newFakeSessionService() s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir(), PreviewURL: "http://localhost:4321/docs"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if resp.Session.PreviewURL != "http://localhost:4321/docs" { t.Fatalf("response previewUrl = %q, want reused existing target", resp.Session.PreviewURL) } } func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) { svc := newFakeSessionService() workspace := t.TempDir() if err := os.MkdirAll(filepath.Join(workspace, "dist"), 0o755); err != nil { t.Fatalf("mkdir dist: %v", err) } if err := os.WriteFile(filepath.Join(workspace, "dist", "index.html"), []byte(``), 0o644); err != nil { t.Fatalf("write dist index: %v", err) } s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: workspace} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"./dist/index.html"}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/dist/index.html") { t.Fatalf("response previewUrl = %q, want dist/index.html files URL", resp.Session.PreviewURL) } if strings.Contains(resp.Session.PreviewURL, workspace) { t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL) } // The resolved files URL actually serves the local file. parsed, err := url.Parse(resp.Session.PreviewURL) if err != nil { t.Fatalf("parse preview URL: %v", err) } fileBody, fileStatus, _ := doRequest(t, srv, "GET", parsed.RequestURI(), "") if fileStatus != http.StatusOK { t.Fatalf("serve local file = %d, want 200; body=%s", fileStatus, fileBody) } } func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) { svc := newFakeSessionService() file := filepath.Join(t.TempDir(), "implementation_plan.html") if err := os.WriteFile(file, []byte(``), 0o644); err != nil { t.Fatalf("write file: %v", err) } srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(file)+`}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) parsed, err := url.Parse(resp.Session.PreviewURL) if err != nil { t.Fatalf("parse preview url: %v", err) } if parsed.Scheme != "file" { t.Fatalf("previewUrl = %q, want file URL", resp.Session.PreviewURL) } } func TestSessionsAPI_SetPreviewMissingAbsoluteFilePathFailsWithoutOverwriting(t *testing.T) { svc := newFakeSessionService() missing := filepath.Join(t.TempDir(), "implmentation_plan.html") s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:4321/docs"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(missing)+`}`) if status != http.StatusNotFound { t.Fatalf("set missing absolute preview = %d, want 404; body=%s", status, body) } if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:4321/docs" { t.Fatalf("persisted previewUrl = %q, want existing target preserved", got) } } func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) { svc := newFakeSessionService() srv := newSessionTestServer(t, svc) readRevision := func() int64 { body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":"http://localhost:5173/"}`) if status != http.StatusOK { t.Fatalf("set preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewRevision int64 `json:"previewRevision"` } `json:"session"` } mustJSON(t, body, &resp) return resp.Session.PreviewRevision } first := readRevision() second := readRevision() if second <= first { t.Fatalf("revision did not advance on same-URL re-run: first=%d second=%d", first, second) } } func TestSessionsAPI_ClearPreviewResetsURL(t *testing.T) { svc := newFakeSessionService() s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:5173/"} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "DELETE", "/api/v1/sessions/ao-1/preview", "") if status != http.StatusOK { t.Fatalf("clear preview = %d, want 200; body=%s", status, body) } var resp struct { Session struct { PreviewURL string `json:"previewUrl"` } `json:"session"` } mustJSON(t, body, &resp) if resp.Session.PreviewURL != "" { t.Fatalf("response previewUrl = %q, want empty after clear", resp.Session.PreviewURL) } if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "" { t.Fatalf("persisted previewUrl = %q, want empty after clear", got) } } func TestSessionsAPI_ClearPreviewNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "DELETE", "/api/v1/sessions/missing-1/preview", "") assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") } func TestSessionsAPI_SetPreviewEmptyURLNoEntry(t *testing.T) { svc := newFakeSessionService() s := svc.sessions["ao-1"] s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir()} svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`) assertErrorCode(t, body, status, http.StatusNotFound, "NO_PREVIEW_ENTRY") } func TestSessionsAPI_SetPreviewNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/missing-1/preview", `{"url":"http://x"}`) assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") } func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) { svc := newFakeSessionService() svc.spawnErr = apierr.Invalid("BRANCH_NOT_FETCHED", `workspace: branch is not fetched: "feature/missing"`, nil) srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","kind":"worker","branch":"feature/missing","prompt":"fix"}`) assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED") } // TestSessionsAPI_SpawnRejectsOverlongDisplayName asserts the spawn endpoint // caps displayName at 20 characters even though the field itself is optional // (the desktop new-task dialog omits it). `ao spawn` enforces the same limit // CLI-side before the request is sent. func TestSessionsAPI_SpawnRejectsOverlongDisplayName(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) overlong := strings.Repeat("x", 21) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","harness":"codex","displayName":"`+overlong+`"}`) assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_TOO_LONG") } func TestSessionsAPI_RenameNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/missing-1", `{"displayName":"Renamed"}`) assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") } func TestSessionsAPI_RenameValidation(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{"displayName":" "}`) assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_REQUIRED") body, status, _ = doRequest(t, srv, "PATCH", "/api/v1/sessions/ao-1", `{`) assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_JSON") } func TestSessionsAPI_ListOrchestratorsOnly(t *testing.T) { svc := newFakeSessionService() now := time.Now().UTC() svc.sessions["ao-orch"] = domain.Session{ SessionRecord: domain.SessionRecord{ ID: "ao-orch", ProjectID: "ao", Kind: domain.KindOrchestrator, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now, }, Status: domain.StatusIdle, } svc.sessions["other-orch"] = domain.Session{ SessionRecord: domain.SessionRecord{ ID: "other-orch", ProjectID: "other", Kind: domain.KindOrchestrator, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now, }, Status: domain.StatusIdle, } srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "GET", "/api/v1/orchestrators", "") if status != http.StatusOK { t.Fatalf("GET orchestrators = %d, want 200; body=%s", status, body) } var list struct { Sessions []sessionBody `json:"sessions"` } mustJSON(t, body, &list) if len(list.Sessions) != 2 { t.Fatalf("len(orchestrators) = %d, want 2; body=%s", len(list.Sessions), body) } got := map[string]string{} for _, sess := range list.Sessions { got[sess.ID] = sess.Kind } if got["ao-orch"] != string(domain.KindOrchestrator) || got["other-orch"] != string(domain.KindOrchestrator) { t.Fatalf("missing orchestrators: %#v", got) } if _, ok := got["ao-1"]; ok { t.Fatalf("worker session leaked into orchestrator list: %#v", got) } } func TestSessionsAPI_SendValidation(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/send", `{"message":""}`) assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED") } func TestSessionsAPI_CleanupWithProjectFilter(t *testing.T) { svc := newFakeSessionService() svc.cleanupResult = []domain.SessionID{"ao-1"} svc.cleanupSkipped = []sessionsvc.CleanupSkipped{{SessionID: "ao-2", Reason: "workspace has uncommitted changes"}} srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup?project=ao", "") if status != http.StatusOK { t.Fatalf("cleanup = %d, want 200; body=%s", status, body) } var got struct { OK bool `json:"ok"` Cleaned []string `json:"cleaned"` Skipped []struct { SessionID string `json:"sessionId"` Reason string `json:"reason"` } `json:"skipped"` } mustJSON(t, body, &got) if !got.OK || len(got.Cleaned) != 1 || got.Cleaned[0] != "ao-1" { t.Fatalf("cleanup response = %#v", got) } if len(got.Skipped) != 1 || got.Skipped[0].SessionID != "ao-2" || got.Skipped[0].Reason != "workspace has uncommitted changes" { t.Fatalf("cleanup skipped = %#v, want preserved workspace with reason", got.Skipped) } if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "ao" { t.Fatalf("cleanupProjects = %#v, want [ao]", svc.cleanupProjects) } } func TestSessionsAPI_CleanupWithoutProjectFilter(t *testing.T) { svc := newFakeSessionService() svc.cleanupResult = []domain.SessionID{"ao-1", "other-1"} srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/cleanup", "") if status != http.StatusOK { t.Fatalf("cleanup = %d, want 200; body=%s", status, body) } var got struct { Cleaned []string `json:"cleaned"` } mustJSON(t, body, &got) if len(got.Cleaned) != 2 || got.Cleaned[0] != "ao-1" || got.Cleaned[1] != "other-1" { t.Fatalf("cleanup response = %#v", got) } if len(svc.cleanupProjects) != 1 || svc.cleanupProjects[0] != "" { t.Fatalf("cleanupProjects = %#v, want empty project filter", svc.cleanupProjects) } } type sessionBody struct { ID string `json:"id"` ProjectID string `json:"projectId"` IssueID string `json:"issueId"` Kind string `json:"kind"` Harness string `json:"harness"` DisplayName string `json:"displayName"` Branch string `json:"branch"` Status string `json:"status"` TerminalHandleID string `json:"terminalHandleId"` } func TestSessionsAPI_PRRoutes(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions/ao-1/pr", "") if status != http.StatusOK { t.Fatalf("GET PRs = %d body=%s", status, body) } var listed struct { SessionID string `json:"sessionId"` PRs []struct { URL string `json:"url"` Number int `json:"number"` Title string `json:"title"` State string `json:"state"` CI struct { State string `json:"state"` FailingChecks []struct { Name string `json:"name"` Status string `json:"status"` Conclusion string `json:"conclusion"` URL string `json:"url"` LogTail string `json:"logTail"` } `json:"failingChecks"` } `json:"ci"` Review struct { Decision string `json:"decision"` UnresolvedBy []struct { ReviewerID string `json:"reviewerId"` Count int `json:"count"` ReviewURL string `json:"reviewUrl"` Links []struct { URL string `json:"url"` File string `json:"file"` Line int `json:"line"` Body string `json:"body"` } `json:"links"` } `json:"unresolvedBy"` } `json:"review"` Mergeability struct { State string `json:"state"` Reasons []string `json:"reasons"` PRURL string `json:"prUrl"` ConflictFiles []struct { Path string `json:"path"` } `json:"conflictFiles"` } `json:"mergeability"` } `json:"prs"` } mustJSON(t, body, &listed) if listed.SessionID != "ao-1" || len(listed.PRs) != 1 || listed.PRs[0].State != "open" || listed.PRs[0].Title == "" { t.Fatalf("GET shape = %#v", listed) } if checks := listed.PRs[0].CI.FailingChecks; len(checks) != 1 || checks[0].Name != "unit" || checks[0].LogTail != "" { t.Fatalf("failing checks = %#v", checks) } if reviewers := listed.PRs[0].Review.UnresolvedBy; len(reviewers) != 1 || reviewers[0].ReviewerID != "reviewer-a" || reviewers[0].ReviewURL == "" || reviewers[0].Links[0].Body != "" { t.Fatalf("reviewers = %#v", reviewers) } if merge := listed.PRs[0].Mergeability; merge.State != "conflicting" || len(merge.ConflictFiles) != 0 || merge.PRURL == "" { t.Fatalf("mergeability = %#v", merge) } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", `{"pr":"142"}`) if status != http.StatusOK { t.Fatalf("claim = %d body=%s", status, body) } var claimed struct { OK bool `json:"ok"` SessionID string `json:"sessionId"` PRs []any `json:"prs"` BranchChanged bool `json:"branchChanged"` TakenOverFrom []string `json:"takenOverFrom"` } mustJSON(t, body, &claimed) if !claimed.OK || claimed.SessionID != "ao-1" || len(claimed.PRs) != 1 || !claimed.BranchChanged || len(claimed.TakenOverFrom) != 0 { t.Fatalf("claim shape = %#v", claimed) } } func TestSessionsAPI_ClaimPRErrors(t *testing.T) { cases := []struct { name string body string err error code int want string }{ {"bad json", `{`, nil, http.StatusBadRequest, "INVALID_JSON"}, {"missing pr", `{}`, nil, http.StatusBadRequest, "PR_REQUIRED"}, {"invalid ref", `{"pr":"x"}`, sessionsvc.ErrInvalidPRRef, http.StatusBadRequest, "INVALID_PR_REF"}, {"session missing", `{"pr":"142"}`, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session"), http.StatusNotFound, "SESSION_NOT_FOUND"}, {"pr missing", `{"pr":"142"}`, sessionsvc.ErrPRNotFound, http.StatusNotFound, "PR_NOT_FOUND"}, {"not open", `{"pr":"142"}`, sessionsvc.ErrPRNotOpen, http.StatusConflict, "PR_NOT_OPEN"}, {"claimed", `{"pr":"142","allowTakeover":false}`, ports.PRClaimedByActiveSessionError{Owner: "ao-2"}, http.StatusConflict, "PR_CLAIMED_BY_ACTIVE_SESSION"}, {"not claimable", `{"pr":"142"}`, sessionsvc.ErrSessionNotClaimable, http.StatusUnprocessableEntity, "SESSION_NOT_CLAIMABLE"}, {"mismatch", `{"pr":"142"}`, sessionsvc.ErrProjectMismatch, http.StatusUnprocessableEntity, "PR_PROJECT_MISMATCH"}, {"scm", `{"pr":"142"}`, sessionsvc.ErrSCMUnavailable, http.StatusServiceUnavailable, "SCM_UNAVAILABLE"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc := newFakeSessionService() svc.claimErr = tc.err srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/pr/claim", tc.body) assertErrorCode(t, body, status, tc.code, tc.want) }) } }