Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andfdkevin 0134781ab1 test: reproduce duplicate and blocked message retries
Preserve diagnosis coverage for accepted queued sends whose response is lost, both before and after canonical timeline arrival. These tests intentionally expose the remaining failure for issue #8997.

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
2026-11-07 10:22:51 +00:00
fdkevin 63d746c5b4 fix(app,client): preserve clientMessageId across queue drains and timeout retries 2026-11-07 17:39:57 +08:00
paseo-bot[bot] aac228e0a8 test(server): separate legacy plugin setup from migration deadline (#8983)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 09:01:32 +00:00
paseo-bot[bot] 0e40022519 Preserve linked resources when pasting prompts (#8972)
* fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.

* fix(app): keep pasted links undoable while drafting offline

Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads.

* fix(app): preserve copied Markdown with normalized links

Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact.

* fix(app): retain external linked tables when pasting prompts

Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells.

* test(app): name rich clipboard paste arguments

* fix(app): preserve pasted links around rowless tables

The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations.

* test(app): wait for workspace restore before undo teardown

The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 07:04:10 +00:00
paseo-bot[bot]andAbdulhakeem Mustapha 751dc82e6c Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973)
* fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval'

* test(desktop): browser_evaluate on pages that allow and forbid eval

* test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it

* fix(desktop): preserve page globals in browser evaluation

Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope.

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>

---------

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 06:03:12 +00:00
paseo-bot[bot] daf26892d3 Keep the provider's actual model after unavailable selections (#8957)
* fix(server): keep the model selected by the provider

A live model setter can retain or resolve a different model than the requested ID. Read its effective selection before persisting manager state so an ignored selection cannot overwrite the actual model. Preserve explicit preference resets.

* fix(server): preserve completed model selections

Return the effective selection from the provider mutation so a separate status request cannot reject an already completed switch or leave configured and runtime models inconsistent.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 04:18:27 +00:00
paseo-bot[bot] c5cf6cbb4c test(server): await shared fetch observation readiness (#8975)
Snapshot refresh completion does not mean both workspaces have joined the repository fetch target. Wait for observation setup before releasing the first fetch, and retain delayed checkout resolution to exercise that ordering.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 03:01:45 +00:00
Abdulhakeem Adetunji Mustapha 78a089a14e fix(desktop): browser_logs reports the HTTP status of each request (#8956)
* fix(desktop): browser_logs reports the HTTP status of each request

* test(desktop): browser_logs reports the status the browser recorded

* test(desktop): e2e checks browser_logs reports a 404 and a 200

* docs(browser): browser_logs also reports the HTTP status
2026-11-07 01:00:56 +00:00
paseo-bot[bot] ec3ae09926 Keep the native model when importing sessions (#8948)
* fix(server): preserve the selected model when importing sessions

Import preparation resolved a new-session catalog default and sent it as an explicit resume override. Keep import selections with the resumed provider and persist its reported model for subsequent reloads.

* fix(codex): allow the first reply after importing a session

Resolve collaboration settings again when default model and effort are applied so imported sessions send a complete first-turn request.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 00:08:25 +00:00
35 changed files with 1459 additions and 180 deletions
@@ -1,5 +1,11 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
fillComposerDraft,
copyLatestTurnIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -28,6 +34,7 @@ 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,
@@ -55,7 +62,9 @@ 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)
@@ -124,11 +133,15 @@ 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);
@@ -136,8 +149,62 @@ test("submitting a message leaves the composer ready for the next message", asyn
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
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);
} finally {
await agent.cleanup();
}
@@ -168,7 +235,11 @@ 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({
@@ -192,7 +263,9 @@ 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,6 +112,7 @@ 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 ({
+77 -10
View File
@@ -31,7 +31,9 @@ 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> {
@@ -156,7 +158,9 @@ 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(
@@ -218,18 +222,24 @@ 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. */
@@ -283,7 +293,10 @@ 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);
@@ -347,7 +360,11 @@ 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);
@@ -505,7 +522,10 @@ 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([]);
@@ -516,7 +536,10 @@ 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;
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
observer.observe(tab, {
attributes: true,
attributeFilter: ["aria-selected"],
});
shortcut("BracketLeft");
});
});
@@ -566,3 +592,44 @@ 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");
}
+181 -4
View File
@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from "vitest";
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 type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
@@ -806,12 +811,16 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([]);
});
it("removes the queued entry and submits its text + attachments", async () => {
it("removes the queued entry and submits its messageId + 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<{ text: string; attachments: ComposerAttachment[] }> = [];
const submitted: Array<{
messageId: string;
text: string;
attachments: ComposerAttachment[];
}> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
@@ -822,7 +831,65 @@ describe("sendQueuedComposerMessageNow", () => {
});
expect(result).toEqual({ status: "submitted" });
expect(queue.state.get("agent")).toEqual([]);
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
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"]);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
@@ -1123,3 +1190,113 @@ 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);
});
+12 -3
View File
@@ -188,6 +188,7 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
clientMessageId?: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -205,7 +206,7 @@ export async function dispatchComposerAgentMessage(
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const clientMessageId = generateMessageId();
const clientMessageId = input.clientMessageId ?? generateMessageId();
const userMessage = createUserMessage({
clientMessageId,
text: input.text,
@@ -295,7 +296,11 @@ export interface SendQueuedComposerMessageNowInput {
agentId: string;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
submitMessage: (input: {
messageId: string;
text: string;
attachments: ComposerAttachment[];
}) => Promise<void>;
failedToSendMessage?: string;
}
@@ -318,7 +323,11 @@ export async function sendQueuedComposerMessageNow(
return next;
});
try {
await input.submitMessage({ text: item.text, attachments: item.attachments });
await input.submitMessage({
messageId: item.id,
text: item.text,
attachments: item.attachments,
});
return { status: "submitted" };
} catch (error) {
input.queue.write((prev) => {
+52 -22
View File
@@ -82,6 +82,7 @@ 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";
@@ -1487,12 +1488,18 @@ 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);
@@ -1546,7 +1553,7 @@ function ComposerContentImpl({
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, submitAttachments: ComposerAttachment[]) => {
async (text: string, submitAttachments: ComposerAttachment[], messageId?: string) => {
onMessageSent?.();
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, attachments: submitAttachments, cwd });
@@ -1560,6 +1567,7 @@ function ComposerContentImpl({
text,
submitAttachments,
appSettings.sendBehavior === "steer" ? "steer" : "interrupt",
messageId,
);
},
[appSettings.sendBehavior, cwd, onMessageSent, t],
@@ -1575,29 +1583,51 @@ function ComposerContentImpl({
text: string,
sendAttachments: ComposerAttachment[],
activeTurnBehavior: "interrupt" | "steer",
explicitClientMessageId?: string,
) => {
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
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,
});
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;
}
onAttentionPromptSend?.();
};
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
@@ -1963,8 +1993,8 @@ function ComposerContentImpl({
agentId,
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
submitMessage: ({ messageId: queuedMessageId, text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments, queuedMessageId),
failedToSendMessage: t("composer.errors.failedToSend"),
});
if (result.status === "failed") {
+11 -6
View File
@@ -88,6 +88,7 @@ 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,
@@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteImagesEffectArgs {
interface PasteEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
@@ -522,7 +523,7 @@ interface PasteImagesEffectArgs {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
function usePasteEffect(args: PasteEffectArgs): void {
const {
textarea,
isConnected,
@@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
} = args;
useEffect(() => {
if (!isWeb || !onAddImages) return;
if (!isWeb) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) return;
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
event.preventDefault();
@@ -1811,7 +1816,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({
usePasteEffect({
textarea: webTextArea,
isConnected,
disabled,
@@ -0,0 +1,144 @@
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
@@ -0,0 +1,7 @@
/** Native inputs retain their platform clipboard handling. */
export function pasteLinkedClipboardText(
_input: HTMLElement,
_clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
return false;
}
@@ -0,0 +1,59 @@
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);
}
+2 -1
View File
@@ -2604,12 +2604,13 @@ export class HostRuntimeStore {
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => {
submitMessage: async ({ messageId, text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({
client,
agentId,
clientMessageId: messageId,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -849,3 +849,42 @@ 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();
});
});
+45 -2
View File
@@ -732,6 +732,29 @@ 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) => {
@@ -1041,16 +1064,29 @@ export const useSessionStore = create<SessionStore>()(
}
}
const acknowledgedIds = state.acknowledgedClientMessageIds ?? [];
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds ?? [],
acknowledgedIds,
);
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 && !changedTasks) {
if (
!changedTail &&
!changedHead &&
!changedSubmissions &&
!changedQueuedMessages &&
!changedTasks
) {
return prev;
}
@@ -1074,6 +1110,7 @@ export const useSessionStore = create<SessionStore>()(
agentStreamHead: nextHead,
agentTasks,
messageSubmissions,
queuedMessages,
},
},
};
@@ -1447,6 +1484,11 @@ 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);
@@ -1471,6 +1513,7 @@ export const useSessionStore = create<SessionStore>()(
agentAuthoritativeHistoryApplied: nextAuthoritative,
agentHistorySyncGeneration: nextSyncGeneration,
messageSubmissions,
queuedMessages,
},
},
};
+1
View File
@@ -1,5 +1,6 @@
declare module "turndown-plugin-gfm" {
import type TurndownService from "turndown";
export const tables: TurndownService.Plugin;
export const gfm: TurndownService.Plugin;
}
+6 -1
View File
@@ -1589,7 +1589,12 @@ test("defaults session RPC waiters to sixty seconds", async () => {
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
await expect(responsePromise).rejects.toMatchObject({
message: "Timeout waiting for message (60000ms)",
code: "DAEMON_REQUEST_TIMEOUT",
outcome: "unknown",
serverId: "srv_test_1",
});
});
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
+9 -1
View File
@@ -7745,7 +7745,15 @@ export class DaemonClient {
if (waiter) {
this.waiters.delete(waiter);
}
wrappedReject(timeoutError);
wrappedReject(
waiter?.submitted
? new DaemonConnectionError(timeoutError.message, timeoutError.code, {
serverId: this.destinationServerId,
outcome: "unknown",
cause: timeoutError,
})
: timeoutError,
);
}, timeout)
: null;
+112 -1
View File
@@ -213,8 +213,18 @@ async function settleDesktopStartup(page) {
}
}
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const server = createServer((_request, response) => {
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;
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
@@ -246,6 +256,67 @@ 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(
@@ -290,6 +361,42 @@ 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`),
@@ -675,6 +782,8 @@ 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");
@@ -1020,6 +1129,7 @@ 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) => {
@@ -1106,6 +1216,7 @@ async function runRegression({
snapshot: "passed",
click: "passed",
localPageSelectors: "passed",
evaluateWithoutUnsafeEval: "passed",
};
}
@@ -1,4 +1,5 @@
import { resolve as resolvePath } from "node:path";
import { createContext, runInContext } from "node:vm";
import { describe, expect, test, vi } from "vitest";
import type {
@@ -434,6 +435,54 @@ 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",
@@ -1406,6 +1455,72 @@ 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" };
@@ -1558,6 +1673,99 @@ 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,4 +1,5 @@
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
import { Script } from "node:vm";
import type {
BrowserAutomationCommand,
@@ -849,6 +850,11 @@ 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(
@@ -1550,14 +1556,36 @@ 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 () => {
return String.raw`(async (readFunction) => {
const __PASEO_BROWSER_EVALUATE__ = true;
try {
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
const userFunction = readFunction();
if (typeof userFunction !== 'function') {
throw new Error('browser_evaluate input must evaluate to a function.');
}
@@ -1582,7 +1610,7 @@ function buildEvaluateScript(
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
})()`;
})(() => (${functionSource}))`;
}
async function focusKeypressTarget(
@@ -1628,6 +1656,8 @@ 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,6 +700,71 @@ 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();
@@ -4699,6 +4764,51 @@ 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");
@@ -5370,6 +5480,9 @@ 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 } };
}
@@ -5452,6 +5565,63 @@ 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,6 +1679,7 @@ export class AgentManager {
cwd: input.cwd,
},
resolvedAgentId,
{ resolveDefaultModel: false },
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
@@ -1705,8 +1706,13 @@ 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),
stripInternalPaseoMcpServer({
...imported.config,
model: runtimeInfo.model ?? imported.config.model,
}),
{ resolveDefaultModel: false },
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
@@ -2328,14 +2334,16 @@ export class AgentManager {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
agent.config.model = normalizedModelId ?? undefined;
// 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;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
@@ -5723,11 +5731,12 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
options: NormalizeConfigOptions = {},
): 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,7 +720,9 @@ export interface AgentSession {
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
/** 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 }>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -1400,9 +1400,10 @@ class PluginAgentSession implements AgentSession {
}));
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
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.toBeUndefined();
await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" });
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"),
@@ -2281,16 +2281,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (!modelId) {
this.currentModel = null;
return;
return { model: this.currentModel };
}
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<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
@@ -2800,6 +2800,7 @@ 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,6 +1379,42 @@ 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,6 +5554,7 @@ 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<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return;
return { model: modelToId(this.state.model) };
}
if (!parsedReference.provider) {
throw new Error(`OMP model id must include a provider: ${modelId}`);
@@ -1314,6 +1314,7 @@ 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,6 +367,7 @@ 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<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return;
return { model: modelToId(this.state.model) };
}
if (!parsedReference.provider) {
throw new Error(`Pi model id must include a provider: ${modelId}`);
@@ -1716,6 +1716,7 @@ 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,15 +672,26 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
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-"));
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-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-"));
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"),
@@ -703,13 +714,11 @@ export default function contribute(server: PluginServerContext) {
["tag", "v1"],
])
await runGitCommand(args, { cwd: repository });
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()}`);
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 checkoutRoot = path.join(legacyRoot, "checkout");
const directory = path.join(checkoutRoot, pluginPath);
directory = path.join(checkoutRoot, pluginPath);
await mkdir(legacyRoot, { recursive: true });
await runGitCommand(["clone", remote, checkoutRoot], { cwd: home });
await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot });
@@ -726,95 +735,111 @@ export default function contribute(server: PluginServerContext) {
},
}),
);
const settingsDirectory = path.join(home, "plugin-settings");
settingsDirectory = path.join(home, "plugin-settings");
await mkdir(path.join(settingsDirectory, id), { recursive: true });
const settingsFile = path.join(settingsDirectory, id, "preferences.json");
const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
settingsFile = path.join(settingsDirectory, id, "preferences.json");
savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } });
await writeFile(settingsFile, savedSettings);
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 {
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 },
});
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 [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",
const open = () =>
bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.8.0", {
managedSources: new ManagedPluginSources(home),
settingsDirectory,
}),
);
} finally {
await service.stopAllPlugins();
}
},
60_000,
);
let service = open();
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",
);
} 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,6 +1134,18 @@ 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;
@@ -1155,6 +1167,7 @@ describe("WorkspaceGitServiceImpl", () => {
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({
runGitCommand,
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
resolveAbsoluteGitDir,
@@ -1163,10 +1176,7 @@ describe("WorkspaceGitServiceImpl", () => {
});
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
const second = service.registerWorkspace({ cwd: secondCwd }, vi.fn());
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1);
@@ -1177,6 +1187,8 @@ describe("WorkspaceGitServiceImpl", () => {
expect(hasOriginRemote).toHaveBeenCalledTimes(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
await waitForObservationToSettle(service, 2);
firstFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);
+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 timing entries. |
| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network entries: URL, timing, size and HTTP status. |
| `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?` | 读取最近的控制台消息和网络耗时条目。 |
| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络条目:URL、耗时、大小和 HTTP 状态码。 |
| `browser_wait` | `browserId, text? \| url?, timeoutMs?` | 等待页面包含某段文本或到达某个 URL 片段(两者必须且只能提供一个)。 |
## 交互 {#interacting}