diff --git a/pkg/hub/handlers_agent_messaging.go b/pkg/hub/handlers_agent_messaging.go index eec2275dd7..0baeeb46dd 100644 --- a/pkg/hub/handlers_agent_messaging.go +++ b/pkg/hub/handlers_agent_messaging.go @@ -162,6 +162,40 @@ func (s *Server) handleAgentOutboundMessage(w http.ResponseWriter, r *http.Reque } } + // Implicit default: no recipient specified at all — fall back to the + // agent's creator (falling back further to its owner if creator is + // unset, e.g. agents created by automation). This is what lets an agent + // just call "send this reply back" without threading a recipient + // through every message-sending path (assistant-reply hooks, Telegram + // relay, etc.). + if recipientID == "" && recipient == "" { + creatorID := agent.CreatedBy + if creatorID == "" { + creatorID = agent.OwnerID + } + if creatorID != "" { + u, err := s.store.GetUser(ctx, creatorID) + switch { + case err == nil: + recipientID = u.ID + name := u.DisplayName + if name == "" { + name = u.Email + } + recipient = "user:" + name + case errors.Is(err, store.ErrNotFound): + // Creator/owner record no longer exists (e.g. deleted user). + // Fall through to the "recipient is required" response below. + default: + // A real backend error (DB down, etc.) shouldn't be reported + // as a 400 validation error — that would mask a transient + // failure as a client mistake. + writeErrorFromErr(w, err, "") + return + } + } + } + if recipientID == "" && recipient == "" { ValidationError(w, "recipient is required — specify a user with 'user:' or 'user:'", nil) return diff --git a/pkg/hub/handlers_test.go b/pkg/hub/handlers_test.go index 2b805bbe57..b3f44a906f 100644 --- a/pkg/hub/handlers_test.go +++ b/pkg/hub/handlers_test.go @@ -3024,3 +3024,76 @@ func TestOutboundMessage_UnknownRecipient(t *testing.T) { t.Errorf("expected 400 for unknown recipient, got %d: %s", rr.Code, rr.Body.String()) } } + +// TestOutboundMessage_ImplicitRecipientDefaultsToCreator verifies that a +// message sent with no recipient at all (the case hit by the assistant-reply +// hook and other auto-forwarding paths, which never set Recipient/RecipientID) +// falls back to the agent's creator instead of rejecting with "recipient is +// required" — matching the doc comment on handleAgentOutboundMessage. +func TestOutboundMessage_ImplicitRecipientDefaultsToCreator(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + creator := &store.User{ + ID: api.NewUUID(), + Email: "creator@example.com", + DisplayName: "Creator", + } + if err := s.CreateUser(ctx, creator); err != nil { + t.Fatal(err) + } + + project := &store.Project{ + ID: api.NewUUID(), + Name: "msg-implicit-project", + Slug: "msg-implicit-project", + Visibility: store.VisibilityPrivate, + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatal(err) + } + + rb := &store.RuntimeBroker{ + ID: tid("broker-msg-implicit"), + Name: "test-broker-implicit", + Slug: "test-broker-implicit", + Endpoint: "http://localhost:9801", + Status: store.BrokerStatusOnline, + } + if err := s.CreateRuntimeBroker(ctx, rb); err != nil { + t.Fatal(err) + } + + agent := &store.Agent{ + ID: api.NewUUID(), + Name: "sender-implicit", + Slug: "sender-implicit", + ProjectID: project.ID, + Phase: "running", + RuntimeBrokerID: tid("broker-msg-implicit"), + Visibility: store.VisibilityPrivate, + CreatedBy: creator.ID, + } + if err := s.CreateAgent(ctx, agent); err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(OutboundMessageRequest{ + Msg: "hello with no recipient set", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/"+agent.ID+"/outbound-message", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + agentIdent := &agentIdentityWrapper{&AgentTokenClaims{ + Claims: jwt.Claims{Subject: agent.ID}, + ProjectID: project.ID, + }} + req = req.WithContext(contextWithIdentity(req.Context(), agentIdent)) + + rr := httptest.NewRecorder() + srv.handleAgentOutboundMessage(rr, req, agent.ID) + + if rr.Code != http.StatusOK && rr.Code != http.StatusCreated { + t.Fatalf("expected success falling back to agent creator, got %d: %s", rr.Code, rr.Body.String()) + } +}