4 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 726479a57f fix: DM partners query includes owned agents as conversation partners
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
Removed the 'peer NOT IN (owned)' filter that excluded all owned agents.
Since all agents (algis, research-*, social-commenter) are owned by the
same user, the filter was hiding all inter-agent DMs. Now shows all
unique DM partners regardless of ownership.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:35:46 +02:00
Algis DumbrisandClaude Opus 4.6 5dd5a6f23c fix: DM partners query uses SQL over all messages, not just inbox
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The previous implementation only scanned inbox (pending messages),
so historical conversations with read/done messages were invisible.
New GetDMPartners() does a direct SQL query with window functions
to find all unique DM partners with most recent message preview
and unread count. Historical conversations now always show.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:30:10 +02:00
Algis DumbrisandClaude Opus 4.6 3b97429f20 fix: DM sidebar shows conversation partners, not owned agents
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
- New API: GET /api/dm/partners — returns DM conversation partners
  ordered by most recent message, with unread counts
- Sidebar DM section now shows actual conversation partners (agents
  you've exchanged messages with) instead of owned agents
- Each partner shows name, unread badge, clickable to /dm/{name}
- Fixes issue where all DMs were shown mixed in one view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:19:22 +02:00
Algis DumbrisandClaude Opus 4.6 7848911a5f fix: ignore system DMs in reactor — prevent stalemate notification cascade
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Release / Publish to MCP Registry (push) Canceled after 0s
The StaleWorker sends DMs from 'system' to all channel members when
workflow messages are stuck in 'proposed' state. These DMs were
triggering reactive agent runs, which couldn't action the stale
messages, burning daily budget on wasted K8s Jobs.

Now: reactor silently ignores all messages from 'system' sender.
System notifications are for human review, not agent action.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:56:37 +02:00
9 changed files with 210 additions and 34 deletions
+58
View File
@@ -581,6 +581,64 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
})
}
// DMPartners returns a list of agents the user has DM conversations with,
// ordered by most recent message. Queries ALL messages (not just inbox)
// so historical conversations always appear.
func (h *MessagesHandler) DMPartners(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"partners": []any{}})
return
}
agentNames := make([]string, len(ownedAgents))
for i, a := range ownedAgents {
agentNames[i] = a.Name
}
partners, err := h.msgService.GetDMPartners(r.Context(), agentNames)
if err != nil {
h.logger.Error("get dm partners failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get DM partners"))
return
}
// Resolve display names
type partnerWithDisplay struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
LastMessage string `json:"last_message"`
LastTime string `json:"last_time"`
Unread int `json:"unread"`
}
result := make([]partnerWithDisplay, len(partners))
for i, p := range partners {
result[i] = partnerWithDisplay{
Name: p.Name,
DisplayName: p.Name,
LastMessage: p.LastMessage,
LastTime: p.LastTime,
Unread: p.Unread,
}
if a, err := h.agentService.GetAgent(r.Context(), p.Name); err == nil {
result[i].DisplayName = a.DisplayName
}
}
writeJSON(w, http.StatusOK, map[string]any{"partners": result})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
+1
View File
@@ -131,6 +131,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
r.Get("/api/dm/partners", messagesHandler.DMPartners)
// Notifications
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
+5
View File
@@ -531,6 +531,11 @@ func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []stri
return messages, nil
}
// GetDMPartners returns all DM conversation partners for the owned agents.
func (s *MessagingService) GetDMPartners(ctx context.Context, ownedAgents []string) ([]DMPartner, error) {
return s.store.GetDMPartners(ctx, ownedAgents)
}
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
func (s *MessagingService) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
return s.store.GetDMUnreadCounts(ctx, agentName)
+103
View File
@@ -29,6 +29,7 @@ type MessageStore interface {
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)
GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, 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)
@@ -687,6 +688,108 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
return scanMessages(rows)
}
// GetDMPartners returns all unique DM conversation partners for the given human agent,
// with the most recent message preview and unread count. Queries ALL messages (not just
// inbox) so historical conversations always appear. The humanAgent is the primary viewer;
// allAgents includes all owned agents to capture DMs sent to/from any of them.
func (s *SQLiteMessageStore) GetDMPartners(ctx context.Context, allAgents []string) ([]DMPartner, error) {
if len(allAgents) == 0 {
return []DMPartner{}, nil
}
placeholders := make([]string, len(allAgents))
for i := range allAgents {
placeholders[i] = "?"
}
inClause := strings.Join(placeholders, ",")
// For each DM, the "peer" is the other party. When from_agent is owned,
// peer = to_agent. When to_agent is owned, peer = from_agent.
// We want to see ALL conversation partners, including other owned agents
// (e.g., research-mcpproxy sending DMs to algis — both owned by same user).
query := fmt.Sprintf(`
SELECT
peer,
body as last_body,
created_at as last_time,
0 as unread
FROM (
SELECT
CASE
WHEN from_agent IN (%s) THEN to_agent
ELSE from_agent
END as peer,
body,
created_at,
ROW_NUMBER() OVER (
PARTITION BY CASE WHEN from_agent IN (%s) THEN to_agent ELSE from_agent END
ORDER BY created_at DESC
) as rn
FROM messages
WHERE channel_id IS NULL
AND (from_agent IN (%s) OR to_agent IN (%s))
) sub
WHERE rn = 1 AND peer IS NOT NULL AND peer != ''
ORDER BY last_time DESC
LIMIT 50`,
inClause, inClause, inClause, inClause,
)
fullArgs := make([]any, 0, len(allAgents)*4)
for i := 0; i < 4; i++ {
for _, a := range allAgents {
fullArgs = append(fullArgs, a)
}
}
rows, err := s.db.QueryContext(ctx, query, fullArgs...)
if err != nil {
return nil, fmt.Errorf("get dm partners: %w", err)
}
defer rows.Close()
// Track owned agent names for unread counting
ownedSet := make(map[string]bool, len(allAgents))
for _, a := range allAgents {
ownedSet[a] = true
}
var partners []DMPartner
for rows.Next() {
var p DMPartner
var body sql.NullString
if err := rows.Scan(&p.Name, &body, &p.LastTime, &p.Unread); err != nil {
return nil, err
}
if body.Valid && len(body.String) > 80 {
p.LastMessage = body.String[:80] + "..."
} else if body.Valid {
p.LastMessage = body.String
}
partners = append(partners, p)
}
if partners == nil {
partners = []DMPartner{}
}
// Count unread messages per partner (pending DMs TO any owned agent FROM each partner)
for i, p := range partners {
if ownedSet[p.Name] {
continue // Skip unread count for inter-agent DMs
}
var count int
err := s.db.QueryRowContext(ctx,
fmt.Sprintf(`SELECT COUNT(*) FROM messages WHERE channel_id IS NULL AND from_agent = ? AND to_agent IN (%s) AND status IN ('pending', 'processing')`, inClause),
append([]any{p.Name}, fullArgs[:len(allAgents)]...)...,
).Scan(&count)
if err == nil {
partners[i].Unread = count
}
}
return partners, rows.Err()
}
func (s *SQLiteMessageStore) CountPendingDMs(ctx context.Context, agentName string) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx,
+8
View File
@@ -101,3 +101,11 @@ type DMUnreadCount struct {
UnreadCount int `json:"unread_count"`
LastMessageID int64 `json:"last_message_id"`
}
// DMPartner represents a DM conversation partner with summary info.
type DMPartner struct {
Name string `json:"name"`
LastMessage string `json:"last_message"`
LastTime string `json:"last_time"`
Unread int `json:"unread"`
}
+7
View File
@@ -75,6 +75,13 @@ func (r *Reactor) Dispatch(ctx context.Context, event dispatcher.MessageEvent) e
// evaluateTrigger runs the decision chain for a single agent.
func (r *Reactor) evaluateTrigger(ctx context.Context, agentName string, event dispatcher.MessageEvent) error {
// 0. Ignore system messages — stalemate worker notifications, retention warnings,
// and other automated DMs should NOT trigger reactive runs. They're notifications
// meant for the human owner, not actionable work for agents.
if event.FromAgent == "system" {
return nil
}
// 1. Get agent config
agent, err := r.agentStore.GetAgentByName(ctx, agentName)
if err != nil {
+6 -6
View File
@@ -11,30 +11,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.C53XvHet.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BYAQw6Ic.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DkfAG9pH.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Ll39S8uO.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.1r_Gkim3.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.koRx5eH6.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1vc73p1 = {
__sveltekit_v7zte8 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.C53XvHet.js"),
import("/_app/immutable/entry/app.1r_Gkim3.js")
import("/_app/immutable/entry/start.DkfAG9pH.js"),
import("/_app/immutable/entry/app.koRx5eH6.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+5
View File
@@ -83,6 +83,11 @@ export const conversations = {
get: (id: number) => request<{ conversation: any; messages: any[] }>('GET', `/api/conversations/${id}`)
};
// DM Partners
export const dmPartners = {
list: () => request<{ partners: any[] }>('GET', '/api/dm/partners')
};
// Agents
export const agents = {
list: () => request<{ agents: any[] }>('GET', '/api/agents'),
+17 -28
View File
@@ -3,12 +3,13 @@
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { notifications } from '$lib/stores/notifications';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi, dmPartners as dmPartnersApi } from '$lib/api/client';
let { open = false, onclose = () => {} }: { open?: boolean; onclose?: () => void } = $props();
let channelList = $state<any[]>([]);
let agentList = $state<any[]>([]);
let dmPartnerList = $state<any[]>([]);
let deadLetterCount = $state(0);
let channelsExpanded = $state(true);
@@ -25,14 +26,16 @@
async function loadSidebarData() {
try {
const [chRes, agRes, dlRes] = await Promise.all([
const [chRes, agRes, dlRes, dmRes] = await Promise.all([
channelsApi.list(),
agentsApi.list(),
deadLettersApi.count().catch(() => ({ count: 0 }))
deadLettersApi.count().catch(() => ({ count: 0 })),
dmPartnersApi.list().catch(() => ({ partners: [] }))
]);
channelList = chRes.channels ?? [];
agentList = agRes.agents ?? [];
deadLetterCount = dlRes.count ?? 0;
dmPartnerList = dmRes.partners ?? [];
} catch {
// handled
}
@@ -55,15 +58,8 @@
return count > 99 ? '99+' : String(count);
}
// Filter DM list: only show human agents + AI agents with unread DMs.
// This hides agent-to-agent internal conversations from the sidebar.
let dmAgentList = $derived(
agentList.filter(agent => {
if (agent.type !== 'ai') return true;
const unread = $notifications.dms.get(agent.name) ?? 0;
return unread > 0;
})
);
// DM partners list is loaded from the API — shows agents you have
// actual conversations with, ordered by most recent message.
const adminLinks = [
{ href: '/agents', label: 'Agents' },
@@ -219,30 +215,23 @@
</button>
{#if dmsExpanded}
<div class="mt-0.5">
{#if dmAgentList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
{#if dmPartnerList.length === 0}
<p class="px-3 py-1 text-xs text-text-secondary italic">No conversations</p>
{:else}
{#each dmAgentList as agent}
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
{#each dmPartnerList as partner}
<a
href="/dm/{agent.name}"
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
href="/dm/{partner.name}"
class="sidebar-item {isActive('/dm/' + partner.name) ? 'sidebar-item-active' : ''}"
onclick={handleNavClick}
>
<span class="relative flex-shrink-0">
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
{(partner.display_name || partner.name).charAt(0).toUpperCase()}
</span>
<span
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
></span>
</span>
<span class="truncate {dmUnread > 0 ? 'font-bold text-text-primary' : ''}">{agent.display_name || agent.name}</span>
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
{#if dmUnread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(dmUnread)}</span>
{:else if agent.type === 'ai'}
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
<span class="truncate {partner.unread > 0 ? 'font-bold text-text-primary' : ''}">{partner.display_name || partner.name}</span>
{#if partner.unread > 0}
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(partner.unread)}</span>
{/if}
</a>
{/each}