From 6e4db1a20d5cdbcc0b6b0043b1d222c3372a2483 Mon Sep 17 00:00:00 2001 From: Amber Agent Date: Sun, 1 Mar 2026 22:02:16 +0000 Subject: [PATCH] feat(public-api): add POST /v1/sessions/:id/runs endpoint for AG-UI prompt delivery Adds a new endpoint that accepts a simplified prompt and converts it to AG-UI RunAgentInput format before proxying to the backend agui/run endpoint. Returns {runId, threadId} for clients to track the run via the events stream. Also fixes pre-existing gofmt formatting issues in middleware.go and main.go. Co-Authored-By: Claude Sonnet 4.6 --- .../public-api/handlers/integration_test.go | 151 ++++++++++++++++++ components/public-api/handlers/middleware.go | 7 +- components/public-api/handlers/sessions.go | 74 +++++++++ components/public-api/main.go | 7 +- components/public-api/types/dto.go | 11 ++ 5 files changed, 245 insertions(+), 5 deletions(-) diff --git a/components/public-api/handlers/integration_test.go b/components/public-api/handlers/integration_test.go index 5f1485dd4..446045597 100644 --- a/components/public-api/handlers/integration_test.go +++ b/components/public-api/handlers/integration_test.go @@ -27,6 +27,7 @@ func setupTestRouter() *gin.Engine { v1.POST("/sessions", CreateSession) v1.GET("/sessions/:id", GetSession) v1.DELETE("/sessions/:id", DeleteSession) + v1.POST("/sessions/:id/runs", CreateSessionRun) } return r @@ -238,6 +239,156 @@ func TestE2E_ProjectMismatchAttack(t *testing.T) { } } +func TestE2E_CreateSessionRun(t *testing.T) { + tests := []struct { + name string + sessionID string + requestBody string + backendStatus int + backendResp string + expectedStatus int + expectRunID bool + }{ + { + name: "Successful run creation", + sessionID: "test-session", + requestBody: `{"prompt": "Fix the authentication bug"}`, + backendStatus: http.StatusOK, + backendResp: `{"runId": "run-abc123", "threadId": "test-session"}`, + expectedStatus: http.StatusOK, + expectRunID: true, + }, + { + name: "Missing prompt returns 400", + sessionID: "test-session", + requestBody: `{}`, + backendStatus: http.StatusOK, + backendResp: `{}`, + expectedStatus: http.StatusBadRequest, + expectRunID: false, + }, + { + name: "Backend error forwarded", + sessionID: "test-session", + requestBody: `{"prompt": "Do something"}`, + backendStatus: http.StatusBadRequest, + backendResp: `{"error": "Session not running"}`, + expectedStatus: http.StatusBadRequest, + expectRunID: false, + }, + { + name: "Invalid session ID returns 400", + sessionID: "INVALID-SESSION", + requestBody: `{"prompt": "Do something"}`, + backendStatus: http.StatusOK, + backendResp: `{}`, + expectedStatus: http.StatusBadRequest, + expectRunID: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify path contains agui/run for valid requests + if tt.expectedStatus != http.StatusBadRequest || tt.backendStatus != http.StatusOK { + if !strings.Contains(r.URL.Path, "agui/run") { + t.Errorf("Expected path to contain agui/run, got %s", r.URL.Path) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.backendStatus) + w.Write([]byte(tt.backendResp)) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/"+tt.sessionID+"/runs", + strings.NewReader(tt.requestBody)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d: %s", tt.expectedStatus, w.Code, w.Body.String()) + } + + if tt.expectRunID { + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + if resp["runId"] == "" { + t.Errorf("Expected runId in response, got %v", resp) + } + if resp["threadId"] == "" { + t.Errorf("Expected threadId in response, got %v", resp) + } + } + }) + } +} + +func TestE2E_CreateSessionRun_MessageFormat(t *testing.T) { + // Verify the AG-UI message format sent to the backend + var capturedBody map[string]interface{} + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"runId": "run-xyz", "threadId": "my-session"}`)) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/sessions/my-session/runs", + strings.NewReader(`{"prompt": "Add unit tests"}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify AG-UI format: threadId and messages array + if capturedBody["threadId"] != "my-session" { + t.Errorf("Expected threadId=my-session, got %v", capturedBody["threadId"]) + } + messages, ok := capturedBody["messages"].([]interface{}) + if !ok || len(messages) != 1 { + t.Fatalf("Expected 1 message in AG-UI format, got %v", capturedBody["messages"]) + } + msg, ok := messages[0].(map[string]interface{}) + if !ok { + t.Fatalf("Expected message to be an object, got %T", messages[0]) + } + if msg["role"] != "user" { + t.Errorf("Expected role=user, got %v", msg["role"]) + } + if msg["content"] != "Add unit tests" { + t.Errorf("Expected content='Add unit tests', got %v", msg["content"]) + } + if msg["id"] == "" { + t.Error("Expected non-empty message id") + } +} + func TestE2E_DeleteSession(t *testing.T) { deleted := false backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/components/public-api/handlers/middleware.go b/components/public-api/handlers/middleware.go index 5b866f6e2..7175dc02c 100644 --- a/components/public-api/handlers/middleware.go +++ b/components/public-api/handlers/middleware.go @@ -109,9 +109,10 @@ func extractProjectFromToken(token string) string { // 1. PURPOSE: Used exclusively for routing (extracting project namespace from ServiceAccount tokens) // 2. NO TRUST: The extracted value is NEVER used for authorization decisions // 3. BACKEND VALIDATES: The Go backend performs FULL token validation including: -// - Signature verification against K8s API server public keys -// - Expiration checking -// - RBAC enforcement via SelfSubjectAccessReview +// - Signature verification against K8s API server public keys +// - Expiration checking +// - RBAC enforcement via SelfSubjectAccessReview +// // 4. FAIL-SAFE: If the token is invalid/forged, the backend rejects it with 401/403 // // DO NOT use this function's output for: diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index 758914e18..f704b36c7 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "time" "ambient-code-public-api/types" @@ -221,6 +222,79 @@ func DeleteSession(c *gin.Context) { forwardErrorResponse(c, resp.StatusCode, body) } +// CreateSessionRun handles POST /v1/sessions/:id/runs +// Delivers a prompt to an active session via the AG-UI run endpoint. +func CreateSessionRun(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + var req types.CreateRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Build AG-UI RunAgentInput from the simple prompt. + // The message ID uses the session ID and current timestamp for uniqueness. + msgID := fmt.Sprintf("msg-%s-%d", sessionID, time.Now().UnixNano()) + backendReq := map[string]interface{}{ + "threadId": sessionID, + "messages": []map[string]interface{}{ + { + "id": msgID, + "role": "user", + "content": req.Prompt, + }, + }, + } + + reqBody, err := json.Marshal(backendReq) + if err != nil { + log.Printf("Failed to marshal run request for session %s: %v", sessionID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/agui/run", project, sessionID) + + resp, err := ProxyRequest(c, http.MethodPost, path, reqBody) + if err != nil { + log.Printf("Backend request failed for session run %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response for session run %s: %v", sessionID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + var runResp types.RunResponse + if err := json.Unmarshal(body, &runResp); err != nil { + log.Printf("Failed to parse backend run response for session %s: %v", sessionID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, runResp) +} + // forwardErrorResponse forwards backend error with consistent JSON format func forwardErrorResponse(c *gin.Context, statusCode int, body []byte) { // Try to parse as JSON error response diff --git a/components/public-api/main.go b/components/public-api/main.go index fb20ac418..6e0f23e14 100644 --- a/components/public-api/main.go +++ b/components/public-api/main.go @@ -96,6 +96,9 @@ func main() { v1.POST("/sessions", handlers.CreateSession) v1.GET("/sessions/:id", handlers.GetSession) v1.DELETE("/sessions/:id", handlers.DeleteSession) + + // Session runs (AG-UI prompt delivery) + v1.POST("/sessions/:id/runs", handlers.CreateSessionRun) } // Get port from environment or default to 8081 @@ -143,8 +146,8 @@ func getAllowedOrigins() []string { // Default: allow common development origins return []string{ - "http://localhost:3000", // Next.js dev server - "http://localhost:8080", // Frontend in kind + "http://localhost:3000", // Next.js dev server + "http://localhost:8080", // Frontend in kind "https://*.apps-crc.testing", // CRC routes } } diff --git a/components/public-api/types/dto.go b/components/public-api/types/dto.go index 9faafa8d1..6e9fcd90f 100644 --- a/components/public-api/types/dto.go +++ b/components/public-api/types/dto.go @@ -36,3 +36,14 @@ type ErrorResponse struct { Error string `json:"error"` Message string `json:"message,omitempty"` } + +// CreateRunRequest is the request body for delivering a prompt to an active session +type CreateRunRequest struct { + Prompt string `json:"prompt" binding:"required"` +} + +// RunResponse is the response after starting a run +type RunResponse struct { + RunID string `json:"runId"` + ThreadID string `json:"threadId"` +}