Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andfdkevin 4c62a88a1c test(server): preserve startup notice diagnosis cases
Record the duplicate notice on reload, the resumed-session startup-row contract, and distinct newly supplied notifications that share text. These diagnostic tests intentionally expose unresolved behavior.

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
2026-11-07 12:44:59 +00:00
fdkevin 6394cc2d0c fix(server): check durable timeline prefix and preserve version-switch notices 2026-11-07 18:50:47 +08:00
fdkevin d765000f44 fix(server): avoid duplicating startup notices when timeline is already primed 2026-11-07 17:19:29 +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
28 changed files with 1326 additions and 152 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");
}
+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);
}
+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;
}
+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 {
@@ -12480,8 +12650,10 @@ test("commits startup notices once on create and after restored history", async
},
};
const limitedToolOutput = limitAgentTimelineItemContent(toolOutput);
let activeNotice = notice;
let lastSession: NotifyingSession | null = null;
class NotifyingSession extends TestAgentSession {
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
readonly initialTimeline = [{ item: activeNotice, timestamp: "2026-09-22T00:00:00.000Z" }];
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
@@ -12500,10 +12672,14 @@ test("commits startup notices once on create and after restored history", async
}
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new NotifyingSession(config);
const session = new NotifyingSession(config);
lastSession = session;
return session;
}
override async resumeSession() {
return new NotifyingSession({ provider: "codex", cwd: workdir });
const session = new NotifyingSession({ provider: "codex", cwd: workdir });
lastSession = session;
return session;
}
})();
const store = new RecordingTimelineStore();
@@ -12517,6 +12693,55 @@ test("commits startup notices once on create and after restored history", async
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {});
ids.push(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
await manager.reloadAgentSession(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
await manager.closeAgent(created.id);
await manager.resumeAgentFromPersistence(
{
provider: "codex",
sessionId: "existing-created",
metadata: { cwd: workdir },
},
undefined,
created.id,
);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
const followUp: AgentTimelineItem = {
type: "user_message",
text: "after cold restore",
messageId: "msg-after-cold-restore",
};
lastSession!.pushEvent({ type: "timeline", provider: "codex", item: followUp });
await manager.reloadAgentSession(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([
notice,
followUp,
]);
const v1Notice: AgentTimelineItem = {
type: "notification",
level: "info",
message: "This chat uses OpenCode v1.",
};
const v2Notice: AgentTimelineItem = {
type: "notification",
level: "info",
message: "This chat uses OpenCode v2.",
};
activeNotice = v1Notice;
await manager.reloadAgentSession(created.id);
activeNotice = v2Notice;
await manager.reloadAgentSession(created.id);
activeNotice = v1Notice;
await manager.reloadAgentSession(created.id);
await manager.reloadAgentSession(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([
notice,
followUp,
v1Notice,
v2Notice,
v1Notice,
]);
activeNotice = notice;
const resumed = await manager.resumeAgentFromPersistence({
provider: "codex",
sessionId: "existing",
@@ -12543,6 +12768,34 @@ test("commits startup notices once on create and after restored history", async
}
});
test("commits distinct new startup rows even when notification text matches", async () => {
const notice = { type: "notification", level: "warning", message: "Runtime warning" } as const;
const initialTimeline = [
{ item: notice, timestamp: "2026-11-07T00:00:00.000Z" },
{ item: notice, timestamp: "2026-11-07T00:01:00.000Z" },
];
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new (class extends TestAgentSession {
readonly initialTimeline = initialTimeline;
})(config);
}
})();
const manager = new AgentManager({ clients: { [client.provider]: client }, logger });
const agent = await manager.createAgent(
{ provider: client.provider, cwd: tmpdir() },
undefined,
{},
);
try {
expect(
(await manager.getTimelineRows(agent.id)).map(({ item, timestamp }) => ({ item, timestamp })),
).toEqual(initialTimeline);
} finally {
await manager.closeAgent(agent.id);
}
});
test("failed startup history closes the session without registering an agent", async () => {
let closed = false;
class FailingHistorySession 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);
@@ -4021,9 +4029,11 @@ export class AgentManager {
// Legacy/imported chats need their existing history before startup rows.
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
} else {
for (const entry of buildImportedTimelineRows(session.initialTimeline)) {
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
}
await this.recordInitialTimelineIfMissing({
agent: managed,
initialTimeline: session.initialTimeline,
durableTimelineHasRows,
});
}
this.refreshSessionPersistence(managed);
}
@@ -4216,6 +4226,37 @@ export class AgentManager {
};
}
private async recordInitialTimelineIfMissing(options: {
agent: ActiveManagedAgent;
initialTimeline: NonNullable<AgentSession["initialTimeline"]>;
durableTimelineHasRows: boolean;
}): Promise<void> {
const { agent, initialTimeline, durableTimelineHasRows } = options;
const inMemoryRows = this.timelineStore.getRows(agent.id);
const firstInMemorySeq = inMemoryRows[0]?.seq ?? Number.POSITIVE_INFINITY;
const existingItems: AgentTimelineItem[] = [];
if (durableTimelineHasRows && this.durableTimelineStore && firstInMemorySeq > 1) {
for (const row of await this.durableTimelineStore.getCommittedRows(agent.id)) {
if (row.seq < firstInMemorySeq) {
existingItems.push(row.item);
}
}
}
for (const row of inMemoryRows) {
existingItems.push(row.item);
}
for (const entry of buildImportedTimelineRows(initialTimeline)) {
if (
entry.item.type === "notification" &&
isDuplicateInitialTimelineNotification({ existingItems, incoming: entry.item })
) {
continue;
}
this.recordTimeline(agent.id, entry.item, { timestamp: entry.timestamp });
existingItems.push(entry.item);
}
}
private prepareAgentForClosure(
agent: LiveManagedAgent,
cancelReason: string,
@@ -5723,11 +5764,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)
@@ -5957,6 +5999,31 @@ export class AgentManager {
}
}
function isDuplicateInitialTimelineNotification(input: {
existingItems: readonly AgentTimelineItem[];
incoming: Extract<AgentTimelineItem, { type: "notification" }>;
}): boolean {
const { existingItems, incoming } = input;
const isOpenCodeRuntimeVersionNotice = /^This chat uses OpenCode v\d+\.$/.test(incoming.message);
if (isOpenCodeRuntimeVersionNotice) {
const lastVersionNotice = existingItems.findLast(
(item): item is Extract<AgentTimelineItem, { type: "notification" }> =>
item.type === "notification" && /^This chat uses OpenCode v\d+\.$/.test(item.message),
);
return (
lastVersionNotice !== undefined &&
lastVersionNotice.level === incoming.level &&
lastVersionNotice.message === incoming.message
);
}
return existingItems.some(
(item) =>
item.type === "notification" &&
item.level === incoming.level &&
item.message === incoming.message,
);
}
function matchesImportableSessionQuery(
session: ImportableProviderSession,
rawQuery: string | undefined,
@@ -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>;
@@ -1724,6 +1724,72 @@ test("commits notices emitted during session open as the initial timeline with s
);
});
test("reopening a plugin agent does not append the same startup notice", async () => {
await withObservedProvider(
{
openChildren(sessionId, emit) {
emit({
type: "session.notice",
sessionId,
notice: {
id: "full-access",
severity: "warning",
title: "Full access",
},
});
},
},
async (client) => {
const manager = new AgentManager({
clients: { [client.provider]: client },
logger: createTestLogger(),
});
const agent = await manager.createAgent(
{ provider: client.provider, cwd: tmpdir() },
undefined,
{},
);
try {
for (let attempt = 0; attempt < 4; attempt++) await manager.reloadAgentSession(agent.id);
expect(
(await manager.getTimelineRows(agent.id)).filter(
(row) => row.item.type === "notification",
),
).toHaveLength(1);
} finally {
await manager.closeAgent(agent.id);
}
},
);
});
test("a resumed plugin session offers only new startup rows to the manager", async () => {
await withObservedProvider(
{
openChildren(sessionId, emit) {
emit({
type: "session.notice",
sessionId,
notice: { id: "full-access", severity: "warning", title: "Full access" },
});
},
},
async (client) => {
const config = { provider: client.provider, cwd: tmpdir() };
const original = await client.createSession(config);
const handle = original.describePersistence()!;
expect(original.initialTimeline).toHaveLength(1);
await original.close();
const resumed = await client.resumeSession(handle, config);
try {
expect(resumed.initialTimeline).toEqual([]);
} finally {
await resumed.close();
}
},
);
});
describe("pending provider responses", () => {
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
"preserves the send error for %s",
@@ -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}