diff --git a/components/backend/handlers/display_name.go b/components/backend/handlers/display_name.go index 3b3bc9524..90fd11133 100755 --- a/components/backend/handlers/display_name.go +++ b/components/backend/handlers/display_name.go @@ -80,7 +80,7 @@ func ValidateDisplayName(name string) string { // - Gracefully handles session deletion during generation (checks IsNotFound) // - No cancellation mechanism exists; goroutine runs to completion or timeout // - Safe for backend restarts: orphaned goroutines will timeout naturally -func GenerateDisplayNameAsync(projectName, sessionName, userMessage string, sessionCtx SessionContext) { +var GenerateDisplayNameAsync = func(projectName, sessionName, userMessage string, sessionCtx SessionContext) { go func() { defer func() { if r := recover(); r != nil { diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f49613ee5..a97a03601 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1203,9 +1203,8 @@ func CreateSession(c *gin.Context) { // This ensures consistent behavior whether sessions are created via API or kubectl. // Trigger async display name generation when initialPrompt is provided - // but no explicit displayName was set. The AG-UI proxy skips the - // initialPrompt message, so sessions created with only an initialPrompt - // (e.g., from the new-session page) would never get a generated name. + // but no explicit displayName was set. The AG-UI proxy also generates + // on the first /agui/run message as a fallback if this call fails. if strings.TrimSpace(req.InitialPrompt) != "" && strings.TrimSpace(req.DisplayName) == "" { spec, ok := created.Object["spec"].(map[string]interface{}) if ok { diff --git a/components/backend/websocket/agui_proxy.go b/components/backend/websocket/agui_proxy.go old mode 100644 new mode 100755 index 2289bea15..441ab7f53 --- a/components/backend/websocket/agui_proxy.go +++ b/components/backend/websocket/agui_proxy.go @@ -1002,12 +1002,6 @@ func triggerDisplayNameGenerationIfNeeded(projectName, sessionName string, messa return } - // Skip if this message is the auto-sent initialPrompt - initialPrompt, _, _ := unstructured.NestedString(spec, "initialPrompt") - if initialPrompt != "" && strings.TrimSpace(userMessage) == strings.TrimSpace(initialPrompt) { - return - } - if !handlers.ShouldGenerateDisplayName(spec) { return } diff --git a/components/backend/websocket/agui_proxy_test.go b/components/backend/websocket/agui_proxy_test.go old mode 100644 new mode 100755 index f54c4f1bc..7e8c0c129 --- a/components/backend/websocket/agui_proxy_test.go +++ b/components/backend/websocket/agui_proxy_test.go @@ -1,15 +1,21 @@ package websocket import ( + "context" "fmt" "net/http" "net/http/httptest" "testing" "ambient-code-backend/handlers" + "ambient-code-backend/tests/test_utils" + "ambient-code-backend/types" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" k8sfake "k8s.io/client-go/kubernetes/fake" ) @@ -204,3 +210,129 @@ func TestDefaultRunnerPort_Constant(t *testing.T) { t.Errorf("Expected DefaultRunnerPort=8001, got %d", handlers.DefaultRunnerPort) } } + +// --- triggerDisplayNameGenerationIfNeeded tests (regression for #1561) --- + +func setupDisplayNameTest(t *testing.T, spec map[string]interface{}) (cleanup func()) { + t.Helper() + + oldDynamic := handlers.DynamicClient + oldK8sProjects := handlers.K8sClientProjects + oldGVRFunc := handlers.GetAgenticSessionV1Alpha1Resource + + agenticSessionGVR := schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "agenticsessions", + } + handlers.GetAgenticSessionV1Alpha1Resource = func() schema.GroupVersionResource { + return agenticSessionGVR + } + + fakeClients := test_utils.NewFakeClientSet() + handlers.DynamicClient = fakeClients.GetDynamicClient() + handlers.K8sClientProjects = fakeClients.GetK8sClient() + + err := test_utils.CreateAgenticSessionInFakeClient( + handlers.DynamicClient, "test-project", "test-session", spec, + ) + if err != nil { + t.Fatalf("Failed to create test session: %v", err) + } + + return func() { + handlers.DynamicClient = oldDynamic + handlers.K8sClientProjects = oldK8sProjects + handlers.GetAgenticSessionV1Alpha1Resource = oldGVRFunc + } +} + +func getDisplayName(t *testing.T, dc dynamic.Interface) string { + t.Helper() + gvr := handlers.GetAgenticSessionV1Alpha1Resource() + item, err := dc.Resource(gvr).Namespace("test-project").Get( + context.Background(), "test-session", metav1.GetOptions{}, + ) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + dn, _, _ := unstructured.NestedString(item.Object, "spec", "displayName") + return dn +} + +func TestTriggerDisplayName_InitialPromptNotSkipped(t *testing.T) { + cleanup := setupDisplayNameTest(t, map[string]interface{}{ + "initialPrompt": "Help me debug auth", + }) + defer cleanup() + + called := false + oldFn := handlers.GenerateDisplayNameAsync + handlers.GenerateDisplayNameAsync = func(projectName, sessionName, userMessage string, sessionCtx handlers.SessionContext) { + called = true + } + defer func() { handlers.GenerateDisplayNameAsync = oldFn }() + + msgs := []types.Message{ + {ID: "msg-1", Role: "user", Content: "Help me debug auth"}, + } + + triggerDisplayNameGenerationIfNeeded("test-project", "test-session", msgs) + + if !called { + t.Error("Expected GenerateDisplayNameAsync to be called for initialPrompt message when displayName is empty") + } +} + +func TestTriggerDisplayName_SkipsWhenNameAlreadySet(t *testing.T) { + cleanup := setupDisplayNameTest(t, map[string]interface{}{ + "initialPrompt": "Help me debug auth", + "displayName": "Debug Auth Middleware", + }) + defer cleanup() + + msgs := []types.Message{ + {ID: "msg-1", Role: "user", Content: "Help me debug auth"}, + } + + triggerDisplayNameGenerationIfNeeded("test-project", "test-session", msgs) + + // displayName should remain unchanged — ShouldGenerateDisplayName + // returns false when displayName is already set. + dn := getDisplayName(t, handlers.DynamicClient) + if dn != "Debug Auth Middleware" { + t.Errorf("Expected displayName to remain %q, got %q", "Debug Auth Middleware", dn) + } +} + +func TestTriggerDisplayName_SkipsWhenNoUserMessage(t *testing.T) { + cleanup := setupDisplayNameTest(t, map[string]interface{}{ + "initialPrompt": "Help me debug auth", + }) + defer cleanup() + + // Only assistant messages — no user content to generate from + msgs := []types.Message{ + {ID: "msg-1", Role: "assistant", Content: "I'll help you debug"}, + } + + triggerDisplayNameGenerationIfNeeded("test-project", "test-session", msgs) + + dn := getDisplayName(t, handlers.DynamicClient) + if dn != "" { + t.Errorf("Expected empty displayName, got %q", dn) + } +} + +func TestTriggerDisplayName_SkipsWhenDynamicClientNil(t *testing.T) { + oldDynamic := handlers.DynamicClient + handlers.DynamicClient = nil + defer func() { handlers.DynamicClient = oldDynamic }() + + msgs := []types.Message{ + {ID: "msg-1", Role: "user", Content: "Help me debug auth"}, + } + + // Should return early without panic when DynamicClient is nil + triggerDisplayNameGenerationIfNeeded("test-project", "test-session", msgs) +}