mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453df213f2 |
@@ -21,6 +21,7 @@ import {
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
recordContextWindowFrames,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
@@ -299,9 +300,10 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const slowSource = gate();
|
||||
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
|
||||
const closedEarly = await hoverContextWindowMeter(page);
|
||||
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(usageCard(closedEarly, "claude:work").getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
await usage.resumeAgent(agent);
|
||||
const resumed = gate();
|
||||
const finished = gate();
|
||||
usage.answerNext({
|
||||
@@ -385,6 +387,58 @@ for (const theme of ["light", "dark"] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test("an open with loaded usage paints it in its first frame and never moves", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
usage.answerNext([onWorkLogin(claude!)]);
|
||||
const loaded = await hoverContextWindowMeter(page);
|
||||
await expect(usageCard(loaded, "claude:work")).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
// The reopen's own request stays unanswered until the card has been on screen a while.
|
||||
const refetch = gate();
|
||||
usage.answerNext({ stream: [refetch.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const reopened = await hoverContextWindowMeter(page);
|
||||
await page.waitForTimeout(300);
|
||||
refetch.open();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(usageCard(reopened, "claude:work")).toBeVisible();
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.filter((frame) => frame.loading)).toEqual([]);
|
||||
expect(frames.filter((frame) => frame.usageCards !== 1)).toEqual([]);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("usage arriving after the card opens never moves its bottom edge", async ({ page, agent }) => {
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
|
||||
const arrival = gate();
|
||||
usage.answerNext({ stream: [arrival.promise, onWorkLogin(claude!)] });
|
||||
const recording = await recordContextWindowFrames(page);
|
||||
const card = await hoverContextWindowMeter(page);
|
||||
await expect(card.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
arrival.open();
|
||||
await expect(usageCard(card, "claude:work")).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
const frames = (await recording.stop()).filter((frame) => frame.mounted);
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
expect(frames.some((frame) => frame.loading)).toBe(true);
|
||||
expect(frames.at(-1)?.usageCards).toBe(1);
|
||||
expect(new Set(frames.map((frame) => frame.bottom)).size).toBe(1);
|
||||
});
|
||||
|
||||
test("context details support keyboard entry and dismiss when the viewport moves", async ({
|
||||
page,
|
||||
agent,
|
||||
|
||||
@@ -49,6 +49,83 @@ export async function moveOntoContextWindowDetails(page: Page, card: Locator): P
|
||||
await page.mouse.move(meter.x + meter.width / 2, box.y + box.height / 2, { steps: 20 });
|
||||
}
|
||||
|
||||
/** The hover card as one painted frame showed it, or unmounted. */
|
||||
export interface ContextWindowFrame {
|
||||
mounted: boolean;
|
||||
bottom: number | null;
|
||||
loading: boolean;
|
||||
usageCards: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the hover card as it was painted, every frame, until stopped, so a state that lasted one
|
||||
* frame is seen. Stop it only after everything has settled.
|
||||
*/
|
||||
export async function recordContextWindowFrames(
|
||||
page: Page,
|
||||
): Promise<{ stop: () => Promise<ContextWindowFrame[]> }> {
|
||||
await page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowFrames = [];
|
||||
recorder.__contextWindowRecording = true;
|
||||
const findCard = () =>
|
||||
document.querySelector<HTMLElement>('[data-testid="context-window-details"]');
|
||||
const read = (card: HTMLElement | null): ContextWindowFrame => ({
|
||||
mounted: card !== null,
|
||||
bottom: card?.getBoundingClientRect().bottom ?? null,
|
||||
loading: card?.innerText.includes("Loading usage...") ?? false,
|
||||
usageCards: card?.querySelectorAll('[data-testid^="usage-report-"]').length ?? 0,
|
||||
});
|
||||
// A card that just resized has not been painted yet: its frame is read from an observer,
|
||||
// which runs before that paint.
|
||||
let watched: HTMLElement | null = null;
|
||||
let paintedHeight: number | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
const watch = (card: HTMLElement) => {
|
||||
observer = new ResizeObserver(() => {
|
||||
paintedHeight = card.offsetHeight;
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
});
|
||||
observer.observe(card);
|
||||
};
|
||||
const channel = new MessageChannel();
|
||||
// A message posted from an animation frame runs after that frame's paint.
|
||||
channel.port1.addEventListener("message", () => {
|
||||
const card = findCard();
|
||||
if (card !== watched) {
|
||||
observer?.disconnect();
|
||||
watched = card;
|
||||
paintedHeight = null;
|
||||
if (card) watch(card);
|
||||
}
|
||||
if (!card || card.offsetHeight === paintedHeight) {
|
||||
recorder.__contextWindowFrames.push(read(card));
|
||||
}
|
||||
});
|
||||
channel.port1.start();
|
||||
const sample = () => {
|
||||
if (!recorder.__contextWindowRecording) return;
|
||||
channel.port2.postMessage(null);
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
return {
|
||||
stop: () =>
|
||||
page.evaluate(() => {
|
||||
const recorder = window as unknown as {
|
||||
__contextWindowFrames: ContextWindowFrame[];
|
||||
__contextWindowRecording: boolean;
|
||||
};
|
||||
recorder.__contextWindowRecording = false;
|
||||
return recorder.__contextWindowFrames;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
@@ -139,6 +216,8 @@ export interface AgentUsageScript {
|
||||
agentRequests(): string[];
|
||||
/** Each scoped agent Refresh, by agent ID. */
|
||||
refreshedAgents(): string[];
|
||||
/** Reloads the agent's session, which starts it under a new usage scope, as a resume does. */
|
||||
resumeAgent(agent: MockAgentSession): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,6 +248,8 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
usage
|
||||
.listRequests()
|
||||
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||
resumeAgent: (agent) =>
|
||||
usage.untilUsageScopeChanges(agent.agentId, () => agent.client.refreshAgent(agent.agentId)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UsageListRequest {
|
||||
export interface UsageReportsFixture {
|
||||
listRequests(): UsageListRequest[];
|
||||
waitForListRequests(count: number): Promise<void>;
|
||||
/** Runs `action`, then waits until the app has received the agent's next usage scope. */
|
||||
untilUsageScopeChanges(agentId: string, action: () => Promise<unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
interface UsageReportsFixtureOptions {
|
||||
@@ -97,6 +99,26 @@ function withUsageSupportFeature(
|
||||
});
|
||||
}
|
||||
|
||||
function agentUsageScope(message: WebSocketMessage): { agentId: string; key: string } | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: { kind?: unknown; agent?: { id?: unknown; usageScopeKey?: unknown } };
|
||||
};
|
||||
} | null;
|
||||
const agent = envelope?.message?.payload?.agent;
|
||||
if (
|
||||
envelope?.message?.type !== "agent_update" ||
|
||||
envelope.message.payload?.kind !== "upsert" ||
|
||||
typeof agent?.id !== "string" ||
|
||||
typeof agent.usageScopeKey !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { agentId: agent.id, key: agent.usageScopeKey };
|
||||
}
|
||||
|
||||
function pick<T>(values: T[], index: number): T {
|
||||
const value = values[Math.min(index, values.length - 1)];
|
||||
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
|
||||
@@ -127,6 +149,16 @@ export async function installUsageReportsFixture(
|
||||
): Promise<UsageReportsFixture> {
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageScopes = new Map<string, string>();
|
||||
const usageScopeChanges = new Map<string, ReturnType<typeof createCounter>>();
|
||||
const usageScopeCounts = new Map<string, number>();
|
||||
const usageScopeCounter = (agentId: string) => {
|
||||
const existing = usageScopeChanges.get(agentId);
|
||||
if (existing) return existing;
|
||||
const created = createCounter();
|
||||
usageScopeChanges.set(agentId, created);
|
||||
return created;
|
||||
};
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
@@ -239,6 +271,15 @@ export async function installUsageReportsFixture(
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
: null;
|
||||
const scope = agentUsageScope(message);
|
||||
if (scope && usageScopes.get(scope.agentId) !== scope.key) {
|
||||
usageScopes.set(scope.agentId, scope.key);
|
||||
usageScopeCounts.set(scope.agentId, (usageScopeCounts.get(scope.agentId) ?? 0) + 1);
|
||||
// The app must hold the new scope before a test acts on it.
|
||||
ws.send(serverInfo ?? message);
|
||||
usageScopeCounter(scope.agentId).increment();
|
||||
return;
|
||||
}
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
@@ -246,5 +287,10 @@ export async function installUsageReportsFixture(
|
||||
return {
|
||||
listRequests: () => [...listRequests],
|
||||
waitForListRequests: (count) => listCounter.waitFor(count),
|
||||
untilUsageScopeChanges: async (agentId, action) => {
|
||||
const next = (usageScopeCounts.get(agentId) ?? 0) + 1;
|
||||
await action();
|
||||
await usageScopeCounter(agentId).waitFor(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +322,17 @@ export function HoverCardContent(props: HoverCardContentProps): ReactElement | n
|
||||
);
|
||||
}
|
||||
|
||||
// A card above its trigger holds its bottom edge, so content that loads while it is open grows it
|
||||
// upward without a correction. Anchored by its top, it would paint over the trigger until placed.
|
||||
type CardPosition = { left: number; top: number } | { left: number; bottom: number };
|
||||
|
||||
function isSamePosition(current: CardPosition | null, next: CardPosition): boolean {
|
||||
if (!current || current.left !== next.left) return false;
|
||||
return "top" in current
|
||||
? "top" in next && current.top === next.top
|
||||
: "bottom" in next && current.bottom === next.bottom;
|
||||
}
|
||||
|
||||
function readDomRect(view: View | null): Rect | null {
|
||||
const element = view as unknown as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
@@ -349,7 +360,7 @@ function HoverCardSurface({
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const overlayRef = useRef<View>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [position, setPosition] = useState<CardPosition | null>(null);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const triggerRect = readDomRect(triggerRef.current);
|
||||
@@ -357,7 +368,7 @@ function HoverCardSurface({
|
||||
const content = contentRef.current as unknown as HTMLElement | null;
|
||||
if (!triggerRect || !overlayRect || !content) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
const { x, y, actualPlacement } = computePosition({
|
||||
triggerRect,
|
||||
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
@@ -366,19 +377,19 @@ function HoverCardSurface({
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
|
||||
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
|
||||
const left = x - overlayRect.x;
|
||||
const next: CardPosition =
|
||||
actualPlacement === "top"
|
||||
? { left, bottom: overlayRect.height - (y + content.offsetHeight - overlayRect.y) }
|
||||
: { left, top: y - overlayRect.y };
|
||||
setPosition((current) => (isSamePosition(current, next) ? current : next));
|
||||
}, [alignment, contentRef, offset, placement, triggerRef]);
|
||||
|
||||
useLayoutEffect(place, [place]);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
position: "absolute" as const,
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
}),
|
||||
[position?.x, position?.y],
|
||||
() => ({ position: "absolute" as const, ...(position ?? { top: -9999, left: -9999 }) }),
|
||||
[position],
|
||||
);
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface Agent {
|
||||
pendingPermissions: AgentPermissionRequest[];
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
usageScopeKey?: string;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string | null;
|
||||
title: string | null;
|
||||
|
||||
@@ -47,8 +47,16 @@ function usageReportsQueryKey(serverId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "reports"] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(serverId: string, agentId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "agent", agentId] as const;
|
||||
function agentUsageQueryKey(serverId: string, agentId: string, usageScopeKey: string | undefined) {
|
||||
return [...hostUsageQueryKey(serverId), "agent", agentId, usageScopeKey ?? null] as const;
|
||||
}
|
||||
|
||||
function useAgentUsageScopeKey(serverId: string, agentId: string | undefined) {
|
||||
return useSessionStore((state) =>
|
||||
agentId === undefined
|
||||
? undefined
|
||||
: state.sessions[serverId]?.agents.get(agentId)?.usageScopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
@@ -190,24 +198,25 @@ export function useUsageHostReports({
|
||||
|
||||
/**
|
||||
* The reports of the account an agent runs under, as its meter's tooltip or sheet shows them.
|
||||
* Fetched while either is mounted, so only while it is open.
|
||||
* Fetched while either is mounted, so only while it is open. Kept in the cache between opens, so
|
||||
* the first frame of a reopen shows them while its own request refreshes them.
|
||||
*/
|
||||
export function useAgentUsage(serverId: string, agentId: string): AgentUsageView {
|
||||
const queryClient = useQueryClient();
|
||||
const canReport = useHostReportsUsage(serverId);
|
||||
const queryKey = agentUsageQueryKey(serverId, agentId);
|
||||
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
|
||||
const queryKey = agentUsageQueryKey(serverId, agentId, usageScopeKey);
|
||||
const query = useFetchQuery({
|
||||
queryKey,
|
||||
// Consuming the signal cancels the request when the details close, so a reopen sends a new
|
||||
// request instead of joining one scoped to the login the agent ran under before.
|
||||
// Consuming the signal cancels the request when the details close, so a report still on its
|
||||
// way cannot land under a scope the agent has since left.
|
||||
queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }),
|
||||
enabled: canReport,
|
||||
// Another agent's reports never stand in while this one's load.
|
||||
dataShape: "value",
|
||||
// Dropped as soon as the details close: an agent resumed under another login keeps its ID, so
|
||||
// reports kept from an earlier open could show the old login. The daemon's cache keeps each
|
||||
// open's request cheap.
|
||||
gcTime: 0,
|
||||
// COMPAT(agentUsageScopeKey): added in v0.15.4, remove after 2027-05-09. Without the key,
|
||||
// an agent resumed under another login keeps its query, so its reports die with the details.
|
||||
gcTime: usageScopeKey === undefined ? 0 : Infinity,
|
||||
// The daemon's errors (an unknown agent) do not heal on retry, and reopening the details
|
||||
// fetches again; retrying would hold the loading sentence for seconds instead.
|
||||
retry: false,
|
||||
@@ -255,13 +264,14 @@ export function useReportRefresh(
|
||||
agentId?: string,
|
||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||
const queryClient = useQueryClient();
|
||||
const usageScopeKey = useAgentUsageScopeKey(serverId, agentId);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true, agentId),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueryData<UsageReportEntry[]>(
|
||||
agentId === undefined
|
||||
? usageReportsQueryKey(serverId)
|
||||
: agentUsageQueryKey(serverId, agentId),
|
||||
: agentUsageQueryKey(serverId, agentId, usageScopeKey),
|
||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -79,6 +79,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
pendingPermissions: agent.pendingPermissions,
|
||||
persistence: agent.persistence,
|
||||
...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}),
|
||||
...(agent.usageScopeKey ? { usageScopeKey: agent.usageScopeKey } : {}),
|
||||
...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}),
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
@@ -126,6 +127,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
pendingPermissions: snapshot.pendingPermissions ?? [],
|
||||
persistence: snapshot.persistence ?? null,
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
usageScopeKey: snapshot.usageScopeKey,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
|
||||
@@ -923,6 +923,8 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
|
||||
persistence: AgentPersistenceHandleSchema.nullable(),
|
||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||
/** Opaque. Changes whenever the accounts this agent reports usage for can change. */
|
||||
usageScopeKey: z.string().optional(),
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
|
||||
@@ -271,6 +271,19 @@ describe("toAgentPayload", () => {
|
||||
expect(permissionA.title).toBe("Run command");
|
||||
});
|
||||
|
||||
it("changes the usage scope key when the harness session or its model changes", () => {
|
||||
function usageScopeKey(usageSession: ReturnType<NonNullable<AgentSession["usageSession"]>>) {
|
||||
const session = { usageSession: () => usageSession } as unknown as AgentSession;
|
||||
return toAgentPayload(createManagedAgent({ session })).usageScopeKey;
|
||||
}
|
||||
const scope = { provider: "claude", model: "opus", env: {}, sessionKey: "session-a" };
|
||||
|
||||
expect(usageScopeKey(scope)).toBe(usageScopeKey({ ...scope }));
|
||||
expect(usageScopeKey({ ...scope, sessionKey: "session-b" })).not.toBe(usageScopeKey(scope));
|
||||
expect(usageScopeKey({ ...scope, model: "sonnet" })).not.toBe(usageScopeKey(scope));
|
||||
expect(usageScopeKey(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits usage when any numeric usage field is NaN", () => {
|
||||
const fields = [
|
||||
"inputTokens",
|
||||
|
||||
@@ -114,6 +114,12 @@ export function toStoredAgentRecord(
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
|
||||
/** Account discovery depends on the harness session and its model, so the key follows both. */
|
||||
function projectUsageScopeKey(agent: ManagedAgent): string | undefined {
|
||||
const usageSession = agent.session?.usageSession?.();
|
||||
return usageSession ? `${usageSession.sessionKey}:${usageSession.model ?? ""}` : undefined;
|
||||
}
|
||||
|
||||
export function toAgentPayload(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions,
|
||||
@@ -162,6 +168,11 @@ export function toAgentPayload(
|
||||
...visibilityFlags(agent),
|
||||
};
|
||||
|
||||
const usageScopeKey = projectUsageScopeKey(agent);
|
||||
if (usageScopeKey !== undefined) {
|
||||
payload.usageScopeKey = usageScopeKey;
|
||||
}
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
if (usage !== undefined) {
|
||||
payload.lastUsage = usage;
|
||||
|
||||
Reference in New Issue
Block a user