Compare commits

..
Author SHA1 Message Date
f56a1add91 fix(usage): complete enterprise spending percentages
Derive missing remaining percentages so CLI spending output matches the Usage card. Preserve explicitly reported percentages and cover missing currency and absent rolling windows.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: JasprW <wang@jaspr.me>
2026-11-07 00:21:38 +00:00
Matt Cowger c9b03eb309 fix(usage): report enterprise spending limits 2026-11-06 12:48:19 -08:00
40 changed files with 349 additions and 1462 deletions
+3
View File
@@ -195,6 +195,9 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
units and displayed only for USD; the balance contract does not represent other currencies.
Codex individual spending limits provide percentages, not dollar amounts.
---
@@ -1,11 +1,5 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
fillComposerDraft,
copyLatestTurnIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -34,7 +28,6 @@ import {
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
@@ -62,9 +55,7 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
await expect
.poll(async () => (await readScrollMetrics(page)).offsetY)
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
await page.screenshot({
path: testInfo.outputPath("chat-keyboard-paging.png"),
});
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
await page.keyboard.press("PageDown");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
@@ -133,15 +124,11 @@ test("focusing the feed preserves assistant text selection and code copy control
});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const copiedTable =
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-",
title: "Composer focus",
featureValues: { mockAssistantResponse: copiedTable },
});
const connection = await installDaemonWebSocketGate(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
@@ -149,62 +136,8 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/issues/39150">this FR</a>',
text: "this FR",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await undoComposerEdit(page);
await expectComposerDraft(page, "Investigate: replace this. Thanks");
await redoComposerEdit(page);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/offline">offline link</a>',
text: "offline link",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
text: "Resource\tStatus\nIssue\tclosed",
});
await expectComposerDraft(
page,
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/">Issue</a><table></table>',
text: "Issue",
});
await expectComposerDraft(page, "[Issue](https://example.com/)");
await copyLatestTurnIntoComposer(page);
await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
} finally {
await agent.cleanup();
}
@@ -235,11 +168,7 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({
viewport: { width: 1024, height: 768 },
isMobile: true,
hasTouch: true,
});
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -263,9 +192,7 @@ test.describe("touch screen wide enough for the desktop layout", () => {
test("returning to an inactive inline composer does not restore focus on touch", async ({
page,
}) => {
const workspace = await seedWorkspace({
repoPrefix: "composer-touch-retained-",
});
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
@@ -112,7 +112,6 @@ test.describe("Workspace archive undo", () => {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
+10 -77
View File
@@ -31,9 +31,7 @@ export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({
timeout: options?.timeout ?? 15_000,
});
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
}
export async function expectComposerDisabled(page: Page): Promise<void> {
@@ -158,9 +156,7 @@ export async function attachImageFromMenu(
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
}
export async function attachFileFromMenu(
@@ -222,24 +218,18 @@ export async function expectGithubAttachmentPill(
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
@@ -293,10 +283,7 @@ export async function startRunningMockAgent(
model: opts.model,
featureValues: opts.featureValues,
});
const agentUrl = `${buildHostWorkspaceRoute(
serverId,
workspace.id,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -360,11 +347,7 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
});
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
@@ -522,10 +505,7 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as {
input: number;
root: number;
}[];
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
@@ -536,10 +516,7 @@ export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", {
name: activeChatTitle,
exact: true,
});
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
@@ -579,10 +556,7 @@ export async function roundTripComposerTabBeforePaint(
});
}
});
observer.observe(tab, {
attributes: true,
attributeFilter: ["aria-selected"],
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
@@ -592,44 +566,3 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer({
page,
html,
text,
}: {
page: Page;
html: string;
text: string;
}): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
export async function undoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+z");
}
export async function redoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+Shift+z");
}
/** Reuse the actual response Copy action and the browser's native paste. */
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
await composerInput(page).fill("");
await composerInput(page).press("ControlOrMeta+v");
}
+4 -181
View File
@@ -1,9 +1,4 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MessageReceipts } from "../../../server/src/server/message-receipts/index";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
@@ -811,16 +806,12 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([]);
});
it("removes the queued entry and submits its messageId + text + attachments", async () => {
it("removes the queued entry and submits its text + attachments", async () => {
const review = reviewWorkspaceAttachment("Queued for send.");
const queue = createFakeQueue(
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
);
const submitted: Array<{
messageId: string;
text: string;
attachments: ComposerAttachment[];
}> = [];
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
@@ -831,65 +822,7 @@ describe("sendQueuedComposerMessageNow", () => {
});
expect(result).toEqual({ status: "submitted" });
expect(queue.state.get("agent")).toEqual([]);
expect(submitted).toEqual([{ messageId: "msg-1", text: "send me", attachments: [review] }]);
});
it("preserves the queued messageId as clientMessageId across failed drain retries", async () => {
const queue = createFakeQueue(
new Map([["agent", [{ id: "msg-queued-1", text: "retry me", attachments: [] }]]]),
);
const stream = createFakeStream();
const sentIds: string[] = [];
let shouldFail = true;
const client: ComposerSendClient = {
sendAgentMessage: async (_agentId, _text, options) => {
sentIds.push(options.messageId);
if (shouldFail) {
shouldFail = false;
throw new Error("Timeout waiting for message (60000ms)");
}
},
uploadFile: async () => ({ requestId: "req", file: null, error: "unused" }),
};
const submitMessage = ({
messageId,
text,
attachments,
}: {
messageId: string;
text: string;
attachments: ComposerAttachment[];
}) =>
dispatchComposerAgentMessage({
client,
agentId: "agent",
clientMessageId: messageId,
text,
attachments,
encodeImages: passthroughEncodeImages,
submission: stream,
});
const first = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-queued-1",
queue,
submitMessage,
});
expect(first).toEqual({
status: "failed",
errorMessage: "Timeout waiting for message (60000ms)",
});
const second = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-queued-1",
queue,
submitMessage,
});
expect(second).toEqual({ status: "submitted" });
expect(sentIds).toEqual(["msg-queued-1", "msg-queued-1"]);
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
@@ -1190,113 +1123,3 @@ describe("file upload preparation", () => {
},
);
});
const directories: string[] = [];
afterEach(async () => {
useSessionStore.getState().clearSession("retry-test");
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function reproduceQueuedRetry(canonicalBeforeTimeout: boolean) {
await mkdir(".artifacts", { recursive: true });
const directory = await mkdtemp(path.resolve(".artifacts/receipts-"));
directories.push(directory);
const receipts = new MessageReceipts(directory);
const store = useSessionStore.getState();
store.initializeSession("retry-test", null!);
store.updateSessionServerInfo("retry-test", {
serverId: "retry-test",
hostname: null,
version: "0.15.3",
features: { canonicalSubmittedPrompts: true },
});
let deliveries = 0;
const ids: string[] = [];
let calls = 0;
const client: ComposerSendClient = {
uploadFile: async () => ({ requestId: "unused", file: null, error: "unused" }),
sendAgentMessage: async (agentId, text, options) => {
calls++;
ids.push(options.messageId);
await receipts.send({
agentId,
messageId: options.messageId,
request: { text },
send: async () => {
deliveries++;
},
});
if (calls === 1) {
if (canonicalBeforeTimeout) {
store.setAgentStreamState("retry-test", agentId, {
tail: [
{
kind: "user_message",
id: "canonical",
messageId: options.messageId,
clientMessageId: options.messageId,
text,
timestamp: new Date(),
},
],
acknowledgedClientMessageIds: [options.messageId],
});
}
throw new Error("Timeout waiting for message (60000ms)");
}
},
};
store.setQueuedMessages(
"retry-test",
new Map([["agent", [{ id: "queued-original", text: "run once", attachments: [] }]]]),
);
const queue = {
read: (agentId: string) =>
useSessionStore.getState().sessions["retry-test"]?.queuedMessages.get(agentId) ?? [],
write: (
update: (
previous: Map<string, QueuedComposerMessage[]>,
) => Map<string, QueuedComposerMessage[]>,
) => store.setQueuedMessages("retry-test", update),
};
const submitMessage = (item: {
messageId?: string;
text: string;
attachments: ComposerAttachment[];
}) =>
dispatchComposerAgentMessage({
client,
agentId: "agent",
clientMessageId: item.messageId,
text: item.text,
attachments: item.attachments,
encodeImages: async () => undefined,
submission: createMessageSubmissionWriter("retry-test"),
});
const attempt = () =>
sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "queued-original",
queue,
submitMessage,
});
const first = await attempt();
expect(first.status).toBe("failed");
const second = await attempt();
return { first, second, deliveries, ids };
}
it("retry after accepted request and lost response executes the queued prompt once", async () => {
const result = await reproduceQueuedRetry(false);
expect(result.second.status).toBe("submitted");
expect(result.deliveries).toBe(1);
expect(result.ids).toEqual(["queued-original", "queued-original"]);
});
it("retry after canonical message and lost response succeeds without a second execution", async () => {
const result = await reproduceQueuedRetry(true);
expect(result.second).toEqual({ status: "submitted" });
expect(result.deliveries).toBe(1);
});
+3 -12
View File
@@ -188,7 +188,6 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
clientMessageId?: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -206,7 +205,7 @@ export async function dispatchComposerAgentMessage(
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const clientMessageId = input.clientMessageId ?? generateMessageId();
const clientMessageId = generateMessageId();
const userMessage = createUserMessage({
clientMessageId,
text: input.text,
@@ -296,11 +295,7 @@ export interface SendQueuedComposerMessageNowInput {
agentId: string;
messageId: string;
queue: QueueWriter;
submitMessage: (input: {
messageId: string;
text: string;
attachments: ComposerAttachment[];
}) => Promise<void>;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
failedToSendMessage?: string;
}
@@ -323,11 +318,7 @@ export async function sendQueuedComposerMessageNow(
return next;
});
try {
await input.submitMessage({
messageId: item.id,
text: item.text,
attachments: item.attachments,
});
await input.submitMessage({ text: item.text, attachments: item.attachments });
return { status: "submitted" };
} catch (error) {
input.queue.write((prev) => {
+22 -52
View File
@@ -82,7 +82,6 @@ import {
type MessageInputRef,
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { generateMessageId } from "@/types/stream";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandTarget } from "@/agent-commands";
import { encodeImages } from "@/utils/encode-images";
@@ -1488,18 +1487,12 @@ function ComposerContentImpl({
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const failedDirectSendRetryRef = useRef<{
agentId: string;
text: string;
clientMessageId: string;
} | null>(null);
const sendAgentMessageRef = useRef<
| ((
agentId: string,
text: string,
attachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
clientMessageId?: string,
) => Promise<void>)
| null
>(null);
@@ -1553,7 +1546,7 @@ function ComposerContentImpl({
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, submitAttachments: ComposerAttachment[], messageId?: string) => {
async (text: string, submitAttachments: ComposerAttachment[]) => {
onMessageSent?.();
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, attachments: submitAttachments, cwd });
@@ -1567,7 +1560,6 @@ function ComposerContentImpl({
text,
submitAttachments,
appSettings.sendBehavior === "steer" ? "steer" : "interrupt",
messageId,
);
},
[appSettings.sendBehavior, cwd, onMessageSent, t],
@@ -1583,51 +1575,29 @@ function ComposerContentImpl({
text: string,
sendAttachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
explicitClientMessageId?: string,
) => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const pendingRetry = failedDirectSendRetryRef.current;
const resolvedClientMessageId =
explicitClientMessageId ??
(pendingRetry?.agentId === targetAgentId && pendingRetry.text === text
? pendingRetry.clientMessageId
: generateMessageId());
try {
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
clientMessageId: resolvedClientMessageId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
if (!explicitClientMessageId) {
failedDirectSendRetryRef.current = null;
}
} catch (error) {
if (!explicitClientMessageId) {
failedDirectSendRetryRef.current = {
agentId: targetAgentId,
text,
clientMessageId: resolvedClientMessageId,
};
}
throw error;
}
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
};
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
@@ -1993,8 +1963,8 @@ function ComposerContentImpl({
agentId,
messageId: id,
queue: queueWriter,
submitMessage: ({ messageId: queuedMessageId, text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments, queuedMessageId),
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
failedToSendMessage: t("composer.errors.failedToSend"),
});
if (result.status === "failed") {
+6 -11
View File
@@ -88,7 +88,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -514,7 +513,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteEffectArgs {
interface PasteImagesEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
@@ -523,7 +522,7 @@ interface PasteEffectArgs {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteEffect(args: PasteEffectArgs): void {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
textarea,
isConnected,
@@ -534,20 +533,16 @@ function usePasteEffect(args: PasteEffectArgs): void {
} = args;
useEffect(() => {
if (!isWeb) return;
if (!isWeb || !onAddImages) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
if (imageFiles.length === 0) return;
event.preventDefault();
@@ -1816,7 +1811,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteEffect({
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
disabled,
@@ -1,144 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
import { pasteLinkedClipboardText } from "./pasted-text.web";
const inputs: HTMLTextAreaElement[] = [];
function editor(): HTMLTextAreaElement {
const input = document.createElement("textarea");
document.body.append(input);
inputs.push(input);
return input;
}
afterEach(() => {
for (const input of inputs.splice(0)) input.remove();
});
function clipboard(html: string, text: string): DataTransfer {
const data = new DataTransfer();
data.setData("text/html", html);
data.setData("text/plain", text);
return data;
}
describe("pasted prompt links", () => {
it.each([
"https://example.com/1",
"https://example.com/日本語",
"<https://example.com/a b>",
"https://例え.テスト/issue",
])("keeps complete Markdown from Copy with destination %s", (destination) => {
const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`;
const content = createMarkdownClipboardContent(source);
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
expect(input.value).toBe("");
});
it("converts external links when plain text names the resource but omits a destination", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<a href="https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E">日本語</a> and <a href="https://example.com/missing">another</a>',
"[日本語](https://example.com/日本語) and another",
),
),
).toBe(true);
expect(input.value).toBe(
"[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)",
);
});
it("preserves a labeled resource URL as Markdown", () => {
const data = clipboard(
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
"Hey can you take a look at this FR and...",
);
const input = editor();
expect(pasteLinkedClipboardText(input, data)).toBe(true);
expect(input.value).toBe(
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
);
});
it("preserves external linked table rows and columns", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
"Resource\tStatus\nIssue\tclosed",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
});
it("keeps table cell separators and line breaks inside their cells", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/issue">Issue | tracker</a></td><td>closed<br>today</td></tr></table>',
"Resource\tStatus\nIssue | tracker\tclosed today",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed<br>today |",
);
});
it("retains headerless tables as HTML without inventing a heading", () => {
const html =
'<table><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr><tr><td>Other</td><td>open</td></tr></tbody></table>';
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe(
true,
);
expect(input.value).toBe(html);
});
it.each([
["<table></table>", ""],
["<table><caption>Context</caption></table>", "\n\nContext"],
])("keeps surrounding links when a table has no rows: %s", (table, caption) => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(`<a href="https://example.com/">Issue</a>${table}`, "Issue"),
),
).toBe(true);
expect(input.value).toBe(`[Issue](https://example.com/)${caption}`);
});
it("leaves plain text and rich text without links to native paste", () => {
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
false,
);
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
expect(pasteLinkedClipboardText(input, null)).toBe(false);
expect(input.value).toBe("");
});
it("keeps paragraphs and multiple links", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
"See one\n\nand two",
),
),
).toBe(true);
expect(input.value).toBe(
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
);
});
});
-7
View File
@@ -1,7 +0,0 @@
/** Native inputs retain their platform clipboard handling. */
export function pasteLinkedClipboardText(
_input: HTMLElement,
_clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
return false;
}
@@ -1,59 +0,0 @@
import TurndownService from "turndown";
import { tables } from "turndown-plugin-gfm";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
const markdown = new TurndownService({
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
// Heading tables become Markdown; headerless tables retain their HTML structure.
markdown.use(tables);
markdown.addRule("rowlessTable", {
filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0,
replacement: (content) => (content ? `\n\n${content}\n\n` : ""),
});
markdown.addRule("tableCell", {
filter: ["th", "td"],
replacement: (content, node) => {
const prefix = node.previousElementSibling ? " " : "| ";
const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "<br>");
return `${prefix}${cell} |`;
},
});
const copiedMarkdown = createAssistantMarkdownParser();
function linkDestinations(fragment: Document): string[] {
return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!);
}
function containsDestinations(plainText: string, destinations: string[]): boolean {
if (destinations.every((url) => plainText.includes(url))) return true;
// Copy normalizes Markdown URLs in HTML (percent encoding, internationalized
// domains). Compare parsed destinations using that same parser, not URL spelling.
const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html");
const links = new Set(linkDestinations(rendered));
return destinations.every((url) => plainText.includes(url) || links.has(url));
}
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
export function pasteLinkedClipboardText(
input: HTMLElement,
clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
const html = clipboard?.getData("text/html");
if (!html) return false;
// Parse in a detached document. Clipboard markup is never mounted in the app.
const fragment = new DOMParser().parseFromString(html, "text/html");
const destinations = linkDestinations(fragment);
const plainText = clipboard!.getData("text/plain");
// Copy actions already put complete Markdown on the clipboard. Keep that
// representation, including tables and formatting, whenever it carries the URLs.
if (containsDestinations(plainText, destinations)) return false;
const text = markdown.turndown(fragment.body);
input.focus();
// Direct value assignment drops the textarea's undo history. This also emits
// the normal input event so the editor publishes its new draft as for typing.
return input.ownerDocument.execCommand("insertText", false, text);
}
+1 -2
View File
@@ -2604,13 +2604,12 @@ export class HostRuntimeStore {
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ messageId, text, attachments }) => {
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({
client,
agentId,
clientMessageId: messageId,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -849,42 +849,3 @@ describe("removeWorkspace", () => {
expect(after.workspaces).toBe(before.workspaces);
});
});
describe("acknowledgedClientMessageIds pruning", () => {
it("prunes acknowledged queued messages in setAgentStreamState and applyAgentTimelineResponseState", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setQueuedMessages(
"test-server",
new Map([
[
"agent-1",
[
{ id: "cmsg-1", text: "first", attachments: [] },
{ id: "cmsg-2", text: "second", attachments: [] },
],
],
]),
);
store.setAgentStreamState("test-server", "agent-1", {
acknowledgedClientMessageIds: ["cmsg-1"],
});
expect(
useSessionStore.getState().sessions["test-server"]?.queuedMessages.get("agent-1"),
).toEqual([{ id: "cmsg-2", text: "second", attachments: [] }]);
store.applyAgentTimelineResponseState("test-server", "agent-1", {
items: [],
head: [],
range: null,
older: "unchanged",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: ["cmsg-2"],
});
expect(
useSessionStore.getState().sessions["test-server"]?.queuedMessages.get("agent-1"),
).toBeUndefined();
});
});
+2 -45
View File
@@ -732,29 +732,6 @@ function isSessionServerInfoUnchanged(input: {
);
}
function pruneAcknowledgedQueuedMessages(
queuedMessages: Map<
string,
Array<{ id: string; text: string; attachments: ComposerAttachment[] }>
>,
agentId: string,
acknowledgedIds: readonly string[],
): Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>> {
if (acknowledgedIds.length === 0) return queuedMessages;
const current = queuedMessages.get(agentId);
if (!current?.length) return queuedMessages;
const acknowledgedSet = new Set(acknowledgedIds);
const filtered = current.filter((item) => !acknowledgedSet.has(item.id));
if (filtered.length === current.length) return queuedMessages;
const next = new Map(queuedMessages);
if (filtered.length > 0) {
next.set(agentId, filtered);
} else {
next.delete(agentId);
}
return next;
}
export const useSessionStore = create<SessionStore>()(
subscribeWithSelector((set, get) => {
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
@@ -1064,29 +1041,16 @@ export const useSessionStore = create<SessionStore>()(
}
}
const acknowledgedIds = state.acknowledgedClientMessageIds ?? [];
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
acknowledgedIds,
state.acknowledgedClientMessageIds ?? [],
);
const changedSubmissions = observedSubmissions !== currentSubmissions;
const queuedMessages = pruneAcknowledgedQueuedMessages(
session.queuedMessages,
agentId,
acknowledgedIds,
);
const changedQueuedMessages = queuedMessages !== session.queuedMessages;
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
const changedTasks = agentTasks !== session.agentTasks;
if (
!changedTail &&
!changedHead &&
!changedSubmissions &&
!changedQueuedMessages &&
!changedTasks
) {
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
return prev;
}
@@ -1110,7 +1074,6 @@ export const useSessionStore = create<SessionStore>()(
agentStreamHead: nextHead,
agentTasks,
messageSubmissions,
queuedMessages,
},
},
};
@@ -1484,11 +1447,6 @@ export const useSessionStore = create<SessionStore>()(
messageSubmissions.delete(agentId);
}
}
const queuedMessages = pruneAcknowledgedQueuedMessages(
session.queuedMessages,
agentId,
state.acknowledgedClientMessageIds,
);
if (state.synchronized) {
nextAuthoritative.set(agentId, true);
nextSyncGeneration.set(agentId, session.historySyncGeneration);
@@ -1513,7 +1471,6 @@ export const useSessionStore = create<SessionStore>()(
agentAuthoritativeHistoryApplied: nextAuthoritative,
agentHistorySyncGeneration: nextSyncGeneration,
messageSubmissions,
queuedMessages,
},
},
};
-1
View File
@@ -1,6 +1,5 @@
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const gfm: TurndownService.Plugin;
}
+1 -6
View File
@@ -1589,12 +1589,7 @@ test("defaults session RPC waiters to sixty seconds", async () => {
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toMatchObject({
message: "Timeout waiting for message (60000ms)",
code: "DAEMON_REQUEST_TIMEOUT",
outcome: "unknown",
serverId: "srv_test_1",
});
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
});
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
+1 -9
View File
@@ -7745,15 +7745,7 @@ export class DaemonClient {
if (waiter) {
this.waiters.delete(waiter);
}
wrappedReject(
waiter?.submitted
? new DaemonConnectionError(timeoutError.message, timeoutError.code, {
serverId: this.destinationServerId,
outcome: "unknown",
cause: timeoutError,
})
: timeoutError,
);
wrappedReject(timeoutError);
}, timeout)
: null;
+1 -112
View File
@@ -213,18 +213,8 @@ async function settleDesktopStartup(page) {
}
}
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const server = createServer((request, response) => {
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-security-policy": "default-src 'self'; script-src 'self'",
});
response.end("<!doctype html><title>CSP page</title><h1>CSP page heading</h1>");
return;
}
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
@@ -256,67 +246,6 @@ async function closeServer(server) {
await new Promise((resolve) => server.close(resolve));
}
// browser_logs must report what the server answered: a 404 may not look like a 200.
async function verifyNetworkLogsReportStatus(client) {
const server = createServer((request, response) => {
response.writeHead(request.url === "/missing" ? 404 : 200, {
"content-type": "text/html; charset=utf-8",
});
response.end("<!doctype html><title>Status page</title><h1>Status page heading</h1>");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
assert(address && typeof address !== "string", "Status page did not bind a TCP port");
let browserId = null;
try {
const created = await callBrowserTool(client, "browser_new_tab", {
url: `http://127.0.0.1:${address.port}/`,
});
browserId = created.browserId;
assert(
typeof browserId === "string",
"browser_new_tab returned no browserId for the status page",
);
await callBrowserTool(client, "browser_wait", {
browserId,
text: "Status page heading",
timeoutMs: 15_000,
});
const fetched = await callBrowserTool(client, "browser_evaluate", {
browserId,
function:
"async () => { const response = await fetch('/missing'); await response.text(); return response.status; }",
});
assert(
JSON.parse(fetched.resultJson) === 404,
`The status page did not answer 404: ${fetched.resultJson}`,
);
// The browser records a request's timing entry a moment after the response completes.
const deadline = Date.now() + 5_000;
let seen = [];
while (Date.now() < deadline) {
const logs = await callBrowserTool(client, "browser_logs", { browserId });
seen = logs.network.map((entry) => ({
pathname: new URL(entry.url).pathname,
status: entry.status ?? null,
}));
const reported = (pathname, status) =>
seen.some((entry) => entry.pathname === pathname && entry.status === status);
if (reported("/missing", 404) && reported("/", 200)) return;
await delay(100);
}
throw new Error(
`browser_logs did not report 404 for /missing and 200 for the page: ${JSON.stringify(seen)}`,
);
} finally {
if (browserId) await callBrowserTool(client, "browser_close_tab", { browserId });
await closeServer(server);
}
}
function mcpPayload(result, command) {
const payload = result.structuredContent;
assert(
@@ -361,42 +290,6 @@ async function waitForGuestActiveElement(client, browserId, elementId) {
return false;
}
// A page whose Content Security Policy omits 'unsafe-eval' refuses eval(); the tools must still work.
async function verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl) {
const created = await callBrowserTool(client, "browser_new_tab", {
url: new URL(CSP_PAGE_PATH, targetUrl).href,
});
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId for the CSP page");
try {
await callBrowserTool(client, "browser_wait", {
browserId,
text: "CSP page heading",
timeoutMs: 15_000,
});
const title = await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => document.title",
});
assert(
JSON.parse(title.resultJson) === "CSP page",
`browser_evaluate on a page without 'unsafe-eval' returned ${title.resultJson}`,
);
// The control: the page itself really does refuse eval().
const probe = await callBrowserTool(client, "browser_evaluate", {
browserId,
function:
'() => { try { (0, eval)("1"); return "eval allowed"; } catch (error) { return error.message; } }',
});
assert(
/Content Security Policy/.test(JSON.parse(probe.resultJson)),
`The CSP page did not refuse eval(): ${probe.resultJson}`,
);
} finally {
await callBrowserTool(client, "browser_close_tab", { browserId });
}
}
async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
@@ -782,8 +675,6 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
const browserId = created.browserId;
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
@@ -1129,7 +1020,6 @@ async function runRegression({
JSON.parse(readyStateResult.resultJson) === "complete",
"Local browser document did not finish loading",
);
await verifyNetworkLogsReportStatus(client);
// Reproduce the report's mismatch: the guest is complete, but the pane's
// last loading signal says it is not ready.
await page.evaluate((id) => {
@@ -1216,7 +1106,6 @@ async function runRegression({
snapshot: "passed",
click: "passed",
localPageSelectors: "passed",
evaluateWithoutUnsafeEval: "passed",
};
}
@@ -1,5 +1,4 @@
import { resolve as resolvePath } from "node:path";
import { createContext, runInContext } from "node:vm";
import { describe, expect, test, vi } from "vitest";
import type {
@@ -435,54 +434,6 @@ function containsScript(tab: FakeTab, ...parts: string[]): boolean {
return tab.scripts.some((script) => parts.every((part) => script.includes(part)));
}
/**
* A browser whose tab runs the network script the host injects against `performance`, the way a
* page runs it, so the test sees what the script reads from Chromium's timing entries.
*/
function browserWithTimingEntries(entries: { resource: unknown[]; navigation: unknown[] }) {
const browser = new BrowserAutomationHarness();
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
const page = createContext({
performance: {
getEntriesByType: (type: string) =>
type === "navigation" ? entries.navigation : entries.resource,
},
});
browser.tab.executeJavaScript = async (script: string) =>
script.includes("performance.getEntriesByType")
? runInContext(script, page)
: fakeAnswer(script);
return browser;
}
/**
* Runs the script the host injects into a page the way Chromium runs it on a page whose Content
* Security Policy omits 'unsafe-eval': injected code runs, but code in the page cannot turn a
* string into code (eval, new Function). Node's vm refuses string code generation the same way.
*/
function pageRunningInjectedScripts(input: {
allowEval: boolean;
globals?: Record<string, unknown>;
}) {
const page = createContext(
{ document: { title: "Fixture" }, ...input.globals },
{ codeGeneration: { strings: input.allowEval, wasm: input.allowEval } },
);
return (script: string): unknown => runInContext(script, page);
}
/** A browser whose tab runs the evaluate script in `page`; its other scripts keep the fake answers. */
function browserEvaluatingIn(page: (script: string) => unknown) {
const browser = new BrowserAutomationHarness();
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
browser.tab.executeJavaScript = async (script: string) => {
if (!script.includes("__PASEO_BROWSER_EVALUATE__")) return fakeAnswer(script);
browser.tab.scripts.push(script);
return page(script);
};
return browser;
}
function requireSnapshotRefs(result: Awaited<ReturnType<BrowserAutomationHarness["snapshot"]>>) {
expect(result).toEqual({
requestId: "req-snapshot",
@@ -1455,72 +1406,6 @@ describe("executeAutomationCommand", () => {
});
});
test("logs reports the HTTP status the browser recorded, and none it did not", async () => {
const timing = (name: string, initiatorType: string, responseStatus?: number) => ({
name,
initiatorType,
startTime: 1,
duration: 2,
transferSize: 3,
...(responseStatus === undefined ? {} : { responseStatus }),
});
const browser = browserWithTimingEntries({
resource: [
timing("https://a.test/api/orders", "fetch", 404),
// Chromium reports 0 for a cross-origin response that sends no Timing-Allow-Origin.
timing("https://cdn.test/lib.js", "script", 0),
// A browser without the field.
timing("https://a.test/old.css", "css"),
],
navigation: [timing("https://a.test/form", "navigation", 200)],
});
const result = await browser.execute({ command: "logs", args: { browserId: BROWSER_A } });
expect(result).toEqual({
requestId: "req-logs",
ok: true,
result: {
command: "logs",
browserId: BROWSER_A,
console: [],
network: [
{
url: "https://a.test/api/orders",
type: "fetch",
status: 404,
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://cdn.test/lib.js",
type: "script",
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://a.test/old.css",
type: "css",
startTime: 1,
duration: 2,
transferSize: 3,
},
{
url: "https://a.test/form",
method: "GET",
type: "navigation",
status: 200,
startTime: 1,
duration: 2,
transferSize: 3,
},
],
},
});
});
test("evaluate returns primitive JSON from the page context", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.evaluateScriptResult = { ok: true, resultJson: "42" };
@@ -1673,99 +1558,6 @@ describe("executeAutomationCommand", () => {
expect(containsScript(browser.tab, "resultJson.slice(0, 79000)")).toBe(true);
});
describe.each([
{ page: "allows eval", allowEval: true },
{ page: "forbids eval (its Content Security Policy omits 'unsafe-eval')", allowEval: false },
])("evaluate on a page that $page", ({ allowEval }) => {
const evaluate = (browser: BrowserAutomationHarness, source: string) =>
browser.execute({
command: "evaluate",
args: { browserId: BROWSER_A, function: source },
});
test("runs the function and returns its result as JSON", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => document.title")).toEqual({
requestId: "req-evaluate",
ok: true,
result: {
command: "evaluate",
browserId: BROWSER_A,
resultJson: '"Fixture"',
truncated: false,
},
});
});
test("awaits an async function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(
browser,
"async () => { await Promise.resolve(); return 6 * 7; }",
);
expect(result).toMatchObject({ ok: true, result: { resultJson: "42" } });
});
test("resolves names against the page's globals, never the wrapper's own", async () => {
const globals = {
element: "page element",
args: "page args",
value: "page value",
resultJson: "page json",
userFunction: "page function",
__paseoEvaluate: "page wrapper",
readFunction: "page reader",
};
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval, globals }));
const result = await evaluate(
browser,
"() => [element, args, value, resultJson, userFunction, __paseoEvaluate, readFunction]",
);
expect(result).toMatchObject({
ok: true,
result: { resultJson: JSON.stringify(Object.values(globals)) },
});
});
test("reports what the function throws", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "() => { throw new Error('boom'); }")).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: "boom" },
});
});
test("reports an expression that is not a function", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
expect(await evaluate(browser, "document.title")).toMatchObject({
ok: false,
error: {
code: "browser_unknown_error",
message: "browser_evaluate input must evaluate to a function.",
},
});
});
test("reports a syntax error with the compiler's own message, without touching the page", async () => {
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
const result = await evaluate(browser, "() => {");
expect(result).toMatchObject({
ok: false,
error: { code: "browser_unknown_error", message: expect.stringMatching(/^Unexpected /) },
});
expect(containsScript(browser.tab, "__PASEO_BROWSER_EVALUATE__")).toBe(false);
});
});
test("screenshot captures the painted viewport", async () => {
const browser = new BrowserAutomationHarness();
@@ -1,5 +1,4 @@
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
import { Script } from "node:vm";
import type {
BrowserAutomationCommand,
@@ -850,11 +849,6 @@ async function executeEvaluate(
elementExpression = expression;
}
const syntaxError = functionSyntaxError(functionSource);
if (syntaxError !== null) {
return fail(requestId, "browser_unknown_error", capEvaluateErrorMessage(syntaxError));
}
let rawResult: unknown;
try {
rawResult = await target.contents.executeJavaScript(
@@ -1556,36 +1550,14 @@ function capEvaluateErrorMessage(message: string): string {
: message.slice(0, MAX_EVALUATE_ERROR_MESSAGE_LENGTH);
}
/**
* The syntax error in the agent's function, or null. It is compiled here because a script that
* fails to compile in the page only reports "Script failed to execute", and a typo deserves the
* compiler's own message. Compiling runs nothing.
*/
function functionSyntaxError(functionSource: string): string | null {
try {
void new Script(`(${functionSource})`);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
/**
* The page script that runs the agent's function and returns its result as bounded JSON.
*
* The function goes into the script as code, not as a string for the page to eval(): a page whose
* Content Security Policy omits 'unsafe-eval' refuses eval() but still runs what the host injects.
* The function-producing thunk is an argument defined outside the wrapper, so its names resolve
* against the page's globals without capturing any wrapper parameters or locals.
*/
function buildEvaluateScript(
functionSource: string,
elementExpression: string | undefined,
): string {
return String.raw`(async (readFunction) => {
return String.raw`(async () => {
const __PASEO_BROWSER_EVALUATE__ = true;
try {
const userFunction = readFunction();
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
if (typeof userFunction !== 'function') {
throw new Error('browser_evaluate input must evaluate to a function.');
}
@@ -1610,7 +1582,7 @@ function buildEvaluateScript(
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
})(() => (${functionSource}))`;
})()`;
}
async function focusKeypressTarget(
@@ -1656,8 +1628,6 @@ const NETWORK_PERFORMANCE_SCRIPT = String.raw`(() => {
url: entry.name,
method: entry.initiatorType === 'navigation' ? 'GET' : undefined,
type: entry.initiatorType,
// 0 when the browser does not say (a cross-origin response without Timing-Allow-Origin).
status: typeof entry.responseStatus === 'number' && entry.responseStatus > 0 ? entry.responseStatus : undefined,
startTime: entry.startTime,
duration: entry.duration,
transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined,
@@ -700,71 +700,6 @@ test("stops an OpenCode execution already active when its conversation is reopen
}
});
test("a completed OpenCode model switch does not depend on another session read", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-model-"));
const harness = new V2Harness();
harness.info.location.directory = workdir;
harness.info.model = { providerID: "provider", id: "source" };
let observationUnavailable = false;
harness.api.model.list = async () => ({
location: { directory: workdir },
data: [
{
providerID: "provider",
id: "target",
modelID: "target",
name: "Target",
capabilities: { tools: true, input: ["text"], output: ["text"] },
enabled: true,
variants: [],
time: { released: 1 },
cost: [],
status: "active",
limit: { context: 200000, output: 10000 },
},
],
});
harness.api.session.switchModel = async ({ model }) => {
harness.info.model = model;
observationUnavailable = true;
};
harness.api.session.get = async () => {
if (observationUnavailable) throw new Error("Transport");
return harness.info;
};
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
const manager = new AgentManager({ clients: { opencode: client }, registry: storage, logger });
const agent = await manager.resumeAgentFromPersistence({
provider: "opencode",
sessionId: "session",
nativeHandle: "session",
metadata: { cwd: workdir },
});
try {
await manager.setAgentModel(agent.id, "provider/target");
await manager.flush();
expect(harness.info.model?.id).toBe("target");
expect(manager.getAgent(agent.id)?.config.model).toBe("provider/target");
expect(manager.getAgent(agent.id)?.runtimeInfo?.model).toBe("provider/target");
const stored = await storage.get(agent.id);
expect(stored?.config?.model).toBe("provider/target");
expect(stored?.runtimeInfo?.model).toBe("provider/target");
expect(stored?.persistence?.metadata?.model).toBe("provider/target");
observationUnavailable = false;
for await (const _event of manager.streamAgent(agent.id, "continue after switching")) {
// A successful mutation leaves a usable session.
}
expect(harness.prompts).toEqual(["continue after switching"]);
} finally {
observationUnavailable = false;
await manager.closeAgent(agent.id);
await manager.flush();
await client.shutdown();
rmSync(workdir, { recursive: true, force: true });
}
});
test("uses an injected timeline store without making it a production requirement", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-"));
const store = new RecordingTimelineStore();
@@ -4764,51 +4699,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
});
test("importProviderSession preserves the native model instead of applying the new-session default", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-model-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const nativeModel = "native-selected-model";
class ImportClient extends TestAgentClient {
importConfig: AgentSessionConfig | undefined;
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
this.importConfig = context.config;
// A resumable provider applies explicit selections but otherwise retains its model.
const session = new TestAgentSession({
...context.config,
model: context.config.model ?? nativeModel,
});
return {
session,
config: context.storedConfig,
persistence: { provider: this.provider, sessionId: input.providerHandleId },
timeline: [],
};
}
}
const client = new ImportClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const imported = await manager.importProviderSession({
provider: "codex",
providerHandleId: "native-session",
cwd: workdir,
workspaceId: "import-model",
});
await manager.flush();
expect(imported.runtimeInfo?.model).toBe(nativeModel);
expect(imported.config.model).toBe(nativeModel);
expect(client.importConfig?.model).toBeUndefined();
expect((await storage.get(imported.id))?.config.model).toBe(nativeModel);
await manager.reloadAgentSession(imported.id);
expect(client.resumeOverrides.at(-1)?.model).toBe(nativeModel);
await manager.getAgent(imported.id)?.session?.close();
});
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
const storagePath = join(workdir, "agents");
@@ -5480,9 +5370,6 @@ test("model changes persist the resolved thinking and fresh provider handle befo
thinkingOptionId: null,
});
}
override async getRuntimeInfo() {
return { ...(await super.getRuntimeInfo()), ...this.selection };
}
describePersistence() {
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
}
@@ -5565,63 +5452,6 @@ test("later explicit config mutations win over events emitted by earlier mutatio
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test.each([
{ requested: "unavailable", effective: "source" },
{ requested: "alias", effective: "canonical" },
{ requested: null, effective: null },
])(
"setAgentModel stores effective selections and preserves resets for $requested",
async ({ requested, effective }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-effective-model-"));
class SelectingSession extends TestAgentSession {
private model: string | null = "source";
async setModel(model: string | null) {
if (model && model !== "unavailable") this.model = model === "alias" ? "canonical" : model;
return { model: this.model };
}
override async getRuntimeInfo() {
return { ...(await super.getRuntimeInfo()), model: this.model };
}
}
class SelectingClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new SelectingSession(config);
}
}
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new SelectingClient() },
registry: storage,
logger,
});
try {
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "source" },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentModel(snapshot.id, requested);
await manager.flush();
expect(manager.getAgent(snapshot.id)?.config.model).toBe(effective ?? undefined);
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe(effective);
const persisted = await storage.get(snapshot.id);
expect(persisted?.config?.model).toBe(effective ?? undefined);
expect(persisted?.runtimeInfo?.model).toBe(effective);
await manager.setAgentModel(snapshot.id, "target");
expect(manager.getAgent(snapshot.id)?.config.model).toBe("target");
expect(manager.getAgent(snapshot.id)?.runtimeInfo?.model).toBe("target");
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
@@ -1679,7 +1679,6 @@ export class AgentManager {
cwd: input.cwd,
},
resolvedAgentId,
{ resolveDefaultModel: false },
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
@@ -1706,13 +1705,8 @@ export class AgentManager {
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const runtimeInfo = await imported.session.getRuntimeInfo();
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer({
...imported.config,
model: runtimeInfo.model ?? imported.config.model,
}),
{ resolveDefaultModel: false },
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
@@ -2334,16 +2328,14 @@ export class AgentManager {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const selection = await agent.session.setModel?.(normalizedModelId);
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
await this.drainSessionEvents(agentId);
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
agent.config.model = normalizedModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
@@ -5731,12 +5723,11 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: NormalizeConfigOptions = {},
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
resolveDefaultModel: options.resolveDefaultModel,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
@@ -720,9 +720,7 @@ export interface AgentSession {
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
/** A returned model is the effective selection committed by this mutation.
* Returning void means the requested selection was accepted. No status read is required. */
setModel?(modelId: string | null): Promise<void | { model: string | null }>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -1400,10 +1400,9 @@ class PluginAgentSession implements AgentSession {
}));
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
return { model: this.bridge.config.model ?? null };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -520,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
setSessionConfigOption.mockClear();
await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" });
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"),
@@ -2281,17 +2281,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (!modelId) {
this.currentModel = null;
return { model: this.currentModel };
return;
}
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
return { model: this.currentModel };
}
private resolveModelSelection(modelId: string): ACPModelSelection {
@@ -2782,7 +2782,7 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
@@ -2800,7 +2800,6 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
return { model: this.lastOptionsModel ?? this.config.model ?? null };
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
@@ -1379,42 +1379,6 @@ describe("Codex app-server provider", () => {
}
});
test.each([undefined, "configured-model"])(
"import resolves model %s before the first collaboration turn",
async (configuredModel) => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
config: { model: configuredModel, model_reasoning_effort: "low" },
}),
"collaborationMode/list": () => ({
data: [{ name: "Code", mode: "default", model: null, reasoning_effort: null }],
}),
});
const provider = createProviderWithFakeAppServer(appServer);
const config = createConfig({ model: undefined, thinkingOptionId: undefined });
const imported = await provider.importSession(
{ providerHandleId: "native-thread", cwd: config.cwd! },
{ config, storedConfig: config },
);
try {
await imported.session.startTurn("first follow-up after import");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
threadId: "native-thread",
model: configuredModel ?? "gpt-5.4",
effort: "low",
collaborationMode: {
mode: "default",
settings: { model: configuredModel ?? "gpt-5.4", reasoning_effort: "low" },
},
});
appServer.assertNoErrors();
} finally {
await imported.session.close();
}
},
);
test("provider persistence leaves options owned by the stored agent config", async () => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
@@ -5554,7 +5554,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
this.refreshResolvedCollaborationMode();
return model;
}
@@ -1302,10 +1302,10 @@ export class OmpAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return { model: modelToId(this.state.model) };
return;
}
if (!parsedReference.provider) {
throw new Error(`OMP model id must include a provider: ${modelId}`);
@@ -1314,7 +1314,6 @@ export class OmpAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
this.config.model = `${model.provider}/${model.id}`;
await this.refreshState();
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -367,7 +367,6 @@ export class OpenCodeV2Session implements AgentSession {
thinkingOptionId: variant ?? null,
},
});
return { model: `${selected.providerID}/${selected.id}` };
}
async setThinkingOption(variant: string | null) {
@@ -1704,10 +1704,10 @@ export class PiRpcAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<{ model: string | null }> {
async setModel(modelId: string | null): Promise<void> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return { model: modelToId(this.state.model) };
return;
}
if (!parsedReference.provider) {
throw new Error(`Pi model id must include a provider: ${modelId}`);
@@ -1716,7 +1716,6 @@ export class PiRpcAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
await this.refreshState();
this.config.model = `${model.provider}/${model.id}`;
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -672,26 +672,15 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
describe("legacy Git migration", () => {
const id = "legacy-review";
const pluginPath = "plugins/review";
let home: string;
let repository: string;
let initial: string;
let remote: string;
let legacyRoot: string;
let directory: string;
let settingsDirectory: string;
let settingsFile: string;
let savedSettings: string;
// Build the legacy installation before the migration workflow deadline starts.
// These seven real Git launches have their own fixture setup budget.
beforeEach(async () => {
home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-"));
it.each([true, false])(
"updates the shipped legacy Git layout with enabled=%s",
async (enabled) => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-"));
roots.push(home);
repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
roots.push(repository);
const id = "legacy-review";
const pluginPath = "plugins/review";
await mkdir(path.join(repository, pluginPath), { recursive: true });
await writeFile(
path.join(repository, pluginPath, "paseo-plugin.json"),
@@ -714,11 +703,13 @@ export default function contribute(server: PluginServerContext) {
["tag", "v1"],
])
await runGitCommand(args, { cwd: repository });
initial = (await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })).stdout.trim();
remote = pathToFileURL(repository).href;
legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`);
const initial = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const remote = pathToFileURL(repository).href;
const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`);
const checkoutRoot = path.join(legacyRoot, "checkout");
directory = path.join(checkoutRoot, pluginPath);
const directory = path.join(checkoutRoot, pluginPath);
await mkdir(legacyRoot, { recursive: true });
await runGitCommand(["clone", remote, checkoutRoot], { cwd: home });
await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot });
@@ -735,111 +726,95 @@ export default function contribute(server: PluginServerContext) {
},
}),
);
settingsDirectory = path.join(home, "plugin-settings");
const settingsDirectory = path.join(home, "plugin-settings");
await mkdir(path.join(settingsDirectory, id), { recursive: true });
settingsFile = path.join(settingsDirectory, id, "preferences.json");
savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
const settingsFile = path.join(settingsDirectory, id, "preferences.json");
const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
await writeFile(settingsFile, savedSettings);
});
it.each([true, false])(
"updates the shipped legacy Git layout with enabled=%s",
async (enabled) => {
const store = createStore(home, {
[id]: { source: "directory", path: directory, enabled },
});
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
settingsDirectory,
}),
);
let service = open();
const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } });
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
settingsDirectory,
}),
);
let service = open();
try {
await service.start();
const expected = {
identity: { kind: "git", remote, pluginPath },
currentRevision: initial,
};
await rename(repository, `${repository}-offline`);
try {
await service.start();
const expected = {
identity: { kind: "git", remote, pluginPath },
currentRevision: initial,
};
await rename(repository, `${repository}-offline`);
try {
expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected);
expect(await service.listPlugins()).toMatchObject([
{ id, enabled, status: enabled ? "running" : "disabled", installation: expected },
]);
} finally {
await rename(`${repository}-offline`, repository);
}
await service.stopAllPlugins();
service = open();
await service.start();
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default");
for (const args of [
["add", "."],
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.test",
"commit",
"-m",
"update",
],
])
await runGitCommand(args, { cwd: repository });
const latest = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const [preview] = await service.previewUpdates({ pluginId: id });
expect(preview).toMatchObject({
outcome: "update",
current: expected,
target: { kind: "git", commit: latest },
});
const [updated] = await service.applyUpdates([preview!.proposal!]);
expect(updated).toMatchObject({
outcome: "updated",
plugin: {
id,
enabled,
status: enabled ? "running" : "disabled",
installation: { identity: expected.identity, currentRevision: latest },
},
});
await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(settingsFile, "utf8")).toBe(savedSettings);
await service.disablePlugin(id);
await service.enablePlugin(id);
expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" });
expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({
status: "ready",
values: { message: "keep my settings" },
});
await service.removePlugin(id);
await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await stat(repository)).toBeDefined();
const sources = new ManagedPluginSources(home);
// A record cannot confer ownership on another plugin's tree or a malformed version name.
sources.commit(id, { kind: "git", remote });
for (const outside of [
path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"),
path.join(home, "plugins", id, "f".repeat(36), "checkout"),
path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"),
])
await expect(sources.describe(id, outside)).rejects.toThrow(
"outside its managed installation",
);
expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected);
expect(await service.listPlugins()).toMatchObject([
{ id, enabled, status: enabled ? "running" : "disabled", installation: expected },
]);
} finally {
await service.stopAllPlugins();
await rename(`${repository}-offline`, repository);
}
},
60_000,
);
});
await service.stopAllPlugins();
service = open();
await service.start();
await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository });
await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default");
for (const args of [
["add", "."],
["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"],
])
await runGitCommand(args, { cwd: repository });
const latest = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })
).stdout.trim();
const [preview] = await service.previewUpdates({ pluginId: id });
expect(preview).toMatchObject({
outcome: "update",
current: expected,
target: { kind: "git", commit: latest },
});
const [updated] = await service.applyUpdates([preview!.proposal!]);
expect(updated).toMatchObject({
outcome: "updated",
plugin: {
id,
enabled,
status: enabled ? "running" : "disabled",
installation: { identity: expected.identity, currentRevision: latest },
},
});
await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(settingsFile, "utf8")).toBe(savedSettings);
await service.disablePlugin(id);
await service.enablePlugin(id);
expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" });
expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({
status: "ready",
values: { message: "keep my settings" },
});
await service.removePlugin(id);
await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({
code: "ENOENT",
});
expect(await stat(repository)).toBeDefined();
const sources = new ManagedPluginSources(home);
// A record cannot confer ownership on another plugin's tree or a malformed version name.
sources.commit(id, { kind: "git", remote });
for (const outside of [
path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"),
path.join(home, "plugins", id, "f".repeat(36), "checkout"),
path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"),
])
await expect(sources.describe(id, outside)).rejects.toThrow(
"outside its managed installation",
);
} finally {
await service.stopAllPlugins();
}
},
60_000,
);
it("keeps the running commit when a Git update build command fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
@@ -1134,18 +1134,6 @@ describe("WorkspaceGitServiceImpl", () => {
});
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
// Keep the second workspace's observation setup behind its initial snapshot.
const secondCwd = join(REPO_CWD, "packages", "server");
const runGitCommand = vi.fn(async (_args, options) => {
if (options.cwd === secondCwd) await delay(250);
return {
stdout: `${REPO_CWD}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const firstFetch = createDeferred<void>();
const runGitFetch = vi.fn(async () => {
await firstFetch.promise;
@@ -1167,7 +1155,6 @@ describe("WorkspaceGitServiceImpl", () => {
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({
runGitCommand,
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
resolveAbsoluteGitDir,
@@ -1176,7 +1163,10 @@ describe("WorkspaceGitServiceImpl", () => {
});
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: secondCwd }, vi.fn());
const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1);
@@ -1187,8 +1177,6 @@ describe("WorkspaceGitServiceImpl", () => {
expect(hasOriginRemote).toHaveBeenCalledTimes(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
await waitForObservationToSettle(service, 2);
firstFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
@@ -162,6 +162,64 @@ describe("claude usage source", () => {
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...(hasWindows ? makeClaudeResponse() : {}),
extra_usage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1250,
currency: "USD",
},
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([
expect.objectContaining({
id: "extra_usage_monthly",
used: 12.5,
remaining: 37.5,
limit: 50,
unit: "usd",
}),
]);
expect(claude.details).toContainEqual(
expect.objectContaining({ id: "extra_usage", value: "Enabled" }),
);
});
it.each(["EUR", null, undefined])(
"does not report extra usage balances for currency %j",
async (currency) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...makeClaudeResponse(),
extra_usage: { monthly_limit: 5000, used_credits: 1250, currency },
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([]);
},
);
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
writeClaudeCredentials(claudeHome, "at_valid");
+28 -4
View File
@@ -68,6 +68,9 @@ const ClaudeUsageResponseSchema = z.object({
extra_usage: z
.object({
is_enabled: z.boolean().optional(),
monthly_limit: ApiNumberSchema.nullish(),
used_credits: ApiNumberSchema.nullish(),
currency: z.string().nullish(),
})
.nullish(),
});
@@ -491,6 +494,26 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec
: null;
}
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
if (used == null && limit == null) return [];
return [
{
id: "extra_usage_monthly",
label: "Extra usage",
used,
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
limit,
unit: "usd" as const,
tone: toneFromUsedPct(
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
),
},
];
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
@@ -594,20 +617,21 @@ export async function fetchUsage(
}
const details: UsageDetail[] = [];
const extraUsageEnabled = resp.extra_usage?.is_enabled;
if (extraUsageEnabled !== undefined) {
const extraUsage = resp.extra_usage;
if (extraUsage?.is_enabled !== undefined) {
details.push({
id: "extra_usage",
label: "Extra usage",
value: extraUsageEnabled ? "Enabled" : "Disabled",
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
});
}
const balances = extraUsageBalances(extraUsage);
return {
status: "available",
planLabel: plan ?? undefined,
windows,
balances: [],
balances,
details,
};
}
@@ -117,6 +117,56 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
]);
});
test("reports enterprise individual spend limit percentage and reset", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({
plan_type: "enterprise",
spend_control: {
individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 },
},
}),
);
expect(report).toMatchObject({
planLabel: "enterprise",
windows: [
expect.objectContaining({
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct: 5,
remainingPct: 95,
resetsAt: new Date(1795996800 * 1000).toISOString(),
}),
],
});
});
test.each([
[{ used_percent: 5 }, 5, 95],
[{ remaining_percent: 95 }, 5, 95],
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
[{ used_percent: 110 }, 110, 0],
])(
"completes individual spending percentages for %j",
async (individual_limit, usedPct, remainingPct) => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit } }),
);
expect(report).toMatchObject({
status: "available",
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
});
},
);
test("omits individual spend limit without a percentage", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }),
);
expect(report.windows).toEqual([]);
});
test("summarizes the session and weekly windows by default, not code review", async () => {
const report = await fetchUsage(
authInput(fixtureHome),
@@ -34,6 +34,15 @@ const rateLimitSchema = z.object({
primary_window: windowSchema.nullish(),
secondary_window: windowSchema.nullish(),
});
const spendControlSchema = z.object({
individual_limit: z
.object({
used_percent: number.nullish(),
remaining_percent: number.nullish(),
reset_at: number.nullish(),
})
.nullish(),
});
const responseSchema = z.object({
plan_type: z.string().optional(),
email: z.string().optional(),
@@ -48,6 +57,7 @@ const responseSchema = z.object({
)
.nullish(),
code_review_rate_limit: rateLimitSchema.nullish(),
spend_control: spendControlSchema.nullish(),
credits: z.object({ balance: number.optional() }).nullish(),
});
@@ -227,6 +237,27 @@ export async function fetchUsage(
},
});
}),
...(usage.spend_control?.individual_limit
? (() => {
const limit = usage.spend_control.individual_limit;
const usedPct =
limit.used_percent ??
(limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined);
if (usedPct === undefined) return [];
return [
{
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct,
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
resetsAt:
limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null,
tone: toneFromUsedPct(usedPct),
},
];
})()
: []),
...usageWindows({
rateLimit: usage.code_review_rate_limit,
scope: { id: "code_review", label: "Code review" },
+1 -1
View File
@@ -33,7 +33,7 @@ Arguments marked `?` are optional.
| -------------------- | -------------------------------------- | ------------------------------------------------------------------------------------- |
| `browser_snapshot` | `browserId` | Return the page as an accessibility tree with element refs. |
| `browser_screenshot` | `browserId, fullPage?` | Capture a PNG of the viewport, or the full page with `fullPage`. |
| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network entries: URL, timing, size and HTTP status. |
| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network timing entries. |
| `browser_wait` | `browserId, text? \| url?, timeoutMs?` | Wait until the page contains text or reaches a URL fragment (exactly one of the two). |
## Interacting
+1 -1
View File
@@ -31,7 +31,7 @@ nav: 工具参考
| -------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| `browser_snapshot` | `browserId` | 以带元素引用的无障碍树形式返回页面。 |
| `browser_screenshot` | `browserId, fullPage?` | 截取视口的 PNG;传入 `fullPage` 时截取整个页面。 |
| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络条目:URL、耗时、大小和 HTTP 状态码。 |
| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络耗时条目。 |
| `browser_wait` | `browserId, text? \| url?, timeoutMs?` | 等待页面包含某段文本或到达某个 URL 片段(两者必须且只能提供一个)。 |
## 交互 {#interacting}