mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0134781ab1 | ||
|
|
63d746c5b4 | ||
|
|
aac228e0a8 | ||
|
|
0e40022519 | ||
|
|
751dc82e6c | ||
|
|
daf26892d3 | ||
|
|
c5cf6cbb4c | ||
|
|
78a089a14e | ||
|
|
ec3ae09926 |
@@ -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 ({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
declare module "turndown-plugin-gfm" {
|
||||
import type TurndownService from "turndown";
|
||||
|
||||
export const tables: TurndownService.Plugin;
|
||||
export const gfm: TurndownService.Plugin;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user