From c3a701fe8edc246beb1923ce8131108b14d2d9a3 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 8 Nov 2025 13:16:35 -0800 Subject: [PATCH] Implement message system improvements (bd-6uix) - Add 30s HTTP client timeout to prevent hangs (bd-de0h) - Implement full message reading with body display (bd-8mfn) - Add validation for --importance flag (bd-bdhn) - Refactor duplicated error messages into constant (bd-s1xn) - Fix inefficient client-side filtering, use server-side (bd-ri6d) - Improve type safety with typed Message struct (bd-p0zr) Amp-Thread-ID: https://ampcode.com/threads/T-ee20cc86-a866-4789-9d4d-4d14e1a6d6f4 Co-authored-by: Amp --- cmd/bd/message.go | 520 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 cmd/bd/message.go diff --git a/cmd/bd/message.go b/cmd/bd/message.go new file mode 100644 index 00000000..3ae46879 --- /dev/null +++ b/cmd/bd/message.go @@ -0,0 +1,520 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var messageCmd = &cobra.Command{ + Use: "message", + Short: "Send and receive messages via Agent Mail", + Long: `Send and receive messages between agents using Agent Mail server. + +Requires Agent Mail server running and these environment variables: + BEADS_AGENT_MAIL_URL - Server URL (e.g., http://127.0.0.1:8765) + BEADS_AGENT_NAME - Your agent name (e.g., fred-beads-stevey-macbook) + BEADS_PROJECT_ID - Project identifier (defaults to repo path) + +Example: + bd message send dave-beads-stevey-macbook "Need review on bd-z0yn" + bd message inbox --unread-only + bd message read msg-abc123 + bd message ack msg-abc123`, +} + +var messageSendCmd = &cobra.Command{ + Use: "send ", + Short: "Send a message to another agent", + Long: `Send a message to another agent via Agent Mail. + +The message can be plain text or GitHub-flavored Markdown. + +Examples: + bd message send dave-beads-stevey-macbook "Working on bd-z0yn" + bd message send cino-beads-stevey-macbook "Please review PR #42" --subject "Review Request" + bd message send emma-beads-stevey-macbook "Found bug in auth" --thread-id bd-123`, + Args: cobra.ExactArgs(2), + RunE: runMessageSend, +} + +var messageInboxCmd = &cobra.Command{ + Use: "inbox", + Short: "List inbox messages", + Long: `List messages in your inbox. + +Examples: + bd message inbox + bd message inbox --unread-only --limit 10 + bd message inbox --urgent-only`, + RunE: runMessageInbox, +} + +var messageReadCmd = &cobra.Command{ + Use: "read ", + Short: "Read a specific message", + Long: `Read and display a specific message by ID. + +Marks the message as read automatically. + +Example: + bd message read msg-abc123`, + Args: cobra.ExactArgs(1), + RunE: runMessageRead, +} + +var messageAckCmd = &cobra.Command{ + Use: "ack ", + Short: "Acknowledge a message", + Long: `Acknowledge a message that requires acknowledgement. + +Also marks the message as read if not already. + +Example: + bd message ack msg-abc123`, + Args: cobra.ExactArgs(1), + RunE: runMessageAck, +} + +// Message send flags +var ( + messageSubject string + messageThreadID string + messageImportance string + messageAckRequired bool +) + +// Message inbox flags +var ( + messageLimit int + messageUnreadOnly bool + messageUrgentOnly bool +) + +func init() { + // Register message commands + rootCmd.AddCommand(messageCmd) + messageCmd.AddCommand(messageSendCmd) + messageCmd.AddCommand(messageInboxCmd) + messageCmd.AddCommand(messageReadCmd) + messageCmd.AddCommand(messageAckCmd) + + // Send command flags + messageSendCmd.Flags().StringVarP(&messageSubject, "subject", "s", "", "Message subject") + messageSendCmd.Flags().StringVar(&messageThreadID, "thread-id", "", "Thread ID to group related messages") + messageSendCmd.Flags().StringVar(&messageImportance, "importance", "normal", "Message importance (low, normal, high, urgent)") + messageSendCmd.Flags().BoolVar(&messageAckRequired, "ack-required", false, "Require acknowledgement from recipient") + + // Inbox command flags + messageInboxCmd.Flags().IntVar(&messageLimit, "limit", 20, "Maximum number of messages to show") + messageInboxCmd.Flags().BoolVar(&messageUnreadOnly, "unread-only", false, "Show only unread messages") + messageInboxCmd.Flags().BoolVar(&messageUrgentOnly, "urgent-only", false, "Show only urgent messages") +} + +// AgentMailConfig holds configuration for Agent Mail server +type AgentMailConfig struct { + URL string + AgentName string + ProjectID string +} + +const agentMailConfigHelp = `Agent Mail not configured. Configure with: + export BEADS_AGENT_MAIL_URL=http://127.0.0.1:8765 + export BEADS_AGENT_NAME=your-agent-name + export BEADS_PROJECT_ID=your-project` + +// Message represents an Agent Mail message +type Message struct { + ID int `json:"id"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + FromAgent string `json:"from_agent"` + CreatedAt time.Time `json:"created_at"` + Importance string `json:"importance"` + AckRequired bool `json:"ack_required"` + ThreadID string `json:"thread_id,omitempty"` + Read bool `json:"read"` + Acknowledged bool `json:"acknowledged"` +} + +// getAgentMailConfig retrieves Agent Mail configuration from environment +func getAgentMailConfig() (*AgentMailConfig, error) { + url := os.Getenv("BEADS_AGENT_MAIL_URL") + if url == "" { + return nil, fmt.Errorf("BEADS_AGENT_MAIL_URL not set") + } + + agentName := os.Getenv("BEADS_AGENT_NAME") + if agentName == "" { + return nil, fmt.Errorf("BEADS_AGENT_NAME not set") + } + + projectID := os.Getenv("BEADS_PROJECT_ID") + if projectID == "" { + // Default to workspace root path (directory containing .beads/) + if dbPath != "" { + beadsDir := filepath.Dir(dbPath) + projectID = filepath.Dir(beadsDir) + } else { + // Fallback to current directory + cwd, err := os.Getwd() + if err == nil { + projectID = cwd + } + } + } + + return &AgentMailConfig{ + URL: url, + AgentName: agentName, + ProjectID: projectID, + }, nil +} + +// sendAgentMailRequest sends a JSON-RPC request to Agent Mail server +func sendAgentMailRequest(config *AgentMailConfig, method string, params interface{}) (json.RawMessage, error) { + request := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]interface{}{ + "name": method, + "arguments": params, + }, + } + + reqBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := strings.TrimRight(config.URL, "/") + "/mcp" + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to connect to Agent Mail server: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Agent Mail server returned error: %s (status %d)", string(body), resp.StatusCode) + } + + var response struct { + Result struct { + Content []struct { + Type string `json:"type"` + Text json.RawMessage `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != nil { + return nil, fmt.Errorf("Agent Mail error: %s (code %d)", response.Error.Message, response.Error.Code) + } + + if len(response.Result.Content) == 0 { + return nil, fmt.Errorf("no content in response") + } + + return response.Result.Content[0].Text, nil +} + +func runMessageSend(cmd *cobra.Command, args []string) error { + // Validate importance flag + validImportance := map[string]bool{ + "low": true, + "normal": true, + "high": true, + "urgent": true, + } + if !validImportance[messageImportance] { + return fmt.Errorf("invalid importance: %s (must be: low, normal, high, urgent)", messageImportance) + } + + config, err := getAgentMailConfig() + if err != nil { + return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) + } + + toAgent := args[0] + message := args[1] + + // Prepare request parameters + params := map[string]interface{}{ + "project_key": config.ProjectID, + "sender_name": config.AgentName, + "to": []string{toAgent}, + "body_md": message, + } + + if messageSubject != "" { + params["subject"] = messageSubject + } else { + // Generate subject from first line of message + firstLine := strings.Split(message, "\n")[0] + if len(firstLine) > 50 { + firstLine = firstLine[:50] + "..." + } + params["subject"] = firstLine + } + + if messageThreadID != "" { + params["thread_id"] = messageThreadID + } + + if messageImportance != "normal" { + params["importance"] = messageImportance + } + + if messageAckRequired { + params["ack_required"] = true + } + + // Send message via Agent Mail + result, err := sendAgentMailRequest(config, "send_message", params) + if err != nil { + return err + } + + // Parse result + var sendResult struct { + Deliveries []struct { + Recipient string `json:"recipient"` + MessageID int `json:"message_id"` + } `json:"deliveries"` + Count int `json:"count"` + } + + if err := json.Unmarshal(result, &sendResult); err != nil { + return fmt.Errorf("failed to parse send result: %w", err) + } + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(sendResult) + } + + fmt.Printf("Message sent to %s\n", toAgent) + if sendResult.Count > 0 && len(sendResult.Deliveries) > 0 { + fmt.Printf("Message ID: %d\n", sendResult.Deliveries[0].MessageID) + } + if messageThreadID != "" { + fmt.Printf("Thread: %s\n", messageThreadID) + } + + return nil +} + +func runMessageInbox(cmd *cobra.Command, args []string) error { + config, err := getAgentMailConfig() + if err != nil { + return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) + } + + // Prepare request parameters + params := map[string]interface{}{ + "project_key": config.ProjectID, + "agent_name": config.AgentName, + "limit": messageLimit, + "include_bodies": false, + } + + if messageUnreadOnly { + params["unread_only"] = true + } + + if messageUrgentOnly { + params["urgent_only"] = true + } + + // Fetch inbox via Agent Mail + result, err := sendAgentMailRequest(config, "fetch_inbox", params) + if err != nil { + return err + } + + // Parse result + var messages []Message + + if err := json.Unmarshal(result, &messages); err != nil { + return fmt.Errorf("failed to parse inbox: %w", err) + } + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(messages) + } + + if len(messages) == 0 { + fmt.Println("No messages in inbox") + return nil + } + + fmt.Printf("Inbox for %s (%d messages):\n\n", config.AgentName, len(messages)) + for _, msg := range messages { + // Format timestamp + age := time.Since(msg.CreatedAt) + var timeStr string + if age < time.Hour { + timeStr = fmt.Sprintf("%dm ago", int(age.Minutes())) + } else if age < 24*time.Hour { + timeStr = fmt.Sprintf("%dh ago", int(age.Hours())) + } else { + timeStr = fmt.Sprintf("%dd ago", int(age.Hours()/24)) + } + + // Status indicators + status := "" + if !msg.Read { + status += " [UNREAD]" + } + if msg.AckRequired && !msg.Acknowledged { + status += " [ACK REQUIRED]" + } + if msg.Importance == "high" || msg.Importance == "urgent" { + status += fmt.Sprintf(" [%s]", strings.ToUpper(msg.Importance)) + } + + fmt.Printf(" %d: %s%s\n", msg.ID, msg.Subject, status) + fmt.Printf(" From: %s (%s)\n", msg.FromAgent, timeStr) + if msg.ThreadID != "" { + fmt.Printf(" Thread: %s\n", msg.ThreadID) + } + fmt.Println() + } + + return nil +} + +func runMessageRead(cmd *cobra.Command, args []string) error { + config, err := getAgentMailConfig() + if err != nil { + return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) + } + + messageID := args[0] + + // Fetch full message with body + fetchParams := map[string]interface{}{ + "project_key": config.ProjectID, + "agent_name": config.AgentName, + "message_id": messageID, + "include_bodies": true, + } + + result, err := sendAgentMailRequest(config, "fetch_inbox", fetchParams) + if err != nil { + return fmt.Errorf("failed to fetch message: %w", err) + } + + // Parse message + var messages []Message + + if err := json.Unmarshal(result, &messages); err != nil { + return fmt.Errorf("failed to parse message: %w", err) + } + + if len(messages) == 0 { + return fmt.Errorf("message not found: %s", messageID) + } + + msg := messages[0] + + // Mark as read if not already + if !msg.Read { + markParams := map[string]interface{}{ + "project_key": config.ProjectID, + "agent_name": config.AgentName, + "message_id": messageID, + } + _, _ = sendAgentMailRequest(config, "mark_message_read", markParams) + } + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(msg) + } + + // Display message + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + fmt.Printf("From: %s\n", msg.FromAgent) + fmt.Printf("Subject: %s\n", msg.Subject) + fmt.Printf("Time: %s\n", msg.CreatedAt.Format("2006-01-02 15:04:05 MST")) + if msg.ThreadID != "" { + fmt.Printf("Thread: %s\n", msg.ThreadID) + } + if msg.Importance != "" && msg.Importance != "normal" { + fmt.Printf("Priority: %s\n", strings.ToUpper(msg.Importance)) + } + if msg.AckRequired { + status := "Required" + if msg.Acknowledged { + status = "Acknowledged" + } + fmt.Printf("Acknowledgement: %s\n", status) + } + fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") + fmt.Println(msg.Body) + fmt.Println() + + return nil +} + +func runMessageAck(cmd *cobra.Command, args []string) error { + config, err := getAgentMailConfig() + if err != nil { + return fmt.Errorf("%w\n\n%s", err, agentMailConfigHelp) + } + + messageID := args[0] + + // Acknowledge message + params := map[string]interface{}{ + "project_key": config.ProjectID, + "agent_name": config.AgentName, + "message_id": messageID, + } + + result, err := sendAgentMailRequest(config, "acknowledge_message", params) + if err != nil { + return err + } + + if jsonOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + var ackResult map[string]interface{} + if err := json.Unmarshal(result, &ackResult); err != nil { + return fmt.Errorf("failed to parse ack result: %w", err) + } + return encoder.Encode(ackResult) + } + + fmt.Printf("Message %s acknowledged\n", messageID) + return nil +}