mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(chatd): add tag-based dedup to push notifications (#22669)
This commit is contained in:
@@ -1816,6 +1816,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
|
|||||||
Body: "Agent has finished running.",
|
Body: "Agent has finished running.",
|
||||||
Icon: "/favicon.ico",
|
Icon: "/favicon.ico",
|
||||||
Data: map[string]string{"url": fmt.Sprintf("/agents/%s", chat.ID)},
|
Data: map[string]string{"url": fmt.Sprintf("/agents/%s", chat.ID)},
|
||||||
|
Tag: chat.ID.String(),
|
||||||
}
|
}
|
||||||
if status == database.ChatStatusError {
|
if status == database.ChatStatusError {
|
||||||
pushMsg.Body = "Agent encountered an error."
|
pushMsg.Body = "Agent encountered an error."
|
||||||
|
|||||||
+74
-12
@@ -1533,21 +1533,14 @@ func TestSuccessfulChatSendsWebPushWithNavigationData(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Wait for the chat to complete and return to waiting status.
|
|
||||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
|
||||||
fromDB, dbErr := db.GetChatByID(ctx, chat.ID)
|
|
||||||
if dbErr != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return fromDB.Status == database.ChatStatusWaiting && !fromDB.WorkerID.Valid
|
|
||||||
}, testutil.IntervalFast)
|
|
||||||
|
|
||||||
// Wait for a web push notification to be dispatched. The dispatch
|
// Wait for a web push notification to be dispatched. The dispatch
|
||||||
// happens asynchronously after the DB status is updated, so we need
|
// happens asynchronously after the DB status is updated, so we need
|
||||||
// to poll rather than assert immediately.
|
// to poll rather than assert immediately.
|
||||||
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
|
testutil.Eventually(ctx, t, func(_ context.Context) bool {
|
||||||
return mockPush.dispatchCount.Load() == 1
|
return mockPush.dispatchCount.Load() >= 1
|
||||||
}, testutil.IntervalFast,
|
}, testutil.IntervalFast)
|
||||||
|
|
||||||
|
require.Equal(t, int32(1), mockPush.dispatchCount.Load(),
|
||||||
"expected exactly one web push dispatch for a completed chat")
|
"expected exactly one web push dispatch for a completed chat")
|
||||||
|
|
||||||
// Verify the notification was sent to the correct user.
|
// Verify the notification was sent to the correct user.
|
||||||
@@ -1565,6 +1558,75 @@ func TestSuccessfulChatSendsWebPushWithNavigationData(t *testing.T) {
|
|||||||
"web push Data should contain the chat navigation URL")
|
"web push Data should contain the chat navigation URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSuccessfulChatSendsWebPushWithTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db, ps := dbtestutil.NewDB(t)
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
|
||||||
|
// Set up a mock OpenAI that returns a simple streaming response.
|
||||||
|
openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||||
|
if !req.Stream {
|
||||||
|
return chattest.OpenAINonStreamingResponse("title")
|
||||||
|
}
|
||||||
|
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("done")...)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock webpush dispatcher that captures calls.
|
||||||
|
mockPush := &mockWebpushDispatcher{}
|
||||||
|
|
||||||
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||||
|
server := chatd.New(chatd.Config{
|
||||||
|
Logger: logger,
|
||||||
|
Database: db,
|
||||||
|
ReplicaID: uuid.New(),
|
||||||
|
Pubsub: ps,
|
||||||
|
PendingChatAcquireInterval: 10 * time.Millisecond,
|
||||||
|
InFlightChatStaleAfter: testutil.WaitSuperLong,
|
||||||
|
WebpushDispatcher: mockPush,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, server.Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
user, model := seedChatDependencies(ctx, t, db)
|
||||||
|
setOpenAIProviderBaseURL(ctx, t, db, openAIURL)
|
||||||
|
|
||||||
|
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
|
||||||
|
OwnerID: user.ID,
|
||||||
|
Title: "push-tag-test",
|
||||||
|
ModelConfigID: model.ID,
|
||||||
|
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the web push notification to be dispatched.
|
||||||
|
// We poll dispatchCount rather than DB status because the
|
||||||
|
// push fires after the status update, creating a small race
|
||||||
|
// window.
|
||||||
|
testutil.Eventually(ctx, t, func(_ context.Context) bool {
|
||||||
|
return mockPush.dispatchCount.Load() >= 1
|
||||||
|
}, testutil.IntervalFast)
|
||||||
|
|
||||||
|
require.Equal(t, int32(1), mockPush.dispatchCount.Load(),
|
||||||
|
"expected exactly one web push dispatch for a completed chat")
|
||||||
|
|
||||||
|
// Verify the push notification tag is set to the chat ID for dedup.
|
||||||
|
mockPush.mu.Lock()
|
||||||
|
capturedMsg := mockPush.lastMessage
|
||||||
|
capturedUser := mockPush.lastUserID
|
||||||
|
mockPush.mu.Unlock()
|
||||||
|
|
||||||
|
require.Equal(t, chat.ID.String(), capturedMsg.Tag,
|
||||||
|
"push notification tag should equal the chat ID for deduplication")
|
||||||
|
require.Equal(t, user.ID, capturedUser,
|
||||||
|
"push notification should be dispatched to the chat owner")
|
||||||
|
require.Equal(t, "push-tag-test", capturedMsg.Title,
|
||||||
|
"push notification title should match the chat title")
|
||||||
|
require.Equal(t, "Agent has finished running.", capturedMsg.Body,
|
||||||
|
"push notification body should indicate the agent finished")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T) {
|
func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ type WebpushMessage struct {
|
|||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
Actions []WebpushMessageAction `json:"actions"`
|
Actions []WebpushMessageAction `json:"actions"`
|
||||||
Data map[string]string `json:"data,omitempty"`
|
Data map[string]string `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
@@ -6798,6 +6798,7 @@ export interface WebpushMessage {
|
|||||||
readonly icon: string;
|
readonly icon: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly body: string;
|
readonly body: string;
|
||||||
|
readonly tag?: string;
|
||||||
readonly actions: readonly WebpushMessageAction[];
|
readonly actions: readonly WebpushMessageAction[];
|
||||||
readonly data?: Record<string, string>;
|
readonly data?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ self.addEventListener("push", (event) => {
|
|||||||
body: payload.body || "",
|
body: payload.body || "",
|
||||||
icon: payload.icon || "/favicon.ico",
|
icon: payload.icon || "/favicon.ico",
|
||||||
data: payload.data,
|
data: payload.data,
|
||||||
|
tag: payload.tag,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user