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
27 changes: 27 additions & 0 deletions autogpt_platform/backend/backend/api/features/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -2317,6 +2317,33 @@ async def list_all_graphs_execution_schedules(
return await get_scheduler_client().get_graph_execution_schedules(user_id=user_id)


@v1_router.get(
path="/schedules/followups",
summary="List copilot follow-up schedules for a user",
operation_id="listCopilotFollowupSchedules",
tags=["schedules"],
dependencies=[Security(requires_user)],
)
async def list_copilot_turn_schedules(
user_id: Annotated[str, Security(get_user_id)],
) -> list[scheduler.CopilotTurnJobInfo]:
"""Return only copilot-turn schedules for the current user.

Sibling of :func:`list_all_graphs_execution_schedules`; one route per kind
keeps the generated frontend client typed to a single concrete return type
instead of a discriminated union.
"""
schedules = await get_scheduler_client().get_execution_schedules(
user_id=user_id, kind="copilot_turn"
)
# The ``kind="copilot_turn"`` filter is the source of truth — every row in
# the result IS a ``CopilotTurnJobInfo``. The cast narrows the static type
# from the polymorphic ``list[GraphExecutionJobInfo | CopilotTurnJobInfo]``
# without an at-runtime second filter that would silently drop rows if the
# discriminator ever drifted.
return cast(list[scheduler.CopilotTurnJobInfo], schedules)


@v1_router.delete(
path="/schedules/{schedule_id}",
summary="Delete execution schedule",
Expand Down
57 changes: 57 additions & 0 deletions autogpt_platform/backend/backend/api/features/v1_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,3 +1037,60 @@ async def test_upload_file_gcs_not_configured_fallback(test_user_id: str):

# Verify cloud storage methods were NOT called
mock_handler.store_file.assert_not_called()


def test_list_copilot_turn_schedules_filters_to_copilot_kind(
mocker: pytest_mock.MockFixture,
test_user_id: str,
) -> None:
"""GET /schedules/followups returns only CopilotTurnJobInfo items for the user.

The route delegates to ``Scheduler.get_execution_schedules(kind="copilot_turn")``;
we mock the client to make sure (a) the kind filter is forwarded and
(b) any non-copilot rows are dropped from the response.
"""
from backend.executor.scheduler import CopilotTurnJobInfo, GraphExecutionJobInfo

copilot_info = CopilotTurnJobInfo(
id="sched-1",
name="copilot followup",
next_run_time="2026-05-22T10:00:00+00:00",
timezone="UTC",
user_id=test_user_id,
session_id="sess-1",
message="check status",
cron="0 9 * * *",
)
graph_info = GraphExecutionJobInfo(
id="sched-2",
name="graph run",
next_run_time="2026-05-22T11:00:00+00:00",
timezone="UTC",
user_id=test_user_id,
graph_id="g-1",
graph_version=1,
cron="0 10 * * *",
input_data={},
)

mock_client = Mock()
mock_client.get_execution_schedules = AsyncMock(
return_value=[copilot_info, graph_info]
)
mocker.patch(
"backend.api.features.v1.get_scheduler_client",
return_value=mock_client,
)

response = client.get("/schedules/followups")

assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["id"] == "sched-1"
assert body[0]["kind"] == "copilot_turn"
assert body[0]["session_id"] == "sess-1"

mock_client.get_execution_schedules.assert_awaited_once_with(
user_id=test_user_id, kind="copilot_turn"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
getDeleteV1DeleteExecutionScheduleMockHandler,
getDeleteV1DeleteExecutionScheduleMockHandler422,
getListCopilotFollowupSchedulesMockHandler,
} from "@/app/api/__generated__/endpoints/schedules/schedules.msw";
import type { CopilotTurnJobInfo } from "@/app/api/__generated__/models/copilotTurnJobInfo";
import { server } from "@/mocks/mock-server";
import {
fireEvent,
render,
screen,
within,
} from "@/tests/integrations/test-utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import FollowupsPage from "../page";

const toastMock = vi.fn();
vi.mock("@/components/molecules/Toast/use-toast", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/components/molecules/Toast/use-toast")
>();
return {
...actual,
useToast: () => ({ toast: toastMock }),
};
});

function makeFollowup(
overrides: Partial<CopilotTurnJobInfo>,
): CopilotTurnJobInfo {
const runAt = new Date(Date.now() + 60 * 60 * 1000);
return {
id: "sched-1",
name: "copilot-followup",
user_id: "user-1",
session_id: "session-abcdef0123",
message: "Check the build status and report back",
cron: null,
run_at: runAt,
next_run_time: runAt.toISOString(),
kind: "copilot_turn",
timezone: "UTC",
cap_retry_count: 0,
...overrides,
};
}

describe("FollowupsPage", () => {
beforeEach(() => {
toastMock.mockClear();
});

afterEach(() => {
server.resetHandlers();
});

test("renders empty state when no follow-ups exist", async () => {
server.use(getListCopilotFollowupSchedulesMockHandler([]));

render(<FollowupsPage />);

expect(await screen.findByTestId("followups-empty")).toBeDefined();
expect(screen.queryByTestId("followups-list")).toBeNull();
});

test("renders one row per follow-up returned by the API", async () => {
server.use(
getListCopilotFollowupSchedulesMockHandler([
makeFollowup({ id: "f1", message: "First follow-up message" }),
makeFollowup({
id: "f2",
message: "Second follow-up message",
session_id: "session-zzzzzzzzzz",
}),
]),
);

render(<FollowupsPage />);

const rows = await screen.findAllByTestId("followup-row");

Check failure on line 81 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > renders one row per follow-up returned by the API

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-row"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:81:31

Check failure on line 81 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > renders one row per follow-up returned by the API

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-row"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:81:31
expect(rows).toHaveLength(2);
expect(rows[0].getAttribute("data-followup-id")).toBe("f1");
expect(rows[1].getAttribute("data-followup-id")).toBe("f2");
expect(screen.getByText("First follow-up message")).toBeDefined();
expect(screen.getByText("Second follow-up message")).toBeDefined();
});

test("session link points to /copilot with the session id query param", async () => {
server.use(
getListCopilotFollowupSchedulesMockHandler([
makeFollowup({ id: "f1", session_id: "session-abcdef0123" }),
]),
);

render(<FollowupsPage />);

const row = await screen.findByTestId("followup-row");

Check failure on line 98 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > session link points to /copilot with the session id query param

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-row"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:98:30

Check failure on line 98 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > session link points to /copilot with the session id query param

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-row"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:98:30
const link = within(row).getByTestId("followup-open-session");
expect(link.getAttribute("href")).toBe(
"/copilot?sessionId=session-abcdef0123",
);
});

test("Cancel button opens the confirmation dialog and calls the delete API", async () => {
server.use(
getListCopilotFollowupSchedulesMockHandler([makeFollowup({ id: "f1" })]),
getDeleteV1DeleteExecutionScheduleMockHandler(),
);

render(<FollowupsPage />);

const cancelButton = await screen.findByTestId("followup-cancel-button");

Check failure on line 113 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > Cancel button opens the confirmation dialog and calls the delete API

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-cancel-button"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:113:39

Check failure on line 113 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > Cancel button opens the confirmation dialog and calls the delete API

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-cancel-button"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:113:39
fireEvent.click(cancelButton);

const confirmButton = await screen.findByTestId("followup-confirm-cancel");
fireEvent.click(confirmButton);

await vi.waitFor(() => {
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({ title: "Follow-up deleted" }),
);
});
});

test("shows a destructive toast when the delete API fails", async () => {
server.use(
getListCopilotFollowupSchedulesMockHandler([makeFollowup({ id: "f1" })]),
getDeleteV1DeleteExecutionScheduleMockHandler422(),
);

render(<FollowupsPage />);

const cancelButton = await screen.findByTestId("followup-cancel-button");

Check failure on line 134 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > shows a destructive toast when the delete API fails

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-cancel-button"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:134:39

Check failure on line 134 in autogpt_platform/frontend/src/app/(platform)/library/followups/__tests__/main.test.tsx

View workflow job for this annotation

GitHub Actions / integration_test

src/app/(platform)/library/followups/__tests__/main.test.tsx > FollowupsPage > shows a destructive toast when the delete API fails

TestingLibraryElementError: Unable to find an element by: [data-testid="followup-cancel-button"] Ignored nodes: comments, script, style <body> <div /> </body> Ignored nodes: comments, script, style <body> <div /> </body> ❯ waitForWrapper node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/.pnpm/@testing-library+dom@10.4.1/node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ src/app/(platform)/library/followups/__tests__/main.test.tsx:134:39
fireEvent.click(cancelButton);

const confirmButton = await screen.findByTestId("followup-confirm-cancel");
fireEvent.click(confirmButton);

await vi.waitFor(() => {
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to delete follow-up",
variant: "destructive",
}),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Text } from "@/components/atoms/Text/Text";
import { ClockClockwiseIcon } from "@phosphor-icons/react";

export function EmptyFollowups() {
return (
<div
className="flex flex-col items-center justify-center gap-3 rounded-large border border-dashed border-zinc-200 px-6 py-16 text-center"
data-testid="followups-empty"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-50">
<ClockClockwiseIcon size={24} className="text-yellow-700" />
</div>
<Text variant="h4" className="text-zinc-900">
No copilot follow-ups
</Text>
<Text variant="body" className="max-w-md !text-zinc-500">
Ask your copilot to schedule something for later and it will show up
here so you can edit or cancel it.
</Text>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import type { CopilotTurnJobInfo } from "@/app/api/__generated__/models/copilotTurnJobInfo";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { ChatCircleTextIcon, TrashIcon } from "@phosphor-icons/react";
import Link from "next/link";
import { useFollowupListItem } from "./useFollowupListItem";

interface Props {
followup: CopilotTurnJobInfo;
}

export function FollowupListItem({ followup }: Props) {
const {
sessionHref,
nextRunLabel,
nextRunTitle,
recurrenceLabel,
messagePreview,
isDeleteOpen,
openDelete,
closeDelete,
isDeleting,
handleDelete,
} = useFollowupListItem({ followup });

return (
<div
className="flex w-full flex-col gap-3 rounded-large border border-zinc-200 bg-white p-4 sm:flex-row sm:items-center sm:justify-between"
data-testid="followup-row"
data-followup-id={followup.id}
>
<Link
href={sessionHref}
className="flex min-w-0 flex-1 items-start gap-3 hover:opacity-80"
data-testid="followup-open-session"
>
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-large border border-slate-50 bg-yellow-50">
<ChatCircleTextIcon
size={18}
className="text-yellow-700"
weight="bold"
/>
</div>
<div className="flex min-w-0 flex-col gap-1">
<Text
variant="body-medium"
className="block w-full truncate text-ellipsis"
>
{messagePreview}
</Text>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<Text
variant="small"
className="!text-zinc-500"
title={nextRunTitle}
>
{nextRunLabel}
</Text>
<span className="text-zinc-300">•</span>
<Text variant="small" className="!text-zinc-500">
{recurrenceLabel}
</Text>
<span className="text-zinc-300">•</span>
<Text variant="small" className="!text-zinc-400">
Session {followup.session_id.slice(0, 8)}
</Text>
</div>
</div>
</Link>

<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="secondary"
size="small"
onClick={openDelete}
data-testid="followup-cancel-button"
aria-label="Delete follow-up"
>
<TrashIcon className="mr-1 h-4 w-4" />
Delete
</Button>
</div>
Comment thread
majdyz marked this conversation as resolved.

<Dialog
controlled={{ isOpen: isDeleteOpen, set: closeDelete }}
styling={{ maxWidth: "32rem" }}
title="Delete follow-up"
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<Text variant="large">
Delete this scheduled follow-up? The copilot will not send the
message and you can recreate it from chat if needed.
</Text>
<Dialog.Footer>
<Button
variant="secondary"
disabled={isDeleting}
onClick={() => closeDelete(false)}
>
Keep it
</Button>
<Button
variant="destructive"
onClick={handleDelete}
loading={isDeleting}
data-testid="followup-confirm-cancel"
>
Yes, delete
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
</div>
);
}
Loading
Loading