Files
synapbus/internal/messaging/store.go
T
Algis DumbrisandClaude Opus 4.6 6bb88374ce fix: DM messages cut off by limit, thread panel shows no replies
Bug 1 (DM disappearing): GetDMMessages used ORDER BY created_at ASC
with LIMIT 100, so newest messages were cut off when >100 DMs exist
between owned agents and a peer. Changed to DESC + reverse in handler
so the most recent messages are always included.

Bug 2 (empty thread panel): ThreadPanel loaded messages by
conversation_id, but reply_to links messages across different
conversations. Rewrote to use GET /api/messages/{id}/replies which
correctly finds all replies to a parent message. Added getReplies
method to the API client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:34:24 +02:00

1086 lines
33 KiB
Go

package messaging
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
)
// MessageStore defines the storage interface for messaging operations.
type MessageStore interface {
InsertMessage(ctx context.Context, msg *Message) error
InsertConversation(ctx context.Context, conv *Conversation) error
FindConversation(ctx context.Context, subject, fromAgent, toAgent string) (*Conversation, error)
GetInboxMessages(ctx context.Context, agentName string, opts ReadOptions) ([]*Message, error)
CountInboxMessages(ctx context.Context, agentName string, opts ReadOptions) (int, error)
GetInboxState(ctx context.Context, agentName string, conversationID int64) (*InboxState, error)
UpdateInboxState(ctx context.Context, agentName string, conversationID int64, lastReadMsgID int64) error
ClaimMessages(ctx context.Context, agentName string, limit int) ([]*Message, error)
UpdateMessageStatus(ctx context.Context, id int64, status, claimedBy string, metadata json.RawMessage) error
GetMessageByID(ctx context.Context, id int64) (*Message, error)
SearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) ([]*Message, error)
CountSearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) (int, error)
GetConversation(ctx context.Context, id int64) (*Conversation, error)
GetConversationMessages(ctx context.Context, conversationID int64) ([]*Message, error)
GetReplies(ctx context.Context, messageID int64) ([]*Message, error)
GetChannelMessages(ctx context.Context, channelID int64, limit, offset int) ([]*Message, error)
CountChannelMessages(ctx context.Context, channelID int64) (int, error)
GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error)
AgentExists(ctx context.Context, agentName string) (bool, error)
CountPendingDMs(ctx context.Context, agentName string) (int64, error)
GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetRecentMentions(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetSystemNotifications(ctx context.Context, agentName string, limit int) ([]*Message, error)
GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error)
GetLastReadForChannel(ctx context.Context, agentName string, channelID int64) (int64, error)
GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error)
GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error)
GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error)
GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error)
}
// SQLiteMessageStore implements MessageStore using SQLite.
type SQLiteMessageStore struct {
db *sql.DB
}
// NewSQLiteMessageStore creates a new SQLite-backed message store.
func NewSQLiteMessageStore(db *sql.DB) *SQLiteMessageStore {
return &SQLiteMessageStore{db: db}
}
func (s *SQLiteMessageStore) InsertConversation(ctx context.Context, conv *Conversation) error {
result, err := s.db.ExecContext(ctx,
`INSERT INTO conversations (subject, created_by, channel_id, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
conv.Subject, conv.CreatedBy, conv.ChannelID,
)
if err != nil {
return fmt.Errorf("insert conversation: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get conversation id: %w", err)
}
conv.ID = id
return nil
}
func (s *SQLiteMessageStore) FindConversation(ctx context.Context, subject, fromAgent, toAgent string) (*Conversation, error) {
var conv Conversation
var channelID sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT c.id, c.subject, c.created_by, c.channel_id, c.created_at, c.updated_at
FROM conversations c
WHERE c.subject = ? AND c.channel_id IS NULL
AND EXISTS (
SELECT 1 FROM messages m WHERE m.conversation_id = c.id
AND ((m.from_agent = ? AND m.to_agent = ?) OR (m.from_agent = ? AND m.to_agent = ?))
)
ORDER BY c.id DESC LIMIT 1`,
subject, fromAgent, toAgent, toAgent, fromAgent,
).Scan(&conv.ID, &conv.Subject, &conv.CreatedBy, &channelID, &conv.CreatedAt, &conv.UpdatedAt)
if err != nil {
return nil, err
}
if channelID.Valid {
conv.ChannelID = &channelID.Int64
}
return &conv, nil
}
func (s *SQLiteMessageStore) InsertMessage(ctx context.Context, msg *Message) error {
metadata := msg.Metadata
if metadata == nil {
metadata = json.RawMessage("{}")
}
var toAgent sql.NullString
if msg.ToAgent != "" {
toAgent = sql.NullString{String: msg.ToAgent, Valid: true}
}
var replyTo sql.NullInt64
if msg.ReplyTo != nil {
replyTo = sql.NullInt64{Int64: *msg.ReplyTo, Valid: true}
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, reply_to, body, priority, status, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
msg.ConversationID, msg.FromAgent, toAgent, msg.ChannelID, replyTo, msg.Body, msg.Priority, msg.Status, string(metadata),
)
if err != nil {
return fmt.Errorf("insert message: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("get message id: %w", err)
}
msg.ID = id
return nil
}
func (s *SQLiteMessageStore) GetInboxMessages(ctx context.Context, agentName string, opts ReadOptions) ([]*Message, error) {
conditions, args := s.buildInboxConditions(agentName, opts)
limit := opts.Limit
if limit <= 0 {
limit = 50
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
query := fmt.Sprintf(
`SELECT m.id, m.conversation_id, m.from_agent, m.to_agent, m.channel_id,
m.body, m.priority, m.status, m.metadata, m.claimed_by, m.claimed_at,
m.created_at, m.updated_at, m.reply_to
FROM messages m
WHERE %s
ORDER BY m.priority DESC, m.created_at ASC
LIMIT ? OFFSET ?`,
strings.Join(conditions, " AND "),
)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query inbox: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
// CountInboxMessages returns the total count of inbox messages matching the given options.
func (s *SQLiteMessageStore) CountInboxMessages(ctx context.Context, agentName string, opts ReadOptions) (int, error) {
conditions, args := s.buildInboxConditions(agentName, opts)
query := fmt.Sprintf(
`SELECT COUNT(*) FROM messages m WHERE %s`,
strings.Join(conditions, " AND "),
)
var count int
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count inbox messages: %w", err)
}
return count, nil
}
// buildInboxConditions builds the WHERE conditions and args for inbox queries.
func (s *SQLiteMessageStore) buildInboxConditions(agentName string, opts ReadOptions) ([]string, []any) {
var conditions []string
var args []any
// Direct messages to this agent
conditions = append(conditions, "m.to_agent = ?")
args = append(args, agentName)
// Filter by read/unread using inbox_state
if !opts.IncludeRead {
conditions = append(conditions,
`m.id > COALESCE(
(SELECT last_read_message_id FROM inbox_state
WHERE agent_name = ? AND conversation_id = m.conversation_id), 0)`)
args = append(args, agentName)
}
if opts.Status != "" {
conditions = append(conditions, "m.status = ?")
args = append(args, opts.Status)
}
if opts.FromAgent != "" {
conditions = append(conditions, "m.from_agent = ?")
args = append(args, opts.FromAgent)
}
if opts.ConversationID != nil {
conditions = append(conditions, "m.conversation_id = ?")
args = append(args, *opts.ConversationID)
}
if opts.MinPriority > 0 {
conditions = append(conditions, "m.priority >= ?")
args = append(args, opts.MinPriority)
}
if opts.After != "" {
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
conditions = append(conditions, "m.created_at >= ?")
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
}
}
if opts.Before != "" {
if t, err := time.Parse(time.RFC3339, opts.Before); err == nil {
conditions = append(conditions, "m.created_at <= ?")
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
}
}
return conditions, args
}
func (s *SQLiteMessageStore) GetInboxState(ctx context.Context, agentName string, conversationID int64) (*InboxState, error) {
var state InboxState
err := s.db.QueryRowContext(ctx,
`SELECT agent_name, conversation_id, last_read_message_id, updated_at
FROM inbox_state WHERE agent_name = ? AND conversation_id = ?`,
agentName, conversationID,
).Scan(&state.AgentName, &state.ConversationID, &state.LastReadMessageID, &state.UpdatedAt)
if err != nil {
return nil, err
}
return &state, nil
}
func (s *SQLiteMessageStore) UpdateInboxState(ctx context.Context, agentName string, conversationID int64, lastReadMsgID int64) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO inbox_state (agent_name, conversation_id, last_read_message_id, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(agent_name, conversation_id) DO UPDATE SET
last_read_message_id = MAX(last_read_message_id, excluded.last_read_message_id),
updated_at = CURRENT_TIMESTAMP`,
agentName, conversationID, lastReadMsgID,
)
return err
}
func (s *SQLiteMessageStore) ClaimMessages(ctx context.Context, agentName string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 10
}
// First, find the IDs of pending messages to claim
idRows, err := s.db.QueryContext(ctx,
`SELECT id FROM messages
WHERE to_agent = ? AND status = 'pending'
ORDER BY priority DESC, created_at ASC
LIMIT ?`,
agentName, limit,
)
if err != nil {
return nil, fmt.Errorf("find pending messages: %w", err)
}
var ids []int64
for idRows.Next() {
var id int64
if err := idRows.Scan(&id); err != nil {
idRows.Close()
return nil, fmt.Errorf("scan message id: %w", err)
}
ids = append(ids, id)
}
idRows.Close()
if len(ids) == 0 {
return []*Message{}, nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, agentName)
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
// Atomically claim these specific messages
_, err = s.db.ExecContext(ctx,
fmt.Sprintf(
`UPDATE messages SET
status = 'processing',
claimed_by = ?,
claimed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id IN (%s) AND status = 'pending'`,
strings.Join(placeholders, ","),
),
args...,
)
if err != nil {
return nil, fmt.Errorf("claim messages: %w", err)
}
// Return the claimed messages by their specific IDs
fetchArgs := make([]any, len(ids))
for i, id := range ids {
fetchArgs[i] = id
}
fetchPlaceholders := make([]string, len(ids))
for i := range ids {
fetchPlaceholders[i] = "?"
}
rows, err := s.db.QueryContext(ctx,
fmt.Sprintf(
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages
WHERE id IN (%s)
ORDER BY priority DESC, created_at ASC`,
strings.Join(fetchPlaceholders, ","),
),
fetchArgs...,
)
if err != nil {
return nil, fmt.Errorf("query claimed messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) UpdateMessageStatus(ctx context.Context, id int64, status, claimedBy string, metadata json.RawMessage) error {
var result sql.Result
var err error
if metadata != nil {
result, err = s.db.ExecContext(ctx,
`UPDATE messages SET status = ?, metadata = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND claimed_by = ?`,
status, string(metadata), id, claimedBy,
)
} else {
result, err = s.db.ExecContext(ctx,
`UPDATE messages SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND claimed_by = ?`,
status, id, claimedBy,
)
}
if err != nil {
return fmt.Errorf("update message status: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("message not found or not claimed by agent")
}
return nil
}
func (s *SQLiteMessageStore) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE id = ?`, id,
)
return scanMessage(row)
}
func (s *SQLiteMessageStore) SearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) ([]*Message, error) {
conditions, args, joinClause, orderClause := s.buildSearchConditions(agentName, query, opts)
limit := opts.Limit
if limit <= 0 {
limit = 20
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
querySQL := fmt.Sprintf(
`SELECT m.id, m.conversation_id, m.from_agent, m.to_agent, m.channel_id,
m.body, m.priority, m.status, m.metadata, m.claimed_by, m.claimed_at,
m.created_at, m.updated_at, m.reply_to
FROM messages m
%s
WHERE %s
%s
LIMIT ? OFFSET ?`,
joinClause,
strings.Join(conditions, " AND "),
orderClause,
)
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, querySQL, args...)
if err != nil {
return nil, fmt.Errorf("search messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
// CountSearchMessages returns the total count of messages matching the search criteria.
func (s *SQLiteMessageStore) CountSearchMessages(ctx context.Context, agentName, query string, opts SearchOptions) (int, error) {
conditions, args, joinClause, _ := s.buildSearchConditions(agentName, query, opts)
countSQL := fmt.Sprintf(
`SELECT COUNT(*) FROM messages m %s WHERE %s`,
joinClause,
strings.Join(conditions, " AND "),
)
var count int
err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count search messages: %w", err)
}
return count, nil
}
// buildSearchConditions builds the WHERE conditions, args, JOIN clause, and ORDER clause for search queries.
func (s *SQLiteMessageStore) buildSearchConditions(agentName, query string, opts SearchOptions) ([]string, []any, string, string) {
var conditions []string
var args []any
// Scope to messages accessible by this agent:
// - DMs where agent is sender or recipient
// - Channel messages where agent is a member
conditions = append(conditions, "(m.to_agent = ? OR m.from_agent = ? OR (m.channel_id IS NOT NULL AND m.to_agent = '' AND EXISTS (SELECT 1 FROM channel_members cm WHERE cm.channel_id = m.channel_id AND cm.agent_name = ?)))")
args = append(args, agentName, agentName, agentName)
var joinClause string
var orderClause string
if query != "" {
joinClause = "JOIN messages_fts ON messages_fts.rowid = m.id"
conditions = append(conditions, "messages_fts MATCH ?")
args = append(args, query)
orderClause = "ORDER BY rank"
} else {
orderClause = "ORDER BY m.created_at DESC"
}
if opts.FromAgent != "" {
conditions = append(conditions, "m.from_agent = ?")
args = append(args, opts.FromAgent)
}
if opts.ToAgent != "" {
conditions = append(conditions, "m.to_agent = ?")
args = append(args, opts.ToAgent)
}
if opts.MinPriority > 0 {
conditions = append(conditions, "m.priority >= ?")
args = append(args, opts.MinPriority)
}
if opts.Status != "" {
conditions = append(conditions, "m.status = ?")
args = append(args, opts.Status)
}
if opts.ChannelID != nil {
conditions = append(conditions, "m.channel_id = ?")
args = append(args, *opts.ChannelID)
}
if opts.Channel != "" {
conditions = append(conditions, "m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) = LOWER(?))")
args = append(args, opts.Channel)
}
if len(opts.Channels) > 0 {
placeholders := make([]string, len(opts.Channels))
for i, ch := range opts.Channels {
placeholders[i] = "?"
args = append(args, ch)
}
conditions = append(conditions, fmt.Sprintf("m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) IN (%s))", strings.Join(placeholders, ",")))
}
if len(opts.ExcludeChannels) > 0 {
placeholders := make([]string, len(opts.ExcludeChannels))
for i, ch := range opts.ExcludeChannels {
placeholders[i] = "?"
args = append(args, ch)
}
conditions = append(conditions, fmt.Sprintf("(m.channel_id IS NULL OR m.channel_id NOT IN (SELECT id FROM channels WHERE LOWER(name) IN (%s)))", strings.Join(placeholders, ",")))
}
if len(opts.Agents) > 0 {
placeholders := make([]string, len(opts.Agents))
for i, a := range opts.Agents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Clone placeholders for the second IN clause
placeholders2 := make([]string, len(opts.Agents))
for i, a := range opts.Agents {
placeholders2[i] = "?"
args = append(args, a)
}
inClause2 := strings.Join(placeholders2, ",")
conditions = append(conditions, fmt.Sprintf("(m.from_agent IN (%s) OR m.to_agent IN (%s))", inClause, inClause2))
}
if len(opts.ExcludeAgents) > 0 {
placeholders := make([]string, len(opts.ExcludeAgents))
for i, a := range opts.ExcludeAgents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
placeholders2 := make([]string, len(opts.ExcludeAgents))
for i, a := range opts.ExcludeAgents {
placeholders2[i] = "?"
args = append(args, a)
}
inClause2 := strings.Join(placeholders2, ",")
conditions = append(conditions, fmt.Sprintf("m.from_agent NOT IN (%s) AND (m.to_agent = '' OR m.to_agent NOT IN (%s))", inClause, inClause2))
}
if opts.After != "" {
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
conditions = append(conditions, "m.created_at >= ?")
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
}
}
if opts.Before != "" {
if t, err := time.Parse(time.RFC3339, opts.Before); err == nil {
conditions = append(conditions, "m.created_at <= ?")
args = append(args, t.UTC().Format("2006-01-02 15:04:05"))
}
}
return conditions, args, joinClause, orderClause
}
func (s *SQLiteMessageStore) GetConversation(ctx context.Context, id int64) (*Conversation, error) {
var conv Conversation
var channelID sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT id, subject, created_by, channel_id, created_at, updated_at
FROM conversations WHERE id = ?`, id,
).Scan(&conv.ID, &conv.Subject, &conv.CreatedBy, &channelID, &conv.CreatedAt, &conv.UpdatedAt)
if err != nil {
return nil, err
}
if channelID.Valid {
conv.ChannelID = &channelID.Int64
}
return &conv, nil
}
func (s *SQLiteMessageStore) GetConversationMessages(ctx context.Context, conversationID int64) ([]*Message, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE conversation_id = ?
ORDER BY created_at ASC`, conversationID,
)
if err != nil {
return nil, fmt.Errorf("get conversation messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetReplies(ctx context.Context, messageID int64) ([]*Message, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE reply_to = ?
ORDER BY created_at ASC`, messageID,
)
if err != nil {
return nil, fmt.Errorf("get replies: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetChannelMessages(ctx context.Context, channelID int64, limit, offset int) ([]*Message, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages WHERE channel_id = ?
ORDER BY created_at ASC
LIMIT ? OFFSET ?`, channelID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("get channel messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
// CountChannelMessages returns the total number of messages in a channel.
func (s *SQLiteMessageStore) CountChannelMessages(ctx context.Context, channelID int64) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE channel_id = ?`, channelID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count channel messages: %w", err)
}
return count, nil
}
func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 50
}
if len(agents) == 0 {
return []*Message{}, nil
}
// Build placeholders for owned agents
placeholders := make([]string, len(agents))
args := make([]any, 0, len(agents)*2+2)
for i, a := range agents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Messages where (from_agent IN owned AND to_agent = peer) OR (from_agent = peer AND to_agent IN owned)
// and channel_id IS NULL (DMs only)
query := fmt.Sprintf(
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages
WHERE channel_id IS NULL
AND ((from_agent IN (%s) AND to_agent = ?) OR (from_agent = ? AND to_agent IN (%s)))
ORDER BY created_at DESC
LIMIT ?`,
inClause, inClause,
)
args = append(args, peerAgent, peerAgent)
for _, a := range agents {
args = append(args, a)
}
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get dm messages: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) CountPendingDMs(ctx context.Context, agentName string) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM messages WHERE to_agent = ? AND status = 'pending' AND channel_id IS NULL`,
agentName,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count pending DMs: %w", err)
}
return count, nil
}
func (s *SQLiteMessageStore) GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 10
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages
WHERE to_agent = ? AND status IN ('pending','processing') AND channel_id IS NULL AND from_agent != 'system'
ORDER BY created_at DESC
LIMIT ?`,
agentName, limit,
)
if err != nil {
return nil, fmt.Errorf("get pending DMs: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetRecentMentions(ctx context.Context, agentName string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 10
}
rows, err := s.db.QueryContext(ctx,
`SELECT m.id, m.conversation_id, m.from_agent, m.to_agent, m.channel_id,
m.body, m.priority, m.status, m.metadata, m.claimed_by, m.claimed_at,
m.created_at, m.updated_at, m.reply_to
FROM messages m
JOIN channel_members cm ON cm.channel_id = m.channel_id AND cm.agent_name = ?
WHERE m.channel_id IS NOT NULL
AND m.body LIKE '%@' || ? || '%'
AND m.from_agent != ?
ORDER BY m.created_at DESC
LIMIT ?`,
agentName, agentName, agentName, limit,
)
if err != nil {
return nil, fmt.Errorf("get recent mentions: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) GetSystemNotifications(ctx context.Context, agentName string, limit int) ([]*Message, error) {
if limit <= 0 {
limit = 5
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, from_agent, to_agent, channel_id,
body, priority, status, metadata, claimed_by, claimed_at,
created_at, updated_at, reply_to
FROM messages
WHERE to_agent = ? AND from_agent = 'system' AND status = 'pending'
ORDER BY created_at DESC
LIMIT ?`,
agentName, limit,
)
if err != nil {
return nil, fmt.Errorf("get system notifications: %w", err)
}
defer rows.Close()
return scanMessages(rows)
}
func (s *SQLiteMessageStore) AgentExists(ctx context.Context, agentName string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM agents WHERE name = ? AND status = 'active'`,
agentName,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// scanMessages scans multiple message rows.
func scanMessages(rows *sql.Rows) ([]*Message, error) {
var messages []*Message
for rows.Next() {
msg, err := scanMessageFromRows(rows)
if err != nil {
return nil, err
}
messages = append(messages, msg)
}
if messages == nil {
messages = []*Message{}
}
return messages, rows.Err()
}
// scanMessageFromRows scans a single message from sql.Rows.
func scanMessageFromRows(rows *sql.Rows) (*Message, error) {
var msg Message
var toAgent, claimedBy sql.NullString
var channelID, replyTo sql.NullInt64
var claimedAt sql.NullTime
var metadata string
err := rows.Scan(
&msg.ID, &msg.ConversationID, &msg.FromAgent, &toAgent, &channelID,
&msg.Body, &msg.Priority, &msg.Status, &metadata, &claimedBy, &claimedAt,
&msg.CreatedAt, &msg.UpdatedAt, &replyTo,
)
if err != nil {
return nil, fmt.Errorf("scan message: %w", err)
}
if toAgent.Valid {
msg.ToAgent = toAgent.String
}
if channelID.Valid {
msg.ChannelID = &channelID.Int64
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.Int64
}
if claimedBy.Valid {
msg.ClaimedBy = claimedBy.String
}
if claimedAt.Valid {
msg.ClaimedAt = &claimedAt.Time
}
msg.Metadata = json.RawMessage(metadata)
return &msg, nil
}
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
// For each unique from_agent that has sent DMs to agentName, it computes
// how many messages have id > last_read_message_id (from inbox_state).
func (s *SQLiteMessageStore) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT
m.from_agent,
COUNT(CASE WHEN m.id > COALESCE(
(SELECT ist.last_read_message_id FROM inbox_state ist
WHERE ist.agent_name = ? AND ist.conversation_id = m.conversation_id), 0)
THEN 1 END) AS unread_count,
MAX(m.id) AS last_message_id
FROM messages m
WHERE m.to_agent = ?
AND m.channel_id IS NULL
AND m.from_agent != 'system'
GROUP BY m.from_agent
ORDER BY m.from_agent`,
agentName, agentName,
)
if err != nil {
return nil, fmt.Errorf("get dm unread counts: %w", err)
}
defer rows.Close()
var results []DMUnreadCount
for rows.Next() {
var d DMUnreadCount
if err := rows.Scan(&d.Agent, &d.UnreadCount, &d.LastMessageID); err != nil {
return nil, fmt.Errorf("scan dm unread count: %w", err)
}
results = append(results, d)
}
if results == nil {
results = []DMUnreadCount{}
}
return results, rows.Err()
}
// GetLastReadForChannel returns the effective last_read_message_id for an agent
// across all conversations in a given channel.
func (s *SQLiteMessageStore) GetLastReadForChannel(ctx context.Context, agentName string, channelID int64) (int64, error) {
var lastRead sql.NullInt64
err := s.db.QueryRowContext(ctx,
`SELECT MIN(COALESCE(ist.last_read_message_id, 0))
FROM (SELECT DISTINCT conversation_id FROM messages WHERE channel_id = ?) conv
LEFT JOIN inbox_state ist ON ist.conversation_id = conv.conversation_id AND ist.agent_name = ?`,
channelID, agentName,
).Scan(&lastRead)
if err != nil {
return 0, fmt.Errorf("get last read for channel: %w", err)
}
if lastRead.Valid {
return lastRead.Int64, nil
}
return 0, nil
}
// GetLastReadForDM returns the effective last_read_message_id for an owned agent
// in DM conversations with a specific peer agent.
func (s *SQLiteMessageStore) GetLastReadForDM(ctx context.Context, agentNames []string, peerAgent string) (int64, error) {
if len(agentNames) == 0 {
return 0, nil
}
placeholders := make([]string, len(agentNames))
args := make([]any, 0, len(agentNames)*2+1)
for i, a := range agentNames {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Find conversations between owned agents and peer agent (DMs only)
// and get the max last_read_message_id
query := fmt.Sprintf(
`SELECT COALESCE(MAX(ist.last_read_message_id), 0)
FROM inbox_state ist
WHERE ist.agent_name IN (%s)
AND ist.conversation_id IN (
SELECT DISTINCT m.conversation_id FROM messages m
WHERE m.channel_id IS NULL
AND ((m.from_agent IN (%s) AND m.to_agent = ?)
OR (m.from_agent = ? AND m.to_agent IN (%s)))
)`,
inClause, inClause, inClause,
)
for _, a := range agentNames {
args = append(args, a)
}
args = append(args, peerAgent, peerAgent)
for _, a := range agentNames {
args = append(args, a)
}
var lastRead int64
err := s.db.QueryRowContext(ctx, query, args...).Scan(&lastRead)
if err != nil {
return 0, fmt.Errorf("get last read for dm: %w", err)
}
return lastRead, nil
}
// GetConversationIDsForChannel returns conversation IDs in a channel with messages up to lastMessageID.
func (s *SQLiteMessageStore) GetConversationIDsForChannel(ctx context.Context, channelID int64, lastMessageID int64) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT conversation_id FROM messages
WHERE channel_id = ? AND id <= ?`,
channelID, lastMessageID,
)
if err != nil {
return nil, fmt.Errorf("get conversation ids for channel: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan conversation id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetConversationIDsForDM returns conversation IDs for DMs between owned agents and a peer agent,
// with messages up to lastMessageID.
func (s *SQLiteMessageStore) GetConversationIDsForDM(ctx context.Context, agentNames []string, peerAgent string, lastMessageID int64) ([]int64, error) {
if len(agentNames) == 0 {
return nil, nil
}
placeholders := make([]string, len(agentNames))
args := make([]any, 0, len(agentNames)*2+2)
for i, a := range agentNames {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
query := fmt.Sprintf(
`SELECT DISTINCT conversation_id FROM messages
WHERE channel_id IS NULL
AND id <= ?
AND ((from_agent IN (%s) AND to_agent = ?)
OR (from_agent = ? AND to_agent IN (%s)))`,
inClause, inClause,
)
// Reorder args: agentNames for first IN, lastMessageID, agentNames for second IN...
finalArgs := make([]any, 0, len(agentNames)*2+3)
finalArgs = append(finalArgs, lastMessageID)
for _, a := range agentNames {
finalArgs = append(finalArgs, a)
}
finalArgs = append(finalArgs, peerAgent, peerAgent)
for _, a := range agentNames {
finalArgs = append(finalArgs, a)
}
rows, err := s.db.QueryContext(ctx, query, finalArgs...)
if err != nil {
return nil, fmt.Errorf("get conversation ids for dm: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan conversation id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// GetReplyCounts returns a map of message ID → reply count for the given IDs.
func (s *SQLiteMessageStore) GetReplyCounts(ctx context.Context, messageIDs []int64) (map[int64]int, error) {
if len(messageIDs) == 0 {
return map[int64]int{}, nil
}
placeholders := make([]string, len(messageIDs))
args := make([]any, len(messageIDs))
for i, id := range messageIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(
`SELECT reply_to, COUNT(*) FROM messages
WHERE reply_to IN (%s)
GROUP BY reply_to`,
strings.Join(placeholders, ","),
)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get reply counts: %w", err)
}
defer rows.Close()
counts := make(map[int64]int)
for rows.Next() {
var replyTo int64
var count int
if err := rows.Scan(&replyTo, &count); err != nil {
return nil, fmt.Errorf("scan reply count: %w", err)
}
counts[replyTo] = count
}
return counts, rows.Err()
}
// scanMessage scans a single message from sql.Row.
func scanMessage(row *sql.Row) (*Message, error) {
var msg Message
var toAgent, claimedBy sql.NullString
var channelID, replyTo sql.NullInt64
var claimedAt sql.NullTime
var metadata string
err := row.Scan(
&msg.ID, &msg.ConversationID, &msg.FromAgent, &toAgent, &channelID,
&msg.Body, &msg.Priority, &msg.Status, &metadata, &claimedBy, &claimedAt,
&msg.CreatedAt, &msg.UpdatedAt, &replyTo,
)
if err != nil {
return nil, err
}
if toAgent.Valid {
msg.ToAgent = toAgent.String
}
if channelID.Valid {
msg.ChannelID = &channelID.Int64
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.Int64
}
if claimedBy.Valid {
msg.ClaimedBy = claimedBy.String
}
if claimedAt.Valid {
t := claimedAt.Time
msg.ClaimedAt = &t
}
msg.Metadata = json.RawMessage(metadata)
return &msg, nil
}