Merge PR #4299: feat(cron): bind scheduled automations to sessions
feat(cron): bind scheduled automations to sessions
This commit is contained in:
@@ -131,6 +131,17 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the automation cascade flag when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes settings updates as a narrow query string", async () => {
|
||||
await updateSettings("tok", {
|
||||
modelPreset: "default",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
import i18n from "@/i18n";
|
||||
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
|
||||
|
||||
const connectSpy = vi.fn();
|
||||
const refreshSpy = vi.fn();
|
||||
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
|
||||
const deleteChatSpy = vi.fn();
|
||||
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
|
||||
const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
@@ -146,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
refresh: refreshSpy,
|
||||
createChat: createChatSpy,
|
||||
forkChat: async () => "fork-chat",
|
||||
deleteChat: async (key: string) => {
|
||||
await deleteChatSpy(key);
|
||||
getSessionAutomations: getSessionAutomationsSpy,
|
||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||
if (options === undefined) await deleteChatSpy(key);
|
||||
else await deleteChatSpy(key, options);
|
||||
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
|
||||
return { deleted: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -210,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
||||
import App from "@/App";
|
||||
|
||||
describe("App layout", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
mockSessions = [];
|
||||
connectSpy.mockClear();
|
||||
updateUrlSpy.mockClear();
|
||||
refreshSpy.mockReset();
|
||||
createChatSpy.mockClear();
|
||||
deleteChatSpy.mockReset();
|
||||
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
runStatusHandlers.clear();
|
||||
@@ -433,6 +440,74 @@ describe("App layout", () => {
|
||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||
}, 15_000);
|
||||
|
||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "First chat",
|
||||
},
|
||||
{
|
||||
key: "websocket:chat-b",
|
||||
channel: "websocket",
|
||||
chatId: "chat-b",
|
||||
createdAt: "2026-04-16T11:00:00Z",
|
||||
updatedAt: "2026-04-16T11:00:00Z",
|
||||
preview: "Second chat",
|
||||
},
|
||||
];
|
||||
getSessionAutomationsSpy.mockResolvedValue([
|
||||
{
|
||||
id: "job-1",
|
||||
name: "Daily repo check",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", every_ms: 86_400_000 },
|
||||
payload: { message: "Check the repo" },
|
||||
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
|
||||
},
|
||||
]);
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(sidebar).getByRole("button", { name: /^First chat$/ }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), {
|
||||
button: 0,
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Daily repo check")).toBeInTheDocument(),
|
||||
);
|
||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
||||
expect(
|
||||
screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
|
||||
deleteAutomations: true,
|
||||
}),
|
||||
);
|
||||
expect(deleteChatSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument();
|
||||
}, 15_000);
|
||||
|
||||
it("keeps the mobile session action menu inside the sidebar sheet", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { setAppLanguage } from "@/i18n";
|
||||
|
||||
function automationJob(nextRunAt = Date.now() + 3_600_000) {
|
||||
function automationJob(
|
||||
nextRunAt = Date.now() + 3_600_000,
|
||||
state: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
id: "job-1",
|
||||
name: "Morning check",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", every_ms: 3_600_000 },
|
||||
payload: { message: "Check the project status" },
|
||||
state: { next_run_at_ms: nextRunAt },
|
||||
state: { next_run_at_ms: nextRunAt, ...state },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
|
||||
}
|
||||
|
||||
describe("SessionInfoPopover", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await setAppLanguage("en");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
|
||||
@@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
|
||||
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a short pending label for deferred automations", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<SessionInfoPopover
|
||||
sessionKey="websocket:chat-1"
|
||||
token="tok"
|
||||
title="Release work"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
|
||||
expect(await screen.findByText("Runs shortly")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes while open so completed one-shot automations disappear", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -180,6 +180,36 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start streaming from completed trailing activity after an answer", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant" as const,
|
||||
content: "Cron test",
|
||||
turnId: "cron:run",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool" as const,
|
||||
kind: "trace" as const,
|
||||
content: "message({})",
|
||||
traces: ["message({})"],
|
||||
turnId: "cron:run",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-cron-done", initialMessages),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
expect(result.current.messages.at(-1)?.kind).toBe("trace");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("useSessions", () => {
|
||||
preview: "Beta",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue(true);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
@@ -115,10 +115,42 @@ describe("useSessions", () => {
|
||||
await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
|
||||
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
|
||||
});
|
||||
|
||||
it("keeps a session when delete is blocked by bound automations", async () => {
|
||||
vi.mocked(api.listSessions).mockResolvedValue([
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: "2026-04-16T10:00:00Z",
|
||||
updatedAt: "2026-04-16T10:00:00Z",
|
||||
preview: "Alpha",
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.deleteSession).mockResolvedValue({
|
||||
deleted: false,
|
||||
blocked_by_automations: true,
|
||||
automations: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessions(), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
|
||||
|
||||
let deleteResult: Awaited<ReturnType<typeof result.current.deleteChat>> | undefined;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteChat("websocket:chat-a");
|
||||
});
|
||||
|
||||
expect(deleteResult?.blocked_by_automations).toBe(true);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
|
||||
});
|
||||
|
||||
it("refreshes sessions when the websocket reports a session update", async () => {
|
||||
vi.mocked(api.listSessions)
|
||||
.mockResolvedValueOnce([
|
||||
@@ -187,7 +219,7 @@ describe("useSessions", () => {
|
||||
await result.current.createChat();
|
||||
});
|
||||
|
||||
expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
|
||||
expect(client.newChat).toHaveBeenCalledWith(60_000, undefined);
|
||||
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
|
||||
|
||||
await act(async () => {
|
||||
@@ -226,7 +258,7 @@ describe("useSessions", () => {
|
||||
await result.current.createChat(workspaceScope);
|
||||
});
|
||||
|
||||
expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
|
||||
expect(client.newChat).toHaveBeenCalledWith(60_000, workspaceScope);
|
||||
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
|
||||
});
|
||||
|
||||
@@ -384,6 +416,40 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the server pending flag for completed tails that still end with trace rows", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "Cron test",
|
||||
turnId: "cron:run",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "message({})",
|
||||
traces: ["message({})"],
|
||||
turnId: "cron:run",
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.messages.at(-1)?.kind).toBe("trace");
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
|
||||
Reference in New Issue
Block a user