Skip to content
Open
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
34 changes: 34 additions & 0 deletions pkg/hub/handlers_agent_messaging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>' or 'user:<email>'", nil)
return
Expand Down
73 changes: 73 additions & 0 deletions pkg/hub/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}