mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9dc8751f | ||
|
|
751dc82e6c | ||
|
|
daf26892d3 | ||
|
|
c5cf6cbb4c | ||
|
|
78a089a14e | ||
|
|
ec3ae09926 |
@@ -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 {
|
||||
|
||||
@@ -648,6 +648,23 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
);
|
||||
}
|
||||
|
||||
test("cancels a background child from a task-id-only stopped notification on reopen", async () => {
|
||||
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<status>stopped</status>\n<summary>Background agent didn't finish before the previous session ended</summary>\n</task-notification>`;
|
||||
writeSession({
|
||||
parentLines: [
|
||||
...backgroundLaunch(),
|
||||
JSON.stringify({ type: "queue-operation", operation: "enqueue", content: text }),
|
||||
JSON.stringify({ type: "user", message: { role: "user", content: text } }),
|
||||
],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: "canceled",
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers a stopped notification over the child's end_turn", async () => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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