fix: notification bugs — human-agent-only counts, Svelte 5 lifecycle, API response parsing
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

- Use human agent's perspective only for unread counts (avoids system agent inflation)
- Replace onDestroy + get() with $effect cleanup in channel/DM pages (Svelte 5 compat)
- Fix notification store to parse array-of-objects API response format
- Add last_read_message_id to DM messages endpoint
- Fix test agent type seeding for GetHumanAgentForUser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-15 09:56:29 +02:00
co-authored by Claude Opus 4.6
parent eec06f5b5f
commit 5143e32f83
7 changed files with 88 additions and 136 deletions
+6 -2
View File
@@ -513,9 +513,13 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
return
}
// Include last_read_message_id for the human agent's DM with the peer
lastRead, _ := h.msgService.GetLastReadForDM(r.Context(), agentNames, peerAgent)
writeJSON(w, http.StatusOK, map[string]any{
"messages": msgs,
"total": len(msgs),
"messages": msgs,
"total": len(msgs),
"last_read_message_id": lastRead,
})
}
+40 -88
View File
@@ -50,14 +50,10 @@ func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Reque
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
// Use only the human agent's perspective for notification counts.
// This avoids system/AI agents inflating unread counts.
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil || humanAgent == nil {
writeJSON(w, http.StatusOK, map[string]any{
"channels": []channelUnread{},
"dms": []dmUnread{},
@@ -66,63 +62,34 @@ func (h *NotificationsHandler) UnreadCounts(w http.ResponseWriter, r *http.Reque
return
}
// Aggregate channel summaries across all owned agents
channelMap := make(map[string]*channelUnread)
for _, agent := range ownedAgents {
if h.channelService == nil {
break
}
summaries, err := h.channelService.GetChannelSummaries(r.Context(), agent.Name)
// Channel summaries for the human agent
channelsList := []channelUnread{}
if h.channelService != nil {
summaries, err := h.channelService.GetChannelSummaries(r.Context(), humanAgent.Name)
if err != nil {
h.logger.Error("get channel summaries failed", "agent", agent.Name, "error", err)
continue
}
for _, cs := range summaries {
existing, ok := channelMap[cs.Name]
if !ok {
channelMap[cs.Name] = &channelUnread{
h.logger.Error("get channel summaries failed", "agent", humanAgent.Name, "error", err)
} else {
for _, cs := range summaries {
channelsList = append(channelsList, channelUnread{
Name: cs.Name,
UnreadCount: cs.UnreadCount,
LastMessageID: cs.LastMessageID,
}
} else {
// Take the max unread count (different agents may see different counts)
if cs.UnreadCount > existing.UnreadCount {
existing.UnreadCount = cs.UnreadCount
}
if cs.LastMessageID > existing.LastMessageID {
existing.LastMessageID = cs.LastMessageID
}
})
}
}
}
channelsList := make([]channelUnread, 0, len(channelMap))
for _, cu := range channelMap {
channelsList = append(channelsList, *cu)
}
// Aggregate DM unread counts across all owned agents
// DM unread counts for the human agent
dmMap := make(map[string]*dmUnread)
for _, agent := range ownedAgents {
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), agent.Name)
if err != nil {
h.logger.Error("get dm unread counts failed", "agent", agent.Name, "error", err)
continue
}
counts, err := h.msgService.GetDMUnreadCounts(r.Context(), humanAgent.Name)
if err != nil {
h.logger.Error("get dm unread counts failed", "agent", humanAgent.Name, "error", err)
} else {
for _, dc := range counts {
existing, ok := dmMap[dc.Agent]
if !ok {
dmMap[dc.Agent] = &dmUnread{
Agent: dc.Agent,
UnreadCount: dc.UnreadCount,
LastMessageID: dc.LastMessageID,
}
} else {
existing.UnreadCount += dc.UnreadCount
if dc.LastMessageID > existing.LastMessageID {
existing.LastMessageID = dc.LastMessageID
}
dmMap[dc.Agent] = &dmUnread{
Agent: dc.Agent,
UnreadCount: dc.UnreadCount,
LastMessageID: dc.LastMessageID,
}
}
}
@@ -176,22 +143,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
humanAgent, err := h.agentService.GetHumanAgentForUser(r.Context(), ownerID)
if err != nil || humanAgent == nil {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No human agent found"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered"))
return
}
agentNames := make([]string, len(ownedAgents))
for i, a := range ownedAgents {
agentNames[i] = a.Name
}
agentNames := []string{humanAgent.Name}
if req.Type == "channel" {
if h.channelService == nil {
@@ -205,7 +163,6 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
return
}
// Get conversation IDs for messages in this channel up to the given message ID
convIDs, err := h.msgService.GetConversationIDsForChannel(r.Context(), ch.ID, req.LastMessageID)
if err != nil {
h.logger.Error("get conversation ids failed", "error", err)
@@ -213,16 +170,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
return
}
// Update inbox state for all owned agents on all relevant conversations
for _, agentName := range agentNames {
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), agentName, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", agentName,
"conversation_id", convID,
"error", err,
)
}
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", humanAgent.Name,
"conversation_id", convID,
"error", err,
)
}
}
} else {
@@ -234,15 +188,13 @@ func (h *NotificationsHandler) MarkRead(w http.ResponseWriter, r *http.Request)
return
}
for _, agentName := range agentNames {
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), agentName, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", agentName,
"conversation_id", convID,
"error", err,
)
}
for _, convID := range convIDs {
if err := h.msgService.UpdateInboxState(r.Context(), humanAgent.Name, convID, req.LastMessageID); err != nil {
h.logger.Error("update inbox state failed",
"agent", humanAgent.Name,
"conversation_id", convID,
"error", err,
)
}
}
}
+2 -2
View File
@@ -28,8 +28,8 @@ func setupNotificationsRouter(t *testing.T) (chi.Router, *messaging.MessagingSer
channelStore := channels.NewSQLiteChannelStore(db)
channelService := channels.NewService(channelStore, msgService, nil)
// Seed agents
seedTestAgent(t, db, "human-agent", 1)
// Seed agents — human-agent must be type 'human' for GetHumanAgentForUser
seedTestAgentWithType(t, db, "human-agent", "human", 1)
seedTestAgent(t, db, "bot-alice", 2)
seedTestAgent(t, db, "bot-bob", 2)
+9 -9
View File
@@ -8,29 +8,29 @@
<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.ClBvSxrP.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BOwfncGc.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DqlV_t7d.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.DpHKCwmv.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BRBotovi.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/VWBPw6n7.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BaoHVn2U.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.D00BGz8q.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.B_lhmyMs.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_ra3pf6 = {
__sveltekit_vhg0t8 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.ClBvSxrP.js"),
import("/_app/immutable/entry/app.D00BGz8q.js")
import("/_app/immutable/entry/start.DpHKCwmv.js"),
import("/_app/immutable/entry/app.B_lhmyMs.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
+14 -19
View File
@@ -1,4 +1,4 @@
import { writable, get } from 'svelte/store';
import { writable } from 'svelte/store';
export interface UnreadCounts {
channels: Map<string, number>;
@@ -28,9 +28,19 @@ function createNotificationStore() {
try {
const res = await fetch('/api/notifications/unread', { credentials: 'same-origin' });
if (!res.ok) return;
const data: { channels?: Record<string, number>; dms?: Record<string, number> } = await res.json();
const channels = new Map(Object.entries(data.channels ?? {}));
const dms = new Map(Object.entries(data.dms ?? {}));
const data = await res.json();
const channels = new Map<string, number>();
const dms = new Map<string, number>();
if (Array.isArray(data.channels)) {
for (const ch of data.channels) {
if (ch.unread_count > 0) channels.set(ch.name, ch.unread_count);
}
}
if (Array.isArray(data.dms)) {
for (const dm of data.dms) {
if (dm.unread_count > 0) dms.set(dm.agent, dm.unread_count);
}
}
const counts: UnreadCounts = { channels, dms, totalUnread: 0 };
counts.totalUnread = recalcTotal(counts);
set(counts);
@@ -39,21 +49,6 @@ function createNotificationStore() {
}
},
/** Get unread count for a channel */
channelUnread(name: string): number {
return get({ subscribe }).channels.get(name) ?? 0;
},
/** Get unread count for a DM agent */
dmUnread(name: string): number {
return get({ subscribe }).dms.get(name) ?? 0;
},
/** Get total unread count */
get totalUnread(): number {
return get({ subscribe }).totalUnread;
},
/** Increment unread count for a channel or DM */
incrementUnread(type: 'channel' | 'dm', target: string) {
update((counts) => {
+9 -8
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { onDestroy } from 'svelte';
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
@@ -61,12 +60,13 @@
function startMarkReadTimer() {
clearMarkReadTimer();
const unread = notifications.channelUnread(channelName);
if (unread > 0 && messageList.length > 0) {
if (lastReadMessageId !== null && messageList.length > 0) {
const lastMsgId = messageList[messageList.length - 1]?.id;
markReadTimer = setTimeout(() => {
notifications.markAsRead('channel', channelName, lastMsgId);
}, 2000);
if (lastMsgId > lastReadMessageId) {
markReadTimer = setTimeout(() => {
notifications.markAsRead('channel', channelName, lastMsgId);
}, 2000);
}
}
}
@@ -77,8 +77,9 @@
}
}
onDestroy(() => {
clearMarkReadTimer();
// Cleanup timer on component destroy
$effect(() => {
return () => clearMarkReadTimer();
});
function scrollToBottom() {
+8 -8
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { onDestroy } from 'svelte';
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
@@ -53,12 +52,13 @@
function startMarkReadTimer() {
clearMarkReadTimer();
const unread = notifications.dmUnread(peerAgent);
if (unread > 0 && messageList.length > 0) {
if (lastReadMessageId !== null && messageList.length > 0) {
const lastMsgId = messageList[messageList.length - 1]?.id;
markReadTimer = setTimeout(() => {
notifications.markAsRead('dm', peerAgent, lastMsgId);
}, 2000);
if (lastMsgId > lastReadMessageId) {
markReadTimer = setTimeout(() => {
notifications.markAsRead('dm', peerAgent, lastMsgId);
}, 2000);
}
}
}
@@ -69,8 +69,8 @@
}
}
onDestroy(() => {
clearMarkReadTimer();
$effect(() => {
return () => clearMarkReadTimer();
});
function scrollToBottom() {