3 Commits
Author SHA1 Message Date
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 196 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)
+89
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,94 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
return scanMessages(rows)
}
// GetDMPartners returns all unique DM conversation partners for the owned agents,
// with the most recent message preview and unread count. This queries ALL messages
// (not just inbox) to show historical conversations.
func (s *SQLiteMessageStore) GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, error) {
if len(agents) == 0 {
return []DMPartner{}, nil
}
placeholders := make([]string, len(agents))
args := make([]any, 0, len(agents)*2)
for i, a := range agents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Duplicate args for the second IN clause
for _, a := range agents {
args = append(args, a)
}
// Find all DM partners with latest message and unread count
query := fmt.Sprintf(`
SELECT
peer,
last_body,
last_time,
COALESCE(SUM(is_unread), 0) as unread
FROM (
SELECT
CASE
WHEN from_agent IN (%s) THEN to_agent
ELSE from_agent
END as peer,
body as last_body,
created_at as last_time,
CASE
WHEN to_agent IN (%s) AND status IN ('pending', 'processing') THEN 1
ELSE 0
END as is_unread,
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 != '' AND peer NOT IN (%s)
GROUP BY peer
ORDER BY last_time DESC
LIMIT 50`,
inClause, inClause, inClause, inClause, inClause, inClause,
)
// Need 6 copies of the args for the 6 IN clauses
fullArgs := make([]any, 0, len(agents)*6)
for i := 0; i < 6; i++ {
for _, a := range agents {
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()
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{}
}
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}