Файли
openidea/src/features/agents/unread.test.tsx
T
matthiasdemauroyтаmatthiasdemauroy 39fe96bd01 feat(agents): show Claude usage limits and session cost in the agent task header (#1041)
Claude Code reports the account's 5-hour and weekly usage limits (rate_limit_event) and the session cost (result.total_cost_usd); only the limit status was used. The limits now show as meters next to the context meter in the task header, with the reset time in the tooltip, and Settings › Tools › Agents › Chat turns each meter (context, work time, limits, cost) on or off. Limits are per provider and kept in memory; cost is off by default.

Closes #1057

Co-authored-by: matthiasdemauroy <[email protected]>
2026-11-04 00:28:23 +08:00

347 рядки
13 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import { AgentsView } from "./AgentsView";
import { AgentsStatus } from "./AgentsStatus";
import { activate } from "./index";
import * as actions from "@/platform/actions";
import { TASK_DRAG } from "./model";
import {
connect,
disconnect,
dockTask,
markVisible,
openHome,
openTask,
setSessionLocation,
undockTask,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
active: null as EditorInput | null,
tabs: [] as OpenEditorTab[],
channel: null as { onmessage: (event: AgentEvent) => void } | null,
tasks: [] as AgentTask[],
notify: vi.fn(),
openInput: vi.fn(),
closeInput: vi.fn(),
showToolWindow: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage = () => {};
},
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: {
agentSubscribe: async (channel: typeof backend.channel) => {
backend.channel = channel;
return { status: "ok", data: backend.tasks };
},
agentProviders: async () => ({ status: "ok", data: [] }),
agentChanges: async () => ({ status: "ok", data: [] }),
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({
openInput: backend.openInput,
closeInput: backend.closeInput,
activeInput: () => backend.active,
openTabs: () => backend.tabs,
}),
toolWindowService: () => ({ show: backend.showToolWindow }),
notifications: () => ({ notify: backend.notify }),
}));
const task = (id: string, status: AgentTask["status"] = "running"): AgentTask => ({
id,
title: id,
status,
provider: "codex",
isolation: "local",
model: null,
cwd: "/project",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
});
const emit = (event: AgentEvent) => act(() => backend.channel!.onmessage(event));
const reply = (id = "one", text = "New reply") =>
emit({ type: "entry", task: id, subagent: null, index: 0, entry: { type: "agent", text } });
const visible: Array<() => void> = [];
const show = (id = "one") => {
let hide!: () => void;
act(() => {
hide = markVisible(id);
});
visible.push(hide);
return () => visible.splice(visible.indexOf(hide), 1)[0]!();
};
beforeEach(async () => {
vi.clearAllMocks();
vi.spyOn(document, "hasFocus").mockReturnValue(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
disconnect();
backend.active = null;
backend.tabs = [];
useAgents.setState({ sessionLocation: "editor" });
localStorage.clear();
backend.tasks = [task("one"), task("two", "ready")];
await connect();
});
afterEach(() => {
visible.splice(0).forEach((hide) => hide());
disconnect();
vi.restoreAllMocks();
});
describe("agent unread activity", () => {
it("does not mark historical tasks unread, but tracks replies even without loaded entries", () => {
expect(useAgents.getState().unread.size).toBe(0);
reply();
reply("one", "New reply, streamed further");
reply("two");
expect([...useAgents.getState().unread]).toEqual(["one", "two"]);
expect(useAgents.getState().entries.size).toBe(0);
expect(backend.notify).not.toHaveBeenCalled();
});
it.each(["ready", "needsInput", "failed", "limited"] as const)("marks background %s transitions unread", (status) => {
emit({ type: "task", task: task("one", status) });
expect(useAgents.getState().unread.has("one")).toBe(true);
expect(backend.notify).toHaveBeenCalledTimes(1);
show();
emit({ type: "task", task: task("one", status) });
expect(useAgents.getState().unread.size).toBe(0);
expect(backend.notify).toHaveBeenCalledTimes(1);
});
it("keeps the latest usage limits per provider without marking anything unread", () => {
const fiveHour = (usedPercent: number) => [{ window: "five_hour", usedPercent, resetsMs: null }];
emit({ type: "limits", provider: "claude", limits: fiveHour(10) });
emit({ type: "limits", provider: "codex", limits: fiveHour(50) });
emit({ type: "limits", provider: "claude", limits: fiveHour(12) });
expect(useAgents.getState().limits.get("claude")).toEqual(fiveHour(12));
expect(useAgents.getState().limits.get("codex")).toEqual(fiveHour(50));
expect(useAgents.getState().unread.size).toBe(0);
});
it("ignores user messages, thoughts, empty replies and subagent streaming", () => {
const entries: AgentEntry[] = [
{ type: "user", text: "Do this", attachments: [] },
{ type: "thought", text: "Thinking" },
{ type: "agent", text: " " },
];
for (const entry of entries) emit({ type: "entry", task: "one", subagent: null, index: 0, entry });
emit({ type: "entry", task: "one", subagent: "child", index: 0, entry: { type: "agent", text: "Working" } });
emit({ type: "task", task: { ...task("one"), title: "Renamed" } });
expect(useAgents.getState().unread.size).toBe(0);
});
it("clears only the viewed task and keeps reading while either split shows it", () => {
reply();
reply("two");
const hide = show();
show();
hide();
reply();
emit({ type: "task", task: task("one", "ready") });
expect([...useAgents.getState().unread]).toEqual(["two"]);
expect(backend.notify).not.toHaveBeenCalled();
});
it("keeps a visible session unread while the window is unfocused, then clears on focus", () => {
vi.mocked(document.hasFocus).mockReturnValue(false);
reply();
show();
expect(useAgents.getState().unread.has("one")).toBe(true);
vi.mocked(document.hasFocus).mockReturnValue(true);
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.size).toBe(0);
reply();
expect(useAgents.getState().unread.size).toBe(0);
});
it("does not read hidden windows and removes focus listeners when the session closes", () => {
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
const hide = show();
reply();
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.has("one")).toBe(true);
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
fireEvent(document, new Event("visibilitychange"));
expect(useAgents.getState().unread.size).toBe(0);
hide();
reply();
fireEvent(window, new Event("focus"));
expect(useAgents.getState().unread.has("one")).toBe(true);
});
it("cleans up archived and deleted tasks, ignores late replies, and resets on project switch", async () => {
reply();
reply("two");
emit({ type: "task", task: task("one", "archived") });
reply();
expect([...useAgents.getState().unread]).toEqual(["two"]);
emit({ type: "removed", id: "two" });
reply("two");
expect(useAgents.getState().unread.size).toBe(0);
emit({ type: "task", task: task("one") });
reply();
const oldChannel = backend.channel!;
disconnect();
backend.tasks = [task("other")];
await connect();
oldChannel.onmessage({ type: "task", task: task("one", "ready") });
expect(useAgents.getState().unread.size).toBe(0);
expect([...useAgents.getState().tasks.keys()]).toEqual(["other"]);
});
});
describe("agent unread indicators", () => {
it("shows a dot on the task and a clickable status count even when all tasks are idle", () => {
emit({ type: "task", task: task("one", "ready") });
render(
<>
<AgentsView />
<AgentsStatus />
</>,
);
expect(screen.getByRole("img", { name: "Unread" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "1 unread" }));
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ data: { task: "one", title: "one" } }), {
focus: true,
});
show();
expect(screen.queryByRole("img", { name: "Unread" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /unread/ })).not.toBeInTheDocument();
});
it("counts unread and needs-input tasks once each in the sidebar badge", () => {
const dispose = activate();
try {
const useBadge = useContributions.getState().toolWindows.get("agents")!.useBadge!;
function Badge() {
return <span data-testid="badge">{useBadge()}</span>;
}
act(() =>
useAgents.setState({
tasks: new Map([task("one", "needsInput"), task("two", "ready")].map((t) => [t.id, t])),
unread: new Set(["one", "two"]),
}),
);
render(<Badge />);
expect(screen.getByTestId("badge")).toHaveTextContent("2");
show("two");
expect(screen.getByTestId("badge")).toHaveTextContent("1");
show("one");
expect(screen.getByTestId("badge")).toHaveTextContent("1");
} finally {
act(dispose);
}
});
});
describe("docking a conversation in the tool window", () => {
it("shows the task in the tool window, closing its editor tab, and undocks back to one", async () => {
act(() => dockTask("one"));
expect(useAgents.getState().docked).toBe("one");
expect(backend.showToolWindow).toHaveBeenCalledWith("agents", true);
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
act(() => undockTask());
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
{ focus: true },
);
});
it("undocks a removed task", async () => {
act(() => dockTask("one"));
emit({ type: "removed", id: "one" });
expect(useAgents.getState().docked).toBeNull();
});
it("drops a dragged task into the editor: undocking when docked, opening a tab otherwise", () => {
const dispose = activate();
const execute = vi.spyOn(actions, "executeAction");
act(() => dropOnEditor({ type: TASK_DRAG, payload: "two" }));
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "two");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:two" }),
{ focus: true },
);
backend.openInput.mockClear();
act(() => dockTask("one"));
act(() => dropOnEditor({ type: TASK_DRAG, payload: "one" }));
expect(execute).toHaveBeenCalledWith("agents.openTaskInEditor", "one");
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenCalledWith(
expect.objectContaining({ kind: "agent-session", key: "agent:one" }),
{ focus: true },
);
dispose();
});
it("moves the active session and applies the header choice to every subsequent session", () => {
backend.active = { kind: "agent-session", key: "agent:two", data: { task: "two" } };
backend.tabs = ["one", "two"].map((id) => ({
input: { kind: "agent-session", key: `agent:${id}`, data: { task: id } },
title: id,
icon: "toolwindow-agents",
lastActive: 0,
}));
act(() => setSessionLocation("toolWindow"));
expect(backend.closeInput).toHaveBeenCalledWith("agent:one");
expect(backend.closeInput).toHaveBeenCalledWith("agent:two");
expect(useAgents.getState().docked).toBe("two");
expect(backend.openInput).not.toHaveBeenCalled();
act(() => openTask("one"));
expect(useAgents.getState().docked).toBe("one");
expect(backend.openInput).not.toHaveBeenCalled();
expect(JSON.parse(localStorage.getItem("openidea.agents.sessionLocation.v1")!)).toBe("toolWindow");
act(() => setSessionLocation("editor"));
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:one" }), { focus: true });
act(() => openTask("two"));
expect(backend.openInput).toHaveBeenLastCalledWith(expect.objectContaining({ key: "agent:two" }), { focus: true });
});
it("opens new tasks in the selected location and moves an unfinished draft with the layout", () => {
act(() => setSessionLocation("toolWindow"));
backend.openInput.mockClear();
act(() => openHome("Keep this draft"));
expect(useAgents.getState().homeOpen).toBe(true);
expect(useAgents.getState().docked).toBeNull();
expect(backend.openInput).not.toHaveBeenCalled();
act(() => setSessionLocation("editor"));
expect(useAgents.getState().homeOpen).toBe(false);
expect(backend.openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: "agent-home" }), { focus: true });
expect(useAgents.getState().composers.get("home")?.text).toBe("Keep this draft");
});
it("drops the dock when the project changes", async () => {
act(() => dockTask("one"));
disconnect();
expect(useAgents.getState().docked).toBeNull();
expect(useAgents.getState().sessionLocation).toBe("toolWindow");
});
});