diff --git a/agent/robot/DESIGN.md b/agent/robot/DESIGN.md index 4440185d..431307f2 100644 --- a/agent/robot/DESIGN.md +++ b/agent/robot/DESIGN.md @@ -138,6 +138,7 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`): | `robot_config` | JSON | Agent configuration (see section 5) | | `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` | | `system_prompt` | text | Identity & role prompt | +| `robot_email` | string | Robot's email address for sending emails (From address) | | `agents` | JSON | Accessible agents list | | `mcp_servers` | JSON | Accessible MCP servers | | `manager_id` | string | Direct manager user ID | @@ -859,7 +860,6 @@ type EmailPreference struct { type EmailTarget struct { To []string `json:"to"` // Recipient addresses - CC []string `json:"cc,omitempty"` // CC addresses Template string `json:"template,omitempty"` // Email template ID Subject string `json:"subject,omitempty"` // Subject template } diff --git a/agent/robot/TECHNICAL.md b/agent/robot/TECHNICAL.md index a050ac6b..4578e2c5 100644 --- a/agent/robot/TECHNICAL.md +++ b/agent/robot/TECHNICAL.md @@ -1169,6 +1169,7 @@ type Robot struct { SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` + RobotEmail string `json:"robot_email"` // Robot's email address for sending emails // Parsed config (from robot_config JSON field) Config *Config `json:"-"` @@ -1448,7 +1449,6 @@ type EmailPreference struct { type EmailTarget struct { To []string `json:"to"` // Recipient addresses - CC []string `json:"cc,omitempty"` // CC addresses Template string `json:"template,omitempty"` // Email template ID Subject string `json:"subject,omitempty"` // Subject template (default: content.Summary) } @@ -2166,7 +2166,27 @@ The agent focuses on content generation: } ``` -### 6.5 Delivery Center +### 6.5 Global Email Configuration + +Email delivery uses global configuration for channel selection and Robot-specific sender identity: + +```go +// types/config_global.go + +// DefaultEmailChannel returns the default messenger channel name +// Default: "email" (maps to messengers/channels.yao) +func DefaultEmailChannel() string + +// SetDefaultEmailChannel sets the default channel (call during agent init) +func SetDefaultEmailChannel(channel string) +``` + +**Usage:** +- `DefaultEmailChannel()` - returns the messenger channel name for email delivery +- `Robot.RobotEmail` - used as the `From` address when sending emails +- If `RobotEmail` is empty, falls back to provider's default `from` address + +### 6.6 Delivery Center The Delivery Center receives `DeliveryRequest`, reads preferences, and executes delivery to **all enabled targets**. @@ -2187,10 +2207,10 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De var results []ChannelResult allSuccess := true - // Email - send to all targets + // Email - send to all targets (robot passed for From address) if prefs.Email != nil && prefs.Email.Enabled { for _, target := range prefs.Email.Targets { - result := dc.sendEmail(ctx, req.Content, target) + result := dc.sendEmail(ctx, req.Content, target, req.Context, robot) results = append(results, result) if !result.Success { allSuccess = false @@ -2232,13 +2252,20 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De } ``` -### 6.6 Channel Handlers +### 6.7 Channel Handlers Each delivery channel is handled by dedicated methods in DeliveryCenter: ```go // sendEmail - send to a single email target -func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryContent, target EmailTarget) ChannelResult { +// Uses Robot.RobotEmail as From address and global DefaultEmailChannel() +func (dc *DeliveryCenter) sendEmail( + ctx context.Context, + content *DeliveryContent, + target EmailTarget, + deliveryCtx *DeliveryContext, + robot *Robot, +) ChannelResult { // Convert attachments to messenger format var attachments []messenger.Attachment for _, att := range content.Attachments { @@ -2255,17 +2282,25 @@ func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryConten } subject := content.Summary - if target.SubjectTemplate != "" { - subject = target.SubjectTemplate + if target.Subject != "" { + subject = target.Subject } - err := dc.messenger.Send(ctx, &messenger.Message{ + msg := &messenger.Message{ To: target.To, - CC: target.CC, Subject: subject, Body: content.Body, Attachments: attachments, - }) + } + + // Set From address from Robot's email (if configured) + if robot != nil && robot.RobotEmail != "" { + msg.From = robot.RobotEmail + } + + // Use global default email channel + channel := DefaultEmailChannel() // from types/config_global.go + err := dc.messenger.Send(ctx, channel, msg) now := time.Now() return ChannelResult{ @@ -2344,7 +2379,7 @@ func (dc *DeliveryCenter) callProcess(ctx context.Context, content *DeliveryCont 2. Automatically send in-app notifications to subscribed users 3. This is transparent to P4 and Delivery Agent -### 6.7 Execution Persistence +### 6.8 Execution Persistence Robot execution history is stored in `__yao.agent_execution` table for UI display: diff --git a/agent/robot/TODO.md b/agent/robot/TODO.md index 8de0d852..424cb65f 100644 --- a/agent/robot/TODO.md +++ b/agent/robot/TODO.md @@ -975,11 +975,11 @@ Supported channels: ### 10.4 Delivery Agent Setup -- [ ] `robot/delivery/package.yao` - Delivery Agent config -- [ ] `robot/delivery/prompts.yml` - delivery prompts - - [ ] Input: Full execution context (P0-P3 results) - - [ ] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels** - - [ ] Agent focuses on content generation, NOT channel selection +- [x] `robot/delivery/package.yao` - Delivery Agent config +- [x] `robot/delivery/prompts.yml` - delivery prompts + - [x] Input: Full execution context (P0-P3 results) + - [x] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels** + - [x] Agent focuses on content generation, NOT channel selection ### 10.5 Delivery Content Structure @@ -1036,46 +1036,48 @@ type DeliveryContext struct { ### 10.6 Implementation **P4 Entry (executor/delivery.go):** -- [ ] `RunDelivery(ctx, exec, data)` - P4 entry point - - [ ] Call Delivery Agent to generate content (only content, no channels) - - [ ] Build DeliveryRequest (Content + Context) - - [ ] Push to Delivery Center - - [ ] Store DeliveryResult in exec.Delivery +- [x] `RunDelivery(ctx, exec, data)` - P4 entry point + - [x] Call Delivery Agent to generate content (only content, no channels) + - [x] Build DeliveryRequest (Content + Context) + - [x] Push to Delivery Center + - [x] Store DeliveryResult in exec.Delivery -**Delivery Center (executor/delivery.go, future: yao/delivery):** -- [ ] `DeliveryCenter.Deliver(ctx, request)` - main entry - - [ ] Read Robot/User delivery preferences - - [ ] Iterate through all enabled targets for each channel - - [ ] Aggregate ChannelResults into DeliveryResult +**Delivery Center (executor/delivery_center.go):** +- [x] `DeliveryCenter.Deliver(ctx, request)` - main entry + - [x] Read Robot/User delivery preferences + - [x] Iterate through all enabled targets for each channel + - [x] Aggregate ChannelResults into DeliveryResult **Channel Handlers (each supports multiple targets):** -- [ ] `sendEmail()` - uses yao/messenger - - [ ] Convert DeliveryAttachment to messenger.Attachment - - [ ] Support multiple EmailTarget - - [ ] Support custom subject_template per target -- [ ] `postWebhook()` - POST JSON - - [ ] POST DeliveryContent as JSON payload - - [ ] Support multiple WebhookTarget - - [ ] Support custom headers per target -- [ ] `callProcess()` - Yao Process call - - [ ] DeliveryContent as first arg - - [ ] Support multiple ProcessTarget - - [ ] Support additional args per target +- [x] `sendEmail()` - uses yao/messenger + - [x] Convert DeliveryAttachment to messenger.Attachment + - [x] Support multiple EmailTarget + - [x] Support custom subject_template per target + - [x] Use `Robot.RobotEmail` as From address (if configured) + - [x] Use global `DefaultEmailChannel()` for messenger channel selection +- [x] `postWebhook()` - POST JSON + - [x] POST DeliveryContent as JSON payload + - [x] Support multiple WebhookTarget + - [x] Support custom headers per target +- [x] `callProcess()` - Yao Process call + - [x] DeliveryContent as first arg + - [x] Support multiple ProcessTarget + - [x] Support additional args per target ### 10.7 Tests -- [ ] `executor/delivery_test.go` - P4 delivery -- [ ] Test: Delivery Agent generates content (only content) -- [ ] Test: DeliveryCenter reads preferences -- [ ] Test: Multiple email targets -- [ ] Test: Multiple webhook targets -- [ ] Test: Multiple process targets -- [ ] Test: Mixed channels (email + webhook + process) -- [ ] Test: sendEmail with attachments -- [ ] Test: postWebhook with custom headers -- [ ] Test: callProcess with args -- [ ] Test: Partial success (some targets fail) -- [ ] Test: DeliveryResult aggregation +- [x] `executor/delivery_test.go` - P4 delivery +- [x] Test: Delivery Agent generates content (only content) +- [x] Test: DeliveryCenter reads preferences +- [x] Test: Multiple email targets (TestDeliveryCenterEmail) +- [x] Test: Multiple webhook targets +- [x] Test: Multiple process targets (TestDeliveryCenterProcess) +- [x] Test: Mixed channels (email + webhook + process) (TestDeliveryCenterAllChannels) +- [x] Test: sendEmail with attachments (TestDeliveryCenterEmail) +- [x] Test: postWebhook with custom headers +- [x] Test: callProcess with args (TestDeliveryCenterProcess) +- [x] Test: Partial success (some targets fail) +- [x] Test: DeliveryResult aggregation --- @@ -1291,7 +1293,7 @@ func TestWithLLM(t *testing.T) { | 7. P1 Goals | ✅ | Goal Generation Agent integration | | 8. P2 Tasks | ✅ | Task Planning Agent integration | | 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation | -| 10. P4 Delivery | ⬜ | Output delivery (email/webhook/process, notify future) | +| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) | | 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) | | 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode | diff --git a/agent/robot/executor/standard/delivery.go b/agent/robot/executor/standard/delivery.go index a48f6d87..0d050e1d 100644 --- a/agent/robot/executor/standard/delivery.go +++ b/agent/robot/executor/standard/delivery.go @@ -1,36 +1,397 @@ package standard import ( + "encoding/json" + "fmt" + "strings" + "time" + robottypes "github.com/yaoapp/yao/agent/robot/types" ) // RunDelivery executes P4: Delivery phase -// Delivers results to configured targets +// Calls the Delivery Agent to generate content, then routes to Delivery Center // // Input: -// - TaskResults (from P3) -// - Delivery config from robot +// - Full execution context (P0-P3) +// - Robot config // // Output: -// - DeliveryResult with success status +// - DeliveryResult with content and channel results // -// Delivery Types: -// - DeliveryEmail: Send email -// - DeliveryNotify: Send notification -// - DeliveryWebhook: Call webhook -// - DeliveryStore: Store to database -// -// TODO: Implement real delivery +// Process: +// 1. Call Delivery Agent with full execution context +// 2. Agent generates DeliveryContent (summary, body, attachments) +// 3. Route content to Delivery Center for actual delivery func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error { - e.simulateStreamDelay() - - exec.Delivery = &robottypes.DeliveryResult{ - RequestID: "delivery-" + exec.ID, - Content: &robottypes.DeliveryContent{ - Summary: "Delivery completed (placeholder)", - Body: "# Delivery\n\nTODO: Implement real delivery logic.", - }, - Success: true, + // Get robot for identity and resources + robot := exec.GetRobot() + if robot == nil { + return fmt.Errorf("robot not found in execution") } + + // Get agent ID for delivery phase + agentID := "__yao.delivery" // default + if robot.Config != nil && robot.Config.Resources != nil { + agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery) + } + + // Build input for Delivery Agent + formatter := NewInputFormatter() + userContent := formatter.FormatDeliveryInput(exec, robot) + + if userContent == "" { + return fmt.Errorf("no content available for delivery generation") + } + + // Call Delivery Agent + caller := NewAgentCaller() + result, err := caller.CallWithMessages(ctx, agentID, userContent) + if err != nil { + return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err) + } + + // Parse response as JSON + // Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } } + data, err := result.GetJSON() + if err != nil { + // Fallback: if not JSON, create minimal content from raw text + content := result.GetText() + if content == "" { + return fmt.Errorf("delivery agent returned empty response") + } + exec.Delivery = &robottypes.DeliveryResult{ + RequestID: generateRequestID(exec.ID), + Content: &robottypes.DeliveryContent{ + Summary: truncateSummary(content, 200), + Body: content, + }, + Success: true, + } + return e.routeToDeliveryCenter(ctx, exec, robot) + } + + // Build DeliveryContent from JSON + content := parseDeliveryContent(data) + if content == nil { + return fmt.Errorf("delivery agent (%s) returned invalid content", agentID) + } + + // Build DeliveryResult + exec.Delivery = &robottypes.DeliveryResult{ + RequestID: generateRequestID(exec.ID), + Content: content, + Success: true, + } + + // Route to Delivery Center for actual delivery + return e.routeToDeliveryCenter(ctx, exec, robot) +} + +// routeToDeliveryCenter sends content to the Delivery Center for actual delivery +// The Delivery Center decides which channels to use based on robot/user preferences +func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error { + if exec.Delivery == nil || exec.Delivery.Content == nil { + return fmt.Errorf("no delivery content to route") + } + + // Get delivery preferences from robot config + var prefs *robottypes.DeliveryPreferences + if robot.Config != nil { + prefs = robot.Config.Delivery + } + + // If no preferences configured, skip delivery (content is still saved in exec.Delivery) + if prefs == nil || !hasActiveChannels(prefs) { + // No channels configured - mark as success but with no results + exec.Delivery.Success = true + return nil + } + + // Create Delivery Center and execute + center := NewDeliveryCenter() + results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{ + MemberID: exec.MemberID, + ExecutionID: exec.ID, + TriggerType: exec.TriggerType, + TeamID: exec.TeamID, + }, prefs, robot) + + // Update delivery result + exec.Delivery.Results = results + now := time.Now() + exec.Delivery.SentAt = &now + + if err != nil { + exec.Delivery.Success = false + exec.Delivery.Error = err.Error() + return err + } + + // Check if all channels succeeded + allSuccess := true + for _, r := range results { + if !r.Success { + allSuccess = false + break + } + } + exec.Delivery.Success = allSuccess + return nil } + +// parseDeliveryContent parses the Delivery Agent response into DeliveryContent +func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent { + if data == nil { + return nil + } + + // Try to get content object + contentData, ok := data["content"].(map[string]interface{}) + if !ok { + // Fallback: maybe the data itself is the content + contentData = data + } + + content := &robottypes.DeliveryContent{} + + // Parse summary + if summary, ok := contentData["summary"].(string); ok { + content.Summary = summary + } + + // Parse body + if body, ok := contentData["body"].(string); ok { + content.Body = body + } + + // Parse attachments + if attachments, ok := contentData["attachments"].([]interface{}); ok { + for _, att := range attachments { + if attMap, ok := att.(map[string]interface{}); ok { + attachment := parseDeliveryAttachment(attMap) + if attachment != nil { + content.Attachments = append(content.Attachments, *attachment) + } + } + } + } + + // Validate: at least summary or body should be present + if content.Summary == "" && content.Body == "" { + return nil + } + + return content +} + +// parseDeliveryAttachment parses a single attachment from the agent response +func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment { + if data == nil { + return nil + } + + att := &robottypes.DeliveryAttachment{} + + if title, ok := data["title"].(string); ok { + att.Title = title + } + if desc, ok := data["description"].(string); ok { + att.Description = desc + } + if taskID, ok := data["task_id"].(string); ok { + att.TaskID = taskID + } + if file, ok := data["file"].(string); ok { + att.File = file + } + + // At minimum, need title and file + if att.Title == "" || att.File == "" { + return nil + } + + return att +} + +// generateRequestID generates a unique request ID for delivery tracking +func generateRequestID(execID string) string { + return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000) +} + +// getTaskDescription extracts a description from task messages +func getTaskDescription(task robottypes.Task) string { + if len(task.Messages) == 0 { + return task.GoalRef + } + + // Try to get text from first message + for _, msg := range task.Messages { + if content, ok := msg.Content.(string); ok && content != "" { + // Truncate if too long + if len(content) > 100 { + return content[:97] + "..." + } + return content + } + } + + // Fallback to goal reference + if task.GoalRef != "" { + return task.GoalRef + } + + return "Task " + task.ID +} + +// truncateSummary truncates text to maxLen characters +func truncateSummary(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + // Find last space before maxLen to avoid cutting words + truncated := text[:maxLen] + if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 { + return truncated[:idx] + "..." + } + return truncated + "..." +} + +// hasActiveChannels checks if any delivery channel is configured +func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool { + if prefs == nil { + return false + } + if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 { + return true + } + if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 { + return true + } + if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 { + return true + } + return false +} + +// FormatDeliveryInput formats the full execution context for the Delivery Agent +func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string { + if exec == nil { + return "" + } + + var sb strings.Builder + + // Robot identity + if robot != nil && robot.Config != nil && robot.Config.Identity != nil { + sb.WriteString("## Robot Identity\n\n") + sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role)) + if len(robot.Config.Identity.Duties) > 0 { + sb.WriteString("- **Duties**: ") + sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", ")) + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // Trigger type + sb.WriteString("## Execution Context\n\n") + sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType)) + sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status)) + sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05"))) + if exec.EndTime != nil { + duration := exec.EndTime.Sub(exec.StartTime) + sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String())) + } + sb.WriteString("\n") + + // Inspiration (P0) - for clock trigger + if exec.Inspiration != nil && exec.Inspiration.Content != "" { + sb.WriteString("## Inspiration (P0)\n\n") + sb.WriteString(exec.Inspiration.Content) + sb.WriteString("\n\n") + } + + // Goals (P1) + if exec.Goals != nil && exec.Goals.Content != "" { + sb.WriteString("## Goals (P1)\n\n") + sb.WriteString(exec.Goals.Content) + sb.WriteString("\n\n") + } + + // Tasks (P2) + if len(exec.Tasks) > 0 { + sb.WriteString("## Tasks (P2)\n\n") + for i, task := range exec.Tasks { + // Extract task description from messages if available + taskDesc := getTaskDescription(task) + sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc)) + sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType)) + sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status)) + if task.ExpectedOutput != "" { + sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput)) + } + } + sb.WriteString("\n") + } + + // Results (P3) - detailed + if len(exec.Results) > 0 { + sb.WriteString("## Results (P3)\n\n") + + successCount := 0 + failCount := 0 + + for _, result := range exec.Results { + if result.Success { + successCount++ + sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID)) + } else { + failCount++ + sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID)) + } + + sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration)) + + // Validation + if result.Validation != nil { + if result.Validation.Passed { + sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score)) + } else { + sb.WriteString("- **Validation**: ✗ Failed\n") + if len(result.Validation.Issues) > 0 { + for _, issue := range result.Validation.Issues { + sb.WriteString(fmt.Sprintf(" - %s\n", issue)) + } + } + } + } + + // Output + if result.Output != nil { + sb.WriteString("\n**Output**:\n") + if output, err := json.MarshalIndent(result.Output, "", " "); err == nil { + sb.WriteString("```json\n") + sb.WriteString(string(output)) + sb.WriteString("\n```\n") + } else { + sb.WriteString(fmt.Sprintf("%v\n", result.Output)) + } + } + + // Error + if result.Error != "" { + sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error)) + } + + sb.WriteString("\n") + } + + // Summary + sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n", + len(exec.Results), successCount, failCount)) + } + + return sb.String() +} diff --git a/agent/robot/executor/standard/delivery_center.go b/agent/robot/executor/standard/delivery_center.go new file mode 100644 index 00000000..0009d7db --- /dev/null +++ b/agent/robot/executor/standard/delivery_center.go @@ -0,0 +1,378 @@ +package standard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/yaoapp/gou/process" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/attachment" + "github.com/yaoapp/yao/messenger" + messengerTypes "github.com/yaoapp/yao/messenger/types" +) + +// DeliveryCenter handles routing delivery content to configured channels +// It decides which channels to use based on robot/user preferences and executes the delivery +type DeliveryCenter struct { + httpClient *http.Client +} + +// NewDeliveryCenter creates a new DeliveryCenter instance +func NewDeliveryCenter() *DeliveryCenter { + return &DeliveryCenter{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Deliver sends content to all configured channels based on preferences +// Returns results for each channel target and any error +func (dc *DeliveryCenter) Deliver( + ctx *robottypes.Context, + content *robottypes.DeliveryContent, + deliveryCtx *robottypes.DeliveryContext, + prefs *robottypes.DeliveryPreferences, + robotInstance *robottypes.Robot, +) ([]robottypes.ChannelResult, error) { + if content == nil { + return nil, fmt.Errorf("delivery content is nil") + } + if prefs == nil { + return nil, nil // No preferences = no delivery + } + + var results []robottypes.ChannelResult + var lastErr error + + // Process email targets + if prefs.Email != nil && prefs.Email.Enabled { + for _, target := range prefs.Email.Targets { + result := dc.sendEmail(ctx.Context, content, target, deliveryCtx, robotInstance) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("email delivery failed: %s", result.Error) + } + } + } + + // Process webhook targets + if prefs.Webhook != nil && prefs.Webhook.Enabled { + for _, target := range prefs.Webhook.Targets { + result := dc.postWebhook(ctx.Context, content, target, deliveryCtx) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("webhook delivery failed: %s", result.Error) + } + } + } + + // Process process targets + if prefs.Process != nil && prefs.Process.Enabled { + for _, target := range prefs.Process.Targets { + result := dc.callProcess(ctx.Context, content, target, deliveryCtx) + results = append(results, result) + if !result.Success && lastErr == nil { + lastErr = fmt.Errorf("process delivery failed: %s", result.Error) + } + } + } + + return results, lastErr +} + +// sendEmail sends delivery content to a single email target +func (dc *DeliveryCenter) sendEmail( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.EmailTarget, + deliveryCtx *robottypes.DeliveryContext, + robotInstance *robottypes.Robot, +) robottypes.ChannelResult { + now := time.Now() + + // Build target identifier from recipients + targetID := strings.Join(target.To, ",") + if targetID == "" { + targetID = "no-recipients" + } + + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryEmail, + Target: targetID, + SentAt: &now, + } + + // Get messenger service + svc := messenger.Instance + if svc == nil { + result.Error = "messenger service not available" + return result + } + + // Build email message + msg := &messengerTypes.Message{ + To: target.To, + Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx), + Body: buildEmailBody(target.Template, content), + Type: messengerTypes.MessageTypeEmail, + } + + // Set From address from Robot's email (if configured) + if robotInstance != nil && robotInstance.RobotEmail != "" { + msg.From = robotInstance.RobotEmail + } + + // Convert attachments + attachments := convertAttachments(ctx, content.Attachments) + if len(attachments) > 0 { + msg.Attachments = attachments + } + + // Send email using global default channel + channel := robottypes.DefaultEmailChannel() + err := svc.Send(ctx, channel, msg) + if err != nil { + result.Error = err.Error() + return result + } + + result.Success = true + result.Recipients = target.To + + return result +} + +// postWebhook posts delivery content to a single webhook target +func (dc *DeliveryCenter) postWebhook( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.WebhookTarget, + deliveryCtx *robottypes.DeliveryContext, +) robottypes.ChannelResult { + now := time.Now() + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryWebhook, + Target: target.URL, + SentAt: &now, + } + + // Build webhook payload + payload := map[string]interface{}{ + "event": "robot.delivery", + "timestamp": now.Format(time.RFC3339), + "execution_id": deliveryCtx.ExecutionID, + "member_id": deliveryCtx.MemberID, + "team_id": deliveryCtx.TeamID, + "trigger_type": deliveryCtx.TriggerType, + "content": map[string]interface{}{ + "summary": content.Summary, + "body": content.Body, + }, + } + + // Add attachments info (not the actual files) + if len(content.Attachments) > 0 { + attachmentInfo := make([]map[string]interface{}, 0, len(content.Attachments)) + for _, att := range content.Attachments { + attachmentInfo = append(attachmentInfo, map[string]interface{}{ + "title": att.Title, + "description": att.Description, + "task_id": att.TaskID, + "file": att.File, + }) + } + payload["attachments"] = attachmentInfo + } + + // Marshal payload + payloadBytes, err := json.Marshal(payload) + if err != nil { + result.Error = fmt.Sprintf("failed to marshal payload: %v", err) + return result + } + + // Build request + method := target.Method + if method == "" { + method = "POST" + } + + req, err := http.NewRequestWithContext(ctx, method, target.URL, bytes.NewReader(payloadBytes)) + if err != nil { + result.Error = fmt.Sprintf("failed to create request: %v", err) + return result + } + + req.Header.Set("Content-Type", "application/json") + + // Add custom headers + for key, value := range target.Headers { + req.Header.Set(key, value) + } + + // Add secret if configured (for signature verification) + if target.Secret != "" { + // TODO: Implement HMAC signature + req.Header.Set("X-Webhook-Secret", target.Secret) + } + + // Send request + resp, err := dc.httpClient.Do(req) + if err != nil { + result.Error = fmt.Sprintf("request failed: %v", err) + return result + } + defer resp.Body.Close() + + // Read response body + body, _ := io.ReadAll(resp.Body) + + // Check status code + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + result.Error = fmt.Sprintf("webhook returned status %d: %s", resp.StatusCode, string(body)) + return result + } + + result.Success = true + result.Details = map[string]interface{}{ + "status_code": resp.StatusCode, + "response": string(body), + } + + return result +} + +// callProcess calls a Yao Process with delivery content +func (dc *DeliveryCenter) callProcess( + ctx context.Context, + content *robottypes.DeliveryContent, + target robottypes.ProcessTarget, + deliveryCtx *robottypes.DeliveryContext, +) robottypes.ChannelResult { + now := time.Now() + result := robottypes.ChannelResult{ + Type: robottypes.DeliveryProcess, + Target: target.Process, + SentAt: &now, + } + + // Build args: DeliveryContent as first arg, then additional args + args := make([]interface{}, 0, 1+len(target.Args)) + args = append(args, map[string]interface{}{ + "content": map[string]interface{}{ + "summary": content.Summary, + "body": content.Body, + "attachments": content.Attachments, + }, + "context": map[string]interface{}{ + "execution_id": deliveryCtx.ExecutionID, + "member_id": deliveryCtx.MemberID, + "team_id": deliveryCtx.TeamID, + "trigger_type": deliveryCtx.TriggerType, + }, + }) + args = append(args, target.Args...) + + // Create and execute process + proc, err := process.Of(target.Process, args...) + if err != nil { + result.Error = fmt.Sprintf("failed to create process: %v", err) + return result + } + proc.Context = ctx + + err = proc.Execute() + if err != nil { + result.Error = err.Error() + return result + } + + result.Success = true + result.Details = proc.Value + + return result +} + +// buildEmailSubject builds the email subject line +func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string { + // Use explicit subject if provided + if subject != "" { + return subject + } + + // Use template-based subject if template is specified + // TODO: Implement template rendering + if template != "" { + return fmt.Sprintf("[Robot] %s", content.Summary) + } + + // Default: use summary + if content.Summary != "" { + return fmt.Sprintf("[Robot] %s", content.Summary) + } + + return fmt.Sprintf("[Robot] Execution %s Complete", ctx.ExecutionID) +} + +// buildEmailBody builds the email body content +func buildEmailBody(template string, content *robottypes.DeliveryContent) string { + // TODO: Implement template rendering + // For now, just use the body directly + if content.Body != "" { + return content.Body + } + return content.Summary +} + +// convertAttachments converts DeliveryAttachment to messenger Attachment format +func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAttachment) []messengerTypes.Attachment { + if len(attachments) == 0 { + return nil + } + + result := make([]messengerTypes.Attachment, 0, len(attachments)) + + for _, att := range attachments { + // Parse file wrapper: __:// + uploader, fileID, isWrapper := attachment.Parse(att.File) + if !isWrapper { + // Skip non-wrapper attachments + continue + } + + // Get file info from attachment manager + manager, ok := attachment.Managers[uploader] + if !ok { + continue + } + + info, err := manager.Info(ctx, fileID) + if err != nil { + continue + } + + // Read file content + content, err := manager.Read(ctx, fileID) + if err != nil { + continue + } + + // Build messenger attachment + msgAtt := messengerTypes.Attachment{ + Filename: info.Filename, + ContentType: info.ContentType, + Content: content, + } + + result = append(result, msgAtt) + } + + return result +} diff --git a/agent/robot/executor/standard/delivery_test.go b/agent/robot/executor/standard/delivery_test.go new file mode 100644 index 00000000..b2c612e3 --- /dev/null +++ b/agent/robot/executor/standard/delivery_test.go @@ -0,0 +1,1174 @@ +package standard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/agent/robot/executor/standard" + "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/agent/testutils" +) + +// ============================================================================ +// P4 Delivery Phase Tests +// ============================================================================ + +func TestRunDeliveryBasic(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("generates delivery content from execution results", func(t *testing.T) { + robot := createDeliveryTestRobot(t, "robot.delivery") + exec := createDeliveryTestExecution(robot) + + // Set up execution context with P0-P3 results + exec.Inspiration = &types.InspirationReport{ + Content: "Morning analysis suggests focus on Q4 review.", + } + exec.Goals = &types.Goals{ + Content: "## Goals\n1. Review Q4 data\n2. Generate summary report", + } + exec.Tasks = []types.Task{ + {ID: "task-001", ExecutorID: "experts.data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + {ID: "task-002", ExecutorID: "experts.summarizer", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + } + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"total_sales": 1500000}}, + {TaskID: "task-002", Success: true, Duration: 800, Output: "Q4 sales exceeded expectations by 15%."}, + } + + // Run delivery phase + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Delivery) + require.NotNil(t, exec.Delivery.Content) + assert.NotEmpty(t, exec.Delivery.Content.Summary) + assert.NotEmpty(t, exec.Delivery.Content.Body) + assert.True(t, exec.Delivery.Success) + }) + + t.Run("handles partial failure in results", func(t *testing.T) { + robot := createDeliveryTestRobot(t, "robot.delivery") + exec := createDeliveryTestExecution(robot) + + exec.Goals = &types.Goals{ + Content: "## Goals\n1. Analyze data\n2. Generate report", + } + exec.Tasks = []types.Task{ + {ID: "task-001", ExecutorID: "experts.data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted}, + {ID: "task-002", ExecutorID: "experts.summarizer", ExecutorType: types.ExecutorAssistant, Status: types.TaskFailed}, + } + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"data": "analyzed"}}, + {TaskID: "task-002", Success: false, Duration: 500, Error: "Summarization failed: timeout"}, + } + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + require.NoError(t, err) + require.NotNil(t, exec.Delivery) + require.NotNil(t, exec.Delivery.Content) + + // Content should mention the failure + body := strings.ToLower(exec.Delivery.Content.Body) + hasFailureInfo := strings.Contains(body, "fail") || + strings.Contains(body, "error") || + strings.Contains(body, "partial") || + strings.Contains(body, "✗") + + assert.True(t, hasFailureInfo || exec.Delivery.Content.Summary != "", "should mention failure or have valid summary") + }) +} + +func TestRunDeliveryErrorHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + ctx := types.NewContext(context.Background(), testAuth()) + + t.Run("returns error when robot is nil", func(t *testing.T) { + exec := &types.Execution{ + ID: "test-exec-1", + TriggerType: types.TriggerClock, + } + // Don't set robot + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "robot not found") + }) + + t.Run("returns error when agent not found", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + Config: &types.Config{ + Identity: &types.Identity{Role: "Test"}, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseDelivery: "non.existent.agent", + }, + }, + }, + } + exec := createDeliveryTestExecution(robot) + exec.Results = []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 100}, + } + + e := standard.New() + err := e.RunDelivery(ctx, exec, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "call failed") + }) +} + +// ============================================================================ +// Delivery Center Tests +// ============================================================================ + +func TestDeliveryCenterWebhook(t *testing.T) { + t.Run("posts to webhook successfully", func(t *testing.T) { + // Create mock webhook server + var receivedPayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&receivedPayload) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "received"}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test delivery completed", + Body: "# Test Report\n\nThis is a test.", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, types.DeliveryWebhook, results[0].Type) + assert.Equal(t, server.URL, results[0].Target) + + // Verify payload structure + assert.Equal(t, "robot.delivery", receivedPayload["event"]) + assert.Equal(t, "exec-001", receivedPayload["execution_id"]) + assert.Equal(t, "member-001", receivedPayload["member_id"]) + contentMap := receivedPayload["content"].(map[string]interface{}) + assert.Equal(t, "Test delivery completed", contentMap["summary"]) + }) + + t.Run("handles webhook failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal error"}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.Error(t, err) // Should return error for failed delivery + require.Len(t, results, 1) + assert.False(t, results[0].Success) + assert.Contains(t, results[0].Error, "500") + }) + + t.Run("supports multiple webhook targets", func(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL + "/hook1"}, + {URL: server.URL + "/hook2"}, + {URL: server.URL + "/hook3"}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 3) + assert.Equal(t, 3, callCount) + + for _, r := range results { + assert.True(t, r.Success) + } + }) + + t.Run("includes custom headers", func(t *testing.T) { + var receivedHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + { + URL: server.URL, + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + "Authorization": "Bearer test-token", + }, + }, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, "custom-value", receivedHeaders.Get("X-Custom-Header")) + assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization")) + }) +} + +func TestDeliveryCenterNoChannels(t *testing.T) { + t.Run("succeeds with no channels configured", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + // No preferences + results, err := center.Deliver(ctx, content, deliveryCtx, nil, nil) + + assert.NoError(t, err) + assert.Empty(t, results) + }) + + t.Run("succeeds with disabled channels", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: false, // Disabled + Targets: []types.WebhookTarget{ + {URL: "http://example.com"}, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.NoError(t, err) + assert.Empty(t, results) + }) +} + +func TestDeliveryCenterMixedChannels(t *testing.T) { + t.Run("delivers to multiple channel types", func(t *testing.T) { + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + // Email would fail without messenger setup, but webhook should succeed + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.True(t, webhookCalled) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + }) +} + +func TestDefaultEmailChannel(t *testing.T) { + t.Run("returns default email channel", func(t *testing.T) { + // Default should be "email" + assert.Equal(t, "email", types.DefaultEmailChannel()) + }) + + t.Run("can set custom email channel", func(t *testing.T) { + // Save original + original := types.DefaultEmailChannel() + defer types.SetDefaultEmailChannel(original) + + // Set custom channel + types.SetDefaultEmailChannel("custom-email") + assert.Equal(t, "custom-email", types.DefaultEmailChannel()) + }) + + t.Run("ignores empty channel", func(t *testing.T) { + // Save original and restore after test + original := types.DefaultEmailChannel() + defer types.SetDefaultEmailChannel(original) + + types.SetDefaultEmailChannel("") + assert.Equal(t, original, types.DefaultEmailChannel()) + }) +} + +func TestRobotEmailInDelivery(t *testing.T) { + t.Run("robot email is passed to delivery center", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test", + Body: "Test body", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + // Robot with email configured + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + // Webhook to verify robot is passed (email would fail without messenger) + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + } + + // Deliver with robot + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + assert.True(t, webhookCalled) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + }) + + t.Run("robot email field is loaded from map", func(t *testing.T) { + data := map[string]interface{}{ + "member_id": "robot-001", + "team_id": "team-001", + "robot_email": "robot@example.com", + } + + robot, err := types.NewRobotFromMap(data) + require.NoError(t, err) + assert.Equal(t, "robot@example.com", robot.RobotEmail) + }) + + t.Run("robot email can be empty", func(t *testing.T) { + data := map[string]interface{}{ + "member_id": "robot-001", + "team_id": "team-001", + // robot_email not set + } + + robot, err := types.NewRobotFromMap(data) + require.NoError(t, err) + assert.Empty(t, robot.RobotEmail) + }) +} + +func TestFormatDeliveryInput(t *testing.T) { + formatter := standard.NewInputFormatter() + + t.Run("formats complete execution context", func(t *testing.T) { + robot := &types.Robot{ + MemberID: "test-robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Sales Analyst", + Duties: []string{"Analyze data", "Generate reports"}, + }, + }, + } + + startTime := time.Now().Add(-5 * time.Minute) + endTime := time.Now() + exec := &types.Execution{ + ID: "exec-123", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + StartTime: startTime, + EndTime: &endTime, + Inspiration: &types.InspirationReport{ + Content: "Morning analysis suggests focus on Q4.", + }, + Goals: &types.Goals{ + Content: "## Goals\n1. Review Q4 data", + }, + Tasks: []types.Task{ + {ID: "task-001", ExecutorID: "data-analyst", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, ExpectedOutput: "JSON with sales data"}, + }, + Results: []types.TaskResult{ + {TaskID: "task-001", Success: true, Duration: 1500, Output: map[string]interface{}{"sales": 1000000}}, + }, + } + + result := formatter.FormatDeliveryInput(exec, robot) + + assert.Contains(t, result, "## Robot Identity") + assert.Contains(t, result, "Sales Analyst") + assert.Contains(t, result, "## Execution Context") + assert.Contains(t, result, "clock") + assert.Contains(t, result, "## Inspiration (P0)") + assert.Contains(t, result, "Morning analysis") + assert.Contains(t, result, "## Goals (P1)") + assert.Contains(t, result, "Review Q4 data") + assert.Contains(t, result, "## Tasks (P2)") + assert.Contains(t, result, "task-001") + assert.Contains(t, result, "## Results (P3)") + assert.Contains(t, result, "✓ Task: task-001") + }) + + t.Run("handles empty execution", func(t *testing.T) { + exec := &types.Execution{ + ID: "exec-empty", + TriggerType: types.TriggerHuman, + Status: types.ExecPending, + StartTime: time.Now(), + } + + result := formatter.FormatDeliveryInput(exec, nil) + + assert.Contains(t, result, "## Execution Context") + assert.Contains(t, result, "human") + }) +} + +// ============================================================================ +// Email Delivery Tests (requires messenger setup) +// ============================================================================ + +func TestDeliveryCenterEmail(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Set robot channel as default for tests + original := types.DefaultEmailChannel() + types.SetDefaultEmailChannel("robot") + defer types.SetDefaultEmailChannel(original) + + t.Run("sends email to single target", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Test Delivery Report", + Body: "This is a test delivery from Robot Agent.\n\n## Results\n- Task 1: Completed\n- Task 2: Completed", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-001", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"test@example.com"}, + Subject: "Robot Delivery Test", + }, + }, + }, + } + + // Send email - ignore billing/API errors, just verify the call was made + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + assert.Equal(t, "test@example.com", results[0].Target) + // Note: Success depends on messenger configuration + }) + + t.Run("sends email to multiple targets", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Multi-target Test", + Body: "Test body for multiple recipients", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-002", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"user1@example.com"}, + Subject: "Report for User 1", + }, + { + To: []string{"user2@example.com", "user3@example.com"}, + Subject: "Report for Team", + }, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 2) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + assert.Equal(t, types.DeliveryEmail, results[1].Type) + assert.Equal(t, "user1@example.com", results[0].Target) + assert.Equal(t, "user2@example.com,user3@example.com", results[1].Target) + }) + + t.Run("sends email with attachments", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Report with Attachments", + Body: "Please find the attached report.", + Attachments: []types.DeliveryAttachment{ + { + Title: "Q4 Report.pdf", + Description: "Quarterly sales report", + TaskID: "task-001", + File: "__local://reports/q4-2024.pdf", + }, + { + Title: "Data Export.csv", + Description: "Raw data export", + TaskID: "task-002", + File: "__local://exports/data.csv", + }, + }, + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-003", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + { + To: []string{"manager@example.com"}, + Subject: "Weekly Report with Attachments", + }, + }, + }, + } + + // Send - attachment conversion may fail if files don't exist, but structure is tested + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryEmail, results[0].Type) + }) +} + +// ============================================================================ +// Process Delivery Tests (requires Yao process setup) +// ============================================================================ + +func TestDeliveryCenterProcess(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + t.Run("calls process with delivery content", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Process Test Summary", + Body: "This is the body content for process testing.", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-001", + TriggerType: types.TriggerEvent, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, types.DeliveryProcess, results[0].Type) + assert.Equal(t, "scripts.tests.delivery.Handle", results[0].Target) + assert.True(t, results[0].Success) + + // Verify process received correct data (Details structure depends on process return) + assert.NotNil(t, results[0].Details, "process should return details") + }) + + t.Run("calls process with additional args", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Args Test", + Body: "Testing additional arguments", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-002", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + Args: []interface{}{"custom-arg-1", "custom-arg-2"}, + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.NotNil(t, results[0].Details, "process should return details with args") + }) + + t.Run("calls multiple process targets", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Multi-process Test", + Body: "Testing multiple process targets", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-003", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.Handle", + }, + { + Process: "scripts.tests.delivery.Notify", + Args: []interface{}{"user-123", "push"}, + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 2) + + // First process: Handle + assert.Equal(t, "scripts.tests.delivery.Handle", results[0].Target) + assert.True(t, results[0].Success) + + // Second process: Notify + assert.Equal(t, "scripts.tests.delivery.Notify", results[1].Target) + assert.True(t, results[1].Success) + }) + + t.Run("handles process failure gracefully", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Failure Test", + Body: "Testing process failure handling", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-004", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.HandleWithFailure", + Args: []interface{}{true}, // shouldFail = true + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + assert.Error(t, err) + require.Len(t, results, 1) + assert.False(t, results[0].Success) + assert.Contains(t, results[0].Error, "Simulated process failure") + }) + + t.Run("handles process with attachments", func(t *testing.T) { + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Attachment Process Test", + Body: "Testing process with attachments", + Attachments: []types.DeliveryAttachment{ + { + Title: "Report.pdf", + Description: "Test report", + TaskID: "task-001", + File: "__local://test/report.pdf", + }, + }, + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-process-005", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + { + Process: "scripts.tests.delivery.HandleAttachments", + }, + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.NotNil(t, results[0].Details, "process should return details with attachments info") + }) +} + +// ============================================================================ +// Mixed Channel Tests +// ============================================================================ + +func TestDeliveryCenterAllChannels(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + // Set robot channel as default + original := types.DefaultEmailChannel() + types.SetDefaultEmailChannel("robot") + defer types.SetDefaultEmailChannel(original) + + t.Run("delivers to email, webhook, and process simultaneously", func(t *testing.T) { + // Setup webhook server + webhookCalled := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + webhookCalled = true + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Full Channel Test", + Body: "Testing all delivery channels together", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-full-001", + TriggerType: types.TriggerClock, + TeamID: "team-001", + } + + robot := &types.Robot{ + MemberID: "robot-001", + RobotEmail: "robot@example.com", + } + + prefs := &types.DeliveryPreferences{ + Email: &types.EmailPreference{ + Enabled: true, + Targets: []types.EmailTarget{ + {To: []string{"user@example.com"}, Subject: "Test"}, + }, + }, + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: server.URL}, + }, + }, + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + {Process: "scripts.tests.delivery.Handle"}, + }, + }, + } + + results, _ := center.Deliver(ctx, content, deliveryCtx, prefs, robot) + + // Should have 3 results (1 email + 1 webhook + 1 process) + require.Len(t, results, 3) + + // Verify each channel type + var emailResult, webhookResult, processResult *types.ChannelResult + for i := range results { + switch results[i].Type { + case types.DeliveryEmail: + emailResult = &results[i] + case types.DeliveryWebhook: + webhookResult = &results[i] + case types.DeliveryProcess: + processResult = &results[i] + } + } + + assert.NotNil(t, emailResult, "should have email result") + assert.NotNil(t, webhookResult, "should have webhook result") + assert.NotNil(t, processResult, "should have process result") + + // Webhook and process should succeed + assert.True(t, webhookCalled, "webhook should be called") + assert.True(t, webhookResult.Success, "webhook should succeed") + assert.True(t, processResult.Success, "process should succeed") + }) + + t.Run("partial failure does not stop other channels", func(t *testing.T) { + // Webhook that fails + failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failServer.Close() + + // Webhook that succeeds + successCalled := false + successServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + successCalled = true + w.WriteHeader(http.StatusOK) + })) + defer successServer.Close() + + center := standard.NewDeliveryCenter() + ctx := types.NewContext(context.Background(), nil) + + content := &types.DeliveryContent{ + Summary: "Partial Failure Test", + Body: "Testing partial failure handling", + } + + deliveryCtx := &types.DeliveryContext{ + MemberID: "member-001", + ExecutionID: "exec-partial-001", + TriggerType: types.TriggerHuman, + TeamID: "team-001", + } + + prefs := &types.DeliveryPreferences{ + Webhook: &types.WebhookPreference{ + Enabled: true, + Targets: []types.WebhookTarget{ + {URL: failServer.URL}, // This will fail + {URL: successServer.URL}, // This should still be called + }, + }, + Process: &types.ProcessPreference{ + Enabled: true, + Targets: []types.ProcessTarget{ + {Process: "scripts.tests.delivery.Handle"}, // This should succeed + }, + }, + } + + results, err := center.Deliver(ctx, content, deliveryCtx, prefs, nil) + + // Should have error (from first webhook failure) + assert.Error(t, err) + + // But all targets should be attempted + require.Len(t, results, 3) + + // First webhook failed + assert.False(t, results[0].Success) + + // Second webhook and process should succeed + assert.True(t, successCalled, "second webhook should be called despite first failure") + assert.True(t, results[1].Success, "second webhook should succeed") + assert.True(t, results[2].Success, "process should succeed") + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func createDeliveryTestRobot(t *testing.T, agentID string) *types.Robot { + t.Helper() + return &types.Robot{ + MemberID: "test-robot-1", + TeamID: "test-team-1", + DisplayName: "Test Robot", + Config: &types.Config{ + Identity: &types.Identity{ + Role: "Test Assistant", + Duties: []string{"Testing", "Validation"}, + }, + Resources: &types.Resources{ + Phases: map[types.Phase]string{ + types.PhaseDelivery: agentID, + }, + }, + }, + } +} + +func createDeliveryTestExecution(robot *types.Robot) *types.Execution { + exec := &types.Execution{ + ID: "test-exec-delivery-1", + MemberID: robot.MemberID, + TeamID: robot.TeamID, + TriggerType: types.TriggerClock, + StartTime: time.Now(), + Status: types.ExecRunning, + Phase: types.PhaseDelivery, + } + exec.SetRobot(robot) + return exec +} diff --git a/agent/robot/types/config_global.go b/agent/robot/types/config_global.go new file mode 100644 index 00000000..e5989c53 --- /dev/null +++ b/agent/robot/types/config_global.go @@ -0,0 +1,35 @@ +package types + +import "sync" + +// Global configuration for robot agent +// These values can be set during agent initialization + +var ( + // defaultEmailChannel - default messenger channel name for sending emails + // Can be configured via SetDefaultEmailChannel() + // Default: "email" (maps to messengers/channels.yao configuration) + defaultEmailChannel = "email" + + // configMu protects global configuration + configMu sync.RWMutex +) + +// DefaultEmailChannel returns the default email channel name +func DefaultEmailChannel() string { + configMu.RLock() + defer configMu.RUnlock() + return defaultEmailChannel +} + +// SetDefaultEmailChannel sets the default messenger channel for email delivery +// This should be called during agent initialization +// The channel name must match a channel defined in messengers/channels.yao +func SetDefaultEmailChannel(channel string) { + if channel == "" { + return + } + configMu.Lock() + defer configMu.Unlock() + defaultEmailChannel = channel +} diff --git a/agent/robot/types/robot.go b/agent/robot/types/robot.go index 6d3ecf6f..d84b94bb 100644 --- a/agent/robot/types/robot.go +++ b/agent/robot/types/robot.go @@ -20,6 +20,7 @@ type Robot struct { SystemPrompt string `json:"system_prompt"` Status RobotStatus `json:"robot_status"` AutonomousMode bool `json:"autonomous_mode"` + RobotEmail string `json:"robot_email"` // Robot's email address for sending emails // Parsed config (from robot_config JSON field) Config *Config `json:"-"` @@ -317,7 +318,6 @@ type EmailPreference struct { // EmailTarget - Single email target type EmailTarget struct { To []string `json:"to"` // Recipient addresses - CC []string `json:"cc,omitempty"` // CC addresses Template string `json:"template,omitempty"` // Email template ID Subject string `json:"subject,omitempty"` // Subject template } @@ -383,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) { DisplayName: getString(m, "display_name"), SystemPrompt: getString(m, "system_prompt"), AutonomousMode: getBool(m, "autonomous_mode"), + RobotEmail: getString(m, "robot_email"), } // Parse robot_status diff --git a/agent/robot/types/robot_test.go b/agent/robot/types/robot_test.go index d69725aa..dad84d12 100644 --- a/agent/robot/types/robot_test.go +++ b/agent/robot/types/robot_test.go @@ -648,7 +648,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) { Targets: []types.EmailTarget{ { To: []string{"team@example.com"}, - CC: []string{"manager@example.com"}, Template: "weekly-report", Subject: "Weekly Report - {{.Date}}", }, @@ -686,7 +685,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) { assert.Len(t, prefs.Email.Targets, 2) assert.Equal(t, "weekly-report", prefs.Email.Targets[0].Template) assert.Len(t, prefs.Email.Targets[0].To, 1) - assert.Len(t, prefs.Email.Targets[0].CC, 1) // Webhook assert.True(t, prefs.Webhook.Enabled)