Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions autogpt_platform/frontend/public/push-sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
var NOTIFICATION_MAP = {
copilot_completion: {
session_completed: {
title: "AutoPilot is ready",
body: "A response is waiting for you.",
title: "AutoGPT",
body: "Task completed",
url: "/copilot",
},
session_failed: {
title: "AutoPilot session failed",
body: "Something went wrong with your session.",
title: "AutoGPT",
body: "Task failed",
url: "/copilot",
},
},
Expand Down Expand Up @@ -152,8 +152,8 @@ self.addEventListener("push", function (event) {

var options = {
body: config.body,
icon: "/favicon.ico",
badge: "/favicon.ico",
icon: "/notification-icon-192.png",
badge: "/notification-icon-192.png",
tag: tag,
data: Object.assign({ url: targetUrl }, data),
renotify: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { UIMessage } from "ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IMPERSONATION_HEADER_NAME } from "@/lib/constants";
import {
COPILOT_COMPLETION_NOTIFICATION,
ORIGINAL_TITLE,
deduplicateMessages,
extractSendMessageText,
Expand Down Expand Up @@ -54,6 +55,16 @@ describe("formatNotificationTitle", () => {
});
});

describe("COPILOT_COMPLETION_NOTIFICATION", () => {
it("matches the copy hardcoded in public/push-sw.js", () => {
expect(COPILOT_COMPLETION_NOTIFICATION).toEqual({
title: "AutoGPT",
body: "Task completed",
icon: "/notification-icon-192.png",
});
});
});

describe("parseSessionIDs", () => {
it("returns empty set for null", () => {
expect(parseSessionIDs(null)).toEqual(new Set());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";

import { COPILOT_COMPLETION_NOTIFICATION } from "../helpers";

type WSHandler = (notification: unknown) => void;
let capturedHandler: WSHandler | null = null;

vi.mock("@/lib/autogpt-server-api/context", () => ({
useBackendAPI: () => ({
onWebSocketMessage: (_event: string, handler: WSHandler) => {
capturedHandler = handler;
return () => {};
},
}),
BackendAPIProvider: ({ children }: { children: ReactNode }) => children,
}));

import { useCopilotNotifications } from "../useCopilotNotifications";
import { useCopilotUIStore } from "../store";

const NotificationCtor = vi.fn();
class FakeNotification {
onclick: (() => void) | null = null;
close = vi.fn();
constructor(title: string, opts: { body: string; icon: string }) {
NotificationCtor(title, opts);
}
}

function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

beforeEach(() => {
capturedHandler = null;
NotificationCtor.mockClear();

(
FakeNotification as unknown as { permission: NotificationPermission }
).permission = "granted";
vi.stubGlobal("Notification", FakeNotification);

useCopilotUIStore.setState({
completedSessionIDs: new Set(),
isNotificationsEnabled: true,
isSoundEnabled: false,
});

Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe("useCopilotNotifications — OS notification dispatch", () => {
it("fires a browser notification with the AutoGPT/Task completed copy and 192px icon when a non-active session completes off-screen", async () => {
renderHook(() => useCopilotNotifications(null), { wrapper: makeWrapper() });
expect(capturedHandler).not.toBeNull();

act(() => {
capturedHandler!({
type: "copilot_completion",
event: "session_completed",
session_id: "sess-1",
});
});

await waitFor(() => {
expect(NotificationCtor).toHaveBeenCalledTimes(1);
});
expect(NotificationCtor).toHaveBeenCalledWith(
COPILOT_COMPLETION_NOTIFICATION.title,
{
body: COPILOT_COMPLETION_NOTIFICATION.body,
icon: COPILOT_COMPLETION_NOTIFICATION.icon,
},
);
});

it("does not fire a notification for unrelated event types", async () => {
renderHook(() => useCopilotNotifications(null), { wrapper: makeWrapper() });
expect(capturedHandler).not.toBeNull();

act(() => {
capturedHandler!({
type: "copilot_completion",
event: "something_else",
session_id: "sess-2",
});
});

await waitFor(() => {
expect(NotificationCtor).not.toHaveBeenCalled();
});
});
});
13 changes: 13 additions & 0 deletions autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import { TOOL_PART_PREFIX } from "./components/JobStatsBar/constants";

export const ORIGINAL_TITLE = "AutoGPT";

/**
* Title/body/icon for the OS-level notification fired when a copilot session
* completes. Kept in sync with the same copy hardcoded in `public/push-sw.js`
* (NOTIFICATION_MAP.copilot_completion.session_completed) — the SW file is
* plain JS served from /public and can't import from this module, so the two
* sources are matched by test rather than by reference.
*/
export const COPILOT_COMPLETION_NOTIFICATION = {
title: "AutoGPT",
body: "Task completed",
icon: "/notification-icon-192.png",
} as const;

/**
* Returns HTTP headers required for direct backend requests from copilot:
* - Authorization Bearer token (JWT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Key } from "@/services/storage/local-storage";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import {
COPILOT_COMPLETION_NOTIFICATION,
ORIGINAL_TITLE,
formatNotificationTitle,
parseSessionIDs,
Expand Down Expand Up @@ -115,9 +116,9 @@ export function useCopilotNotifications(activeSessionID: string | null) {
Notification.permission === "granted" &&
isUserAway
) {
showBrowserNotification("AutoPilot is ready", {
body: "A response is waiting for you.",
icon: "/favicon.ico",
showBrowserNotification(COPILOT_COMPLETION_NOTIFICATION.title, {
body: COPILOT_COMPLETION_NOTIFICATION.body,
icon: COPILOT_COMPLETION_NOTIFICATION.icon,
sessionID,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ describe("push-sw getNotificationConfig", () => {
type: "copilot_completion",
event: "session_completed",
});
expect(config.title).toBe("AutoPilot is ready");
expect(config.title).toBe("AutoGPT");
expect(config.body).toBe("Task completed");
expect(config.url).toBe("/copilot");
});

Expand All @@ -64,7 +65,8 @@ describe("push-sw getNotificationConfig", () => {
type: "copilot_completion",
event: "session_failed",
});
expect(config.title).toBe("AutoPilot session failed");
expect(config.title).toBe("AutoGPT");
expect(config.body).toBe("Task failed");
});

it("falls back to generic notification for unknown type", () => {
Expand Down
Loading