fix(test): retire MCP managers between shared test files (#159800)

* fix(test): retire MCP managers between shared test files

Drain the current manager before resetting file-scoped modules, preserve replacement and uncertain custody, and carry earlier disposal failures into later global cleanup scopes. Keep the existing best-effort disposal contract.

* fix(test): settle MCP clients before closing HTTP fixtures

Keep intentional failed retirement on a fixture-owned real manager, drain shared clients before server closure, and join fixture shutdown. Preserve all failure assertions and custody guards. Extract the three HTTP lifecycle cases without changing their registration order. Final runtime owner suite passes all 114 cases with clean teardown.

* fix(test): resolve deferred helper under raw Node

Import the canonical dependency-free deferred implementation by its real
TypeScript path so standalone shutdown fixtures do not rely on Vitest aliases.

The exact preimage reproduces module-not-found; all 21 raw shutdown cases, both
shared-runner cases, and affected static checks pass on macOS Node 26.10.

* test(ui): wait for history before asserting effort controls

Model browsing can become ready while chat history is still hydrating. Bind the existing partial-refresh fixture to the successful history response and current rendered session before checking effort controls, preserving the immediate enabled-state assertion.
This commit is contained in:
Peter Steinberger 2026-10-01 16:34:10 -07:00 • committed by GitHub
parent db95710e23
commit a6089edc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 560 additions and 141 deletions

View file

@ -64,6 +64,12 @@ runners and registries. These helpers reuse their core owners; register the
session fixture lifecycle explicitly. Use published runtime subpaths when
they already expose the needed operation.
After closing retained plugin runtime handles, call `resetPluginRuntimeStateForTest()`
and await `waitForPluginCacheRetirement(true)` from `plugin-test-runtime` to include
borrowed cache generations. Assert that
its `failures` array is empty before deleting fixture files or restoring the
environment. A rejected or failed retirement must leave the fixture intact.
Await `listChannelIngressQueueAccountIdsForTests` from
`channel-ingress-test-runtime` or `plugin-state-test-runtime`. It uses the shared
read-only worker and leaves missing state uncreated. Join asynchronous database

View file

@ -9,6 +9,7 @@ import {
createTestRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
waitForPluginCacheRetirement,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@ -37,6 +38,12 @@ describe("system-agent delegation scenario tool policy", () => {
gatewayToken: "qa-test-token",
workspaceDir,
});
// Core delegate policy does not need the Gateway's memory tools or QA restart probes.
baseline.plugins = {
allow: ["qa-channel"],
slots: { memory: "none" },
entries: { "qa-channel": { enabled: true } },
};
const scenario = readQaScenarioById("system-agent-delegation-generation");
// Match the suite owner's merge of validated catalog patches into the typed QA config.
config = applyQaMergePatch(baseline, scenario.gatewayConfigPatch ?? {}) as OpenClawConfig;
@ -44,6 +51,8 @@ describe("system-agent delegation scenario tool policy", () => {
afterEach(async () => {
resetPluginRuntimeStateForTest();
const { failures } = await waitForPluginCacheRetirement(true);
expect(failures).toEqual([]);
await fs.rm(workspaceDir, { recursive: true, force: true });
});

View file

@ -121,6 +121,7 @@ function scopedCatalogToolsSignature(tools: readonly McpCatalogTool[]): string {
}
export function createSessionMcpRuntimeManagerLifecycle(store: SessionMcpRuntimeManagerStore) {
let cleanupUncertain = false;
const schedulers = new Set<GatewayScheduler>();
let schedulerScope = store.scheduler.scope();
const reserveRuntimeSlot = (
@ -181,6 +182,7 @@ export function createSessionMcpRuntimeManagerLifecycle(store: SessionMcpRuntime
store.runtimeSlots.delete(runtime);
}
} catch (error) {
cleanupUncertain = true;
recordAgentCleanupFailure();
throw error;
}
@ -189,6 +191,7 @@ export function createSessionMcpRuntimeManagerLifecycle(store: SessionMcpRuntime
const disposal = Promise.resolve()
.then(close)
.catch((error: unknown) => {
cleanupUncertain = true;
recordAgentCleanupFailure();
throw error;
})
@ -460,6 +463,10 @@ export function createSessionMcpRuntimeManagerLifecycle(store: SessionMcpRuntime
if (store.disposalInFlight === disposal) {
store.disposalInFlight = undefined;
}
// Unpublished runtimes can fail before this caller opens its cleanup scope.
if (sessionId === undefined && cleanupUncertain) {
recordAgentCleanupFailure();
}
});
};

View file

@ -219,6 +219,19 @@ describe("MCP manager creation ownership", () => {
expect(runtime.dispose).toHaveBeenCalledOnce();
expect(cleanupScope.outcome).toBe("uncertain");
expect(manager.listRuntimeKeys()).toEqual([]);
const otherSession = "unrelated-session";
await manager.getOrCreate({ ...params, sessionId: otherSession });
const targetedScope = createAgentCleanupScope();
await targetedScope.run(() => manager.disposeSession(otherSession));
expect(targetedScope.outcome).toBe("closed");
for (let attempt = 0; attempt < 2; attempt++) {
const laterScope = createAgentCleanupScope();
await laterScope.run(() => manager.disposeAll());
expect(laterScope.outcome).toBe("uncertain");
}
expect(runtime.dispose).toHaveBeenCalledOnce();
});
it("constructs and retires an empty manager without binding or importing transports", async () => {

View file

@ -214,6 +214,13 @@ it("discovers an installed Agent Plugins bundle and executes its real stdio tool
pluginDataExists: true,
argv: [expandedMarkerPath],
});
} catch (error) {
console.error(
"Agent Plugins bundle failed before MCP cleanup",
error,
runtime.peekCatalog()?.diagnostics,
);
throw error;
} finally {
await materialized.dispose();
await disposeAllSessionMcpRuntimes();

View file

@ -3840,65 +3840,14 @@ describe("disposeSession timeout", () => {
{ timeout: 15_000 },
async () => {
testing.setBundleMcpDisposeTimeoutMsForTest(50);
const sessionId = "test-session-" + Date.now();
const server = http.createServer((req, res) => {
if (req.method === "GET") {
res.writeHead(405).end();
return;
}
if (req.method === "DELETE") {
// Never respond — simulates a hung terminateSession() DELETE.
return;
}
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
const message = JSON.parse(body);
res.setHeader("content-type", "application/json");
res.setHeader("mcp-session-id", sessionId);
if (message.method === "initialize") {
res.writeHead(200).end(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
capabilities: { tools: {} },
serverInfo: { name: "hanging-delete-server", version: "1.0.0" },
},
}),
);
} else if (message.method === "notifications/initialized") {
res.writeHead(202).end();
} else if (message.method === "tools/list") {
res.writeHead(200).end(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{ name: "probe", description: "probe", inputSchema: { type: "object" } }],
},
}),
);
} else {
res.writeHead(200).end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} }));
}
});
const manager = createSessionMcpRuntimeManager();
const termination = createDeferred();
const server = await startCatalogRecoveryMcpServer("hanging-delete-server", {
holdTermination: termination.promise,
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const addr = server.address() as { port: number };
try {
const runtime = await getOrCreateSessionMcpRuntime({
const runtime = await manager.getOrCreate({
sessionId: "session-streamable-http-dispose",
sessionKey: "agent:test:session-streamable-http-dispose",
workspaceDir: "/workspace",
@ -3906,7 +3855,7 @@ describe("disposeSession timeout", () => {
mcp: {
servers: {
hangingDelete: {
url: `http://127.0.0.1:${addr.port}/mcp`,
url: server.url,
transport: "streamable-http",
},
},
@ -3923,10 +3872,9 @@ describe("disposeSession timeout", () => {
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(1_000);
await retireSessionMcpRuntime({
sessionId: runtime.sessionId,
reason: "external retirement before final run cleanup",
});
expect(server.terminationCount()).toBe(1);
await manager.disposeSession(runtime.sessionId);
expect(manager.listRuntimeKeys()).toEqual([]);
const cleanupScope = createAgentCleanupScope();
await cleanupScope.run(async () => {
await expect(materialized.dispose()).rejects.toThrow("could not confirm closure");
@ -3934,7 +3882,9 @@ describe("disposeSession timeout", () => {
});
expect(cleanupScope.outcome).toBe("uncertain");
} finally {
server.close();
termination.resolve();
await manager.disposeAll();
await server.close();
}
},
);

View file

@ -84,6 +84,7 @@ export {
export { addTestHook } from "../plugins/hooks.test-helpers.js";
export { createPluginRecord } from "../plugins/status.test-helpers.js";
export { createPluginMetadataSnapshotFixture } from "../plugins/plugin-metadata.test-support.js";
export { waitForPluginCacheRetirement } from "../plugins/plugin-cache.js";
export { useProviderCatalogMetadata } from "./test-helpers/provider-catalog.js";
export { useProviderToolSchemaRuntimeForTest } from "./test-helpers/provider-tool-schemas.test-support.js";
export { useBundledProviderPolicyArtifactsForTest } from "./test-helpers/provider-policy-artifacts.test-support.js";

View file

@ -1,10 +1,14 @@
import { fileURLToPath } from "node:url";
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
import type { VitestWorkerRun } from "../../scripts/lib/vitest-worker-run.mts";
import { createDeferredCore } from "../../src/shared/deferred.ts";
import { createBoundedChildOutput } from "./bounded-child-output.ts";
/** Capture fixture diagnostics without losing the managed cancellation outcome. */
export async function runVitestShutdownCommand({
maxBytes = 2 * 1024 * 1024,
signal,
workerRun,
...options
}: Pick<
Parameters<typeof runManagedCommand>[0],
@ -12,20 +16,35 @@ export async function runVitestShutdownCommand({
> & {
bin?: string;
maxBytes?: number;
workerRun?: VitestWorkerRun;
}) {
const stdout = createBoundedChildOutput(maxBytes);
const stderr = createBoundedChildOutput(maxBytes);
const controller = new AbortController();
const workerCompletion = workerRun ? createDeferredCore<number>() : undefined;
let borrowerCompletion: Promise<number> | undefined;
let overflow: Error | undefined;
try {
const code = await runManagedCommand({
...options,
bin: options.bin ?? process.execPath,
args: workerRun
? [
fileURLToPath(
new URL("../../scripts/lib/vitest-worker-bootstrap.mts", import.meta.url),
),
workerRun.descriptor.directory,
...(options.args ?? []),
]
: options.args,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
stdio: workerRun ? ["ignore", "pipe", "pipe", "ipc"] : ["ignore", "pipe", "pipe"],
requireProcessTreeExit: process.platform !== "win32",
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
onReady(child) {
if (workerRun && workerCompletion) {
borrowerCompletion = workerRun.borrow(child, workerCompletion.promise);
}
for (const [pipe, output] of [
[child.stdout, stdout],
[child.stderr, stderr],
@ -46,14 +65,21 @@ export async function runVitestShutdownCommand({
options.onReady?.(child);
},
});
workerCompletion?.resolve(code);
await borrowerCompletion;
return { code, stdout: stdout.text(), stderr: stderr.text() };
} catch (cause) {
// Cleanup failures remain primary; overflowing output must not conceal live writers.
const aborted = cause instanceof Error && "code" in cause && cause.code === "ABORT_ERR";
const error = aborted && overflow ? overflow : cause;
throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
const failure = aborted && overflow ? overflow : cause;
const error = Object.assign(failure instanceof Error ? failure : new Error(String(failure)), {
stdout: stdout.text(),
stderr: stderr.text(),
});
if (borrowerCompletion) {
workerCompletion?.reject(error);
await Promise.allSettled([borrowerCompletion]);
}
throw error;
}
}

View file

@ -39,27 +39,37 @@ function bindings() {
_customElementDefinitions: JsdomCustomElementDefinition[];
};
} = require("jsdom/lib/generated/idl/CustomElementRegistry.js");
return { utils, eventTarget, blob, formData, registry };
const document: {
convert(
window: object,
value: Document,
): {
_lastFocusedElement: object | null;
_clearDOMSelector(): void;
};
} = require("jsdom/lib/generated/idl/Document.js");
return { utils, eventTarget, blob, formData, registry, document };
}
// jsdom 30.1.1 follows the unfocusing steps: blur() focuses the document viewport, so
// hasFocus() stays true. A fresh document has no focused area; restore that state.
export function clearJsdomViewportFocus(document: Document): void {
const impl = bindings().utils.implForWrapper(document) as {
_lastFocusedElement?: unknown;
_clearDOMSelector?: () => void;
} | null;
if (impl && impl._lastFocusedElement === impl) {
const impl = bindings().document.convert(globalThis, document);
// blur() focuses the viewport in jsdom 30.1.1; preserve element focus and clear only that viewport.
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
if (impl._lastFocusedElement === impl) {
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
impl._lastFocusedElement = null;
impl._clearDOMSelector?.();
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
impl._clearDOMSelector();
}
}
export function jsdomCustomElementDefinitions(registry: object) {
const native = bindings().registry;
return native.is(registry)
? native.convert(globalThis, registry)._customElementDefinitions
: undefined;
if (!native.is(registry)) {
return undefined;
}
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
return native.convert(globalThis, registry)._customElementDefinitions;
}
function installJsdomWindowAdapter(): void {
@ -73,8 +83,10 @@ function installJsdomWindowAdapter(): void {
const result = setup(wrapper, window, ...args);
// Window initializes its EventTarget with itself as the global object.
// Register Bun's distinct proxy here so iframe windows receive the same repair.
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
if (wrapper === window && utils.implForWrapper(window._globalProxy) === null) {
utils.registerWrapper(
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
window._globalProxy,
utils.implForWrapper(window),
eventTarget.interfaceDescriptor,
@ -85,7 +97,9 @@ function installJsdomWindowAdapter(): void {
}
export function installJsdomEnvironmentAdapter(environment: Environment): void {
if (Object.hasOwn(environment, adapterInstalled)) return;
if (Object.hasOwn(environment, adapterInstalled)) {
return;
}
Object.defineProperty(environment, adapterInstalled, { value: true });
// Bun also needs this repair for direct JSDOM consumers in Node-environment tests.
if (process.versions.bun) {
@ -102,6 +116,7 @@ export function installJsdomEnvironmentAdapter(environment: Environment): void {
function installWebApis(target: object, window: DOMWindow) {
const { blob, formData } = bindings();
const toNativeBlob = (value: Blob) =>
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
new NativeBlob([blob.convert(window, value)._bytes], { type: value.type });
const toNativeBody = (value: BodyInit): BodyInit => {
if (blob.is(value)) {
@ -116,6 +131,7 @@ export function installJsdomEnvironmentAdapter(environment: Environment): void {
// The filename overload would rebuild this through jsdom's global File.
result.append(
name,
// oxlint-disable-next-line eslint/no-underscore-dangle -- jsdom owns this implementation member name.
new NativeFile([blob.convert(window, entry)._bytes], entry.name, {
type: entry.type,
lastModified: entry.lastModified,
@ -161,8 +177,11 @@ export function installJsdomEnvironmentAdapter(environment: Environment): void {
await result.teardown(target);
} finally {
for (const [key, descriptor] of originals) {
if (descriptor) Object.defineProperty(target, key, descriptor);
else Reflect.deleteProperty(target, key);
if (descriptor) {
Object.defineProperty(target, key, descriptor);
} else {
Reflect.deleteProperty(target, key);
}
}
}
},

View file

@ -0,0 +1,201 @@
import path from "node:path";
export function mcpManagerFixtureFiles(repoRoot: string): Record<string, string> {
const source = (name: string) => JSON.stringify(path.join(repoRoot, "src", name));
const imports = `import { expect, it, vi } from "vitest";
const key = Symbol.for("openclaw.sessionMcpRuntimeManager");
const probeKey = Symbol.for("fixture.sessionMcpManager");
async function fixtureManager() {
const { bindSessionMcpRuntimeTestScheduler } = await import(${source("agents/agent-bundle-mcp-manager.test-support.ts")});
await bindSessionMcpRuntimeTestScheduler();
const { getSessionMcpRuntimeManagerForTesting } = await import(${source("agents/agent-bundle-mcp-manager-api.ts")});
return getSessionMcpRuntimeManagerForTesting();
}
`;
return {
"09-mcp-a-mocked-owner.test.ts": `${imports}
vi.mock(${source("plugins/plugin-metadata-snapshot.ts")}, () => ({}));
it("retains a real MCP manager created under a file-owned metadata mock", async () => {
const manager = await fixtureManager();
await manager.disposeAll();
expect(globalThis[key]).toBe(manager);
});
`,
"09-mcp-b-installed-bundle.test.ts": `import ${source("agents/agent-bundle-mcp-runtime.agent-bundle.test.ts")};
`,
"09-mcp-c-replaced-owner.test.ts": `${imports}
it("cancels the agent before publishing a successor during MCP disposal", async () => {
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "replacement", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
const join = lease.runtime.joinCleanup.bind(lease.runtime);
await vi.resetModules();
const { createSessionMcpRuntimeManager } = await import(${source("agents/agent-bundle-mcp-manager.test-support.ts")});
const successor = createSessionMcpRuntimeManager();
const probe = globalThis[probeKey] = { successor, joined: false, cancelled: false };
let cancel;
const cancelled = new Promise(resolve => { cancel = resolve; });
const { ACTIVE_EMBEDDED_RUNS } = await import(${source("agents/embedded-agent-runner/run-state.ts")});
ACTIVE_EMBEDDED_RUNS.set("mcp-dependent-run", { cancel() { probe.cancelled = true; cancel(); } });
lease.runtime.joinCleanup = async () => {
expect(probe.cancelled, "agent cancellation must release the MCP cleanup barrier").toBe(true);
await cancelled;
await join();
globalThis[key] = successor;
probe.joined = true;
};
});
`,
"09-mcp-d-current-owner.test.ts": `${imports}
it("keeps the replacement MCP manager usable after prior-owner cleanup", async () => {
const probe = globalThis[probeKey];
expect(probe.joined).toBe(true);
expect(probe.cancelled).toBe(true);
expect(globalThis[key]).toBe(probe.successor);
const { acquireSessionMcpRuntime } = await import(${source("agents/agent-bundle-mcp-manager-api.ts")});
const lease = await acquireSessionMcpRuntime({ sessionId: "successor", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false } } });
expect(lease.runtime.sessionId).toBe("successor");
lease.releaseLease();
delete globalThis[probeKey];
});
`,
"09-mcp-e-mocked-disposer.test.ts": `${imports}
it("leaves a retained MCP session behind a file-owned disposal spy", async () => {
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "mocked-dispose", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
globalThis[probeKey] = { manager };
vi.spyOn(manager, "disposeAll").mockResolvedValue(undefined);
});
`,
"09-mcp-f-disposal-custody.test.ts": `${imports}
it("restores the MCP disposal spy and closes its real owner", () => {
const { manager } = globalThis[probeKey];
expect(globalThis[key]).toBeUndefined();
expect(manager.listRuntimeKeys()).toEqual([]);
delete globalThis[probeKey];
});
`,
"97-mcp-a-cancel-failure.test.ts": `${imports}
import path from "node:path";
it("keeps run-owned resources when cancellation fails", async () => {
const runState = await import(${source("agents/embedded-agent-runner/run-state.ts")});
const { openOpenClawStateDatabase } = await import(${source("state/openclaw-state-db.ts")});
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "cancel-failure", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: path.join(import.meta.dirname, "cancel-state") } });
const handle = { cancel() { throw new Error("Synthetic run cancellation failed"); } };
const baseline = {
env: process.env.OPENCLAW_MCP_CANCEL_FIXTURE,
global: globalThis.__openclawMcpCancelFixture,
hasGlobal: Object.hasOwn(globalThis, "__openclawMcpCancelFixture"),
};
const probe = globalThis[probeKey] = { manager, runState, database, handle, baseline, closes: 0 };
vi.stubEnv("OPENCLAW_MCP_CANCEL_FIXTURE", "file-owned");
vi.stubGlobal("__openclawMcpCancelFixture", "file-owned");
const join = lease.runtime.joinCleanup.bind(lease.runtime);
lease.runtime.joinCleanup = async () => { probe.closes++; await join(); };
runState.ACTIVE_EMBEDDED_RUNS.set("cancel-failure", handle);
});
`,
"97-mcp-b-cancel-custody.test.ts": `${imports}
it("preserves active runs and their module, MCP and database owners after failed cancellation", async () => {
const probe = globalThis[probeKey];
const runState = await import(${source("agents/embedded-agent-runner/run-state.ts")});
try {
expect(process.env.OPENCLAW_MCP_CANCEL_FIXTURE, "file environment must be restored after failed cancellation").toBe(probe.baseline.env);
expect(globalThis.__openclawMcpCancelFixture, "file global must be restored after failed cancellation").toBe(probe.baseline.global);
expect(Object.hasOwn(globalThis, "__openclawMcpCancelFixture")).toBe(probe.baseline.hasGlobal);
expect(probe.closes, "MCP disposal must not follow failed run cancellation").toBe(0);
expect(globalThis[key]).toBe(probe.manager);
expect(probe.database.db.isOpen).toBe(true);
expect(runState).toBe(probe.runState);
expect(runState.ACTIVE_EMBEDDED_RUNS.get("cancel-failure")).toBe(probe.handle);
} finally {
probe.runState.ACTIVE_EMBEDDED_RUNS.delete("cancel-failure");
await probe.manager.disposeAll();
const { closeOpenClawStateDatabaseAsync } = await import(${source("state/openclaw-state-db.ts")});
await closeOpenClawStateDatabaseAsync();
delete globalThis[probeKey];
}
});
`,
"98-mcp-a-direct-disposer.test.ts": `${imports}
it("replaces a retained MCP manager disposer without a restorable spy", async () => {
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "mocked-dispose", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
globalThis[probeKey] = { manager, dispose: manager.disposeAll.bind(manager) };
manager.disposeAll = vi.fn(async () => undefined);
});
`,
"98-mcp-b-direct-custody.test.ts": `${imports}
it("retains MCP custody when the file mocked its disposer", async () => {
const { manager, dispose } = globalThis[probeKey];
expect(globalThis[key]).toBe(manager);
expect(manager.listRuntimeKeys()).toEqual(["mocked-dispose"]);
await dispose();
expect(manager.listRuntimeKeys()).toEqual([]);
if (globalThis[key] === manager) Reflect.deleteProperty(globalThis, key);
delete globalThis[probeKey];
});
`,
"98-mcp-c-prior-failure.test.ts": `${imports}
it("fails MCP cleanup before the runner opens its cleanup scope", async () => {
const { createAgentCleanupScope } = await import(${source("agents/run-cleanup-timeout.ts")});
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "prior-failure", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
const probe = globalThis[probeKey] = { manager, closes: 0 };
lease.runtime.joinCleanup = async () => {
probe.closes++;
throw new Error("Synthetic MCP closure could not be confirmed");
};
const scope = createAgentCleanupScope();
await scope.run(() => manager.disposeAll());
expect(scope.outcome).toBe("uncertain");
expect(manager.listRuntimeKeys()).toEqual([]);
await vi.resetModules();
});
`,
"98-mcp-d-prior-custody.test.ts": `${imports}
it("retains MCP custody after an earlier disposal failure without retrying it", () => {
const probe = globalThis[probeKey];
expect(probe.closes).toBe(1);
expect(globalThis[key]).toBe(probe.manager);
if (globalThis[key] === probe.manager) Reflect.deleteProperty(globalThis, key);
delete globalThis[probeKey];
});
`,
"99-mcp-a-uncertain-owner.test.ts": `${imports}
it("records old-module MCP cleanup uncertainty during file retirement", async () => {
const manager = await fixtureManager();
const lease = await manager.acquire({ sessionId: "uncertain", workspaceDir: process.cwd(), cfg: { plugins: { enabled: false }, mcp: { servers: { probe: { command: process.execPath } } } } });
lease.releaseLease();
const probe = globalThis[probeKey] = { manager, closes: 0 };
lease.runtime.joinCleanup = async () => {
probe.closes++;
throw new Error("Synthetic MCP closure could not be confirmed");
};
// The runner imports its scope from a new module graph; the manager records
// the swallowed disposal error through its original cleanup module.
await vi.resetModules();
});
`,
"99-mcp-b-retained-owner.test.ts": `${imports}
it("retains the uncertain MCP owner without retrying its failed disposal", () => {
const probe = globalThis[probeKey];
expect(probe.closes).toBe(1);
expect(globalThis[key]).toBe(probe.manager);
});
`,
"99-mcp-c-runner-generation.test.ts": `${imports}
it("keeps uncertain MCP custody after the runner module is reevaluated", () => {
const probe = globalThis[probeKey];
expect(probe.closes).toBe(1);
expect(globalThis[key]).toBe(probe.manager);
});
`,
};
}

View file

@ -1,3 +1,4 @@
/* @vitest-environment node */
// Regression coverage for the non-isolated runner's cross-file cleanup. Keep
// every producer/observer pair in one child run: the contract is file-to-file
// cleanup, not five independent Vitest process boots.
@ -8,10 +9,12 @@ import path from "node:path";
import { expect, it } from "vitest";
import type { JsonTestResults } from "vitest/node";
import type { VitestReportCapture } from "../scripts/lib/vitest-report-capture.mts";
import { createVitestWorkerRun } from "../scripts/lib/vitest-worker-run.mts";
import { resolveTestNodeExecPath } from "../src/test-utils/node-process.js";
import { runVitestShutdownCommand } from "./helpers/vitest-shutdown-command.ts";
import { agentReaderFixtureFiles } from "./non-isolated-runner.agent-reader-fixtures.ts";
import { gatewayWorkerLifetimeFixtureFiles } from "./non-isolated-runner.gateway-lifecycle-fixtures.ts";
import { mcpManagerFixtureFiles } from "./non-isolated-runner.mcp-fixtures.ts";
import { mockResolutionFixtureFiles } from "./non-isolated-runner.mock-resolution-fixtures.ts";
import { skillsWatcherFixtureFiles } from "./non-isolated-runner.skills-watcher-fixtures.ts";
import { testApiLifecycleFixtureFiles } from "./non-isolated-runner.test-api-fixtures.ts";
@ -114,7 +117,15 @@ import { expect, vi, type RunnerTestFile } from "vitest";
const resetModules = vi.resetModules;
export default class FixtureRunner extends Runner {
override async onAfterRunFiles(files: RunnerTestFile[]) {
await super.onAfterRunFiles(files);
const generation = files.some(file => file.filepath.endsWith("12-a-skills-watcher-leak.test.ts"))
? "?skills-watcher-generation"
: files.some(file => file.filepath.endsWith("99-mcp-b-retained-owner.test.ts"))
? "?mcp-retirement-generation"
: undefined;
const cleanup = generation
? (await import(${JSON.stringify(path.join(repoRoot, "test", "non-isolated-runner.ts"))} + generation)).default.prototype.onAfterRunFiles
: Runner.prototype.onAfterRunFiles;
await cleanup.call(this, files);
expect(vi.resetModules, "file cleanup restores the native module reset").toBe(resetModules);
}
}
@ -431,6 +442,7 @@ it("reloads the redirected mock after a real import", () => {
});
`,
...mockResolutionFixtureFiles,
...mcpManagerFixtureFiles(repoRoot),
...testApiLifecycleFixtureFiles(repoRoot),
...documentFocusFixtureFiles(),
...agentReaderFixtureFiles(repoRoot, fixtureRoot),
@ -457,12 +469,24 @@ async function assertCompletion(
const capture: VitestReportCapture = JSON.parse(
await fs.readFile(`${expected.reportPath}.capture.json`, "utf8"),
);
const report: JsonTestResults = JSON.parse(await fs.readFile(expected.reportPath, "utf8"));
const childFailures = report.testResults.flatMap((file) =>
[file.message, ...file.assertionResults.flatMap((test) => test.failureMessages)]
.filter((message): message is string => typeof message === "string" && message.length > 0)
.map(
(message) =>
`${path.basename(file.name)}: ${message.split("\n", 2).join("\n").slice(0, 512)}`,
),
);
expect(expected.pid).toEqual(expect.any(Number));
expect(capture).toMatchObject({
expect(
capture,
`Child report: ${expected.reportPath}\n${childFailures.slice(0, 20).join("\n")}`,
).toMatchObject({
pid: expected.pid,
root: expected.root,
processTimedOut: false,
ended: { reason: "failed", unhandledErrors: 0, failedModules: 2, suiteErrors: 2 },
ended: { reason: "failed", unhandledErrors: 0, failedModules: 6, suiteErrors: 6 },
});
const project = {
name: "non-isolated-runner",
@ -477,11 +501,10 @@ async function assertCompletion(
expect(module).toMatchObject(project);
}
const report: JsonTestResults = JSON.parse(await fs.readFile(expected.reportPath, "utf8"));
expect(report.testResults.map((file) => file.name).toSorted()).toEqual(expected.files);
expect(report).toMatchObject({
numTotalTests: 53,
numPassedTests: 52,
numTotalTests: 68,
numPassedTests: 67,
numPendingTests: 1,
numFailedTests: 0,
numTodoTests: 0,
@ -490,16 +513,34 @@ async function assertCompletion(
const name = path.basename(file.name);
const crashed = name === "01-a-crash.test.ts";
const leakedWatchers = name === "12-a-skills-watcher-leak.test.ts";
const uncertainMcp =
name === "99-mcp-a-uncertain-owner.test.ts" || name === "98-mcp-c-prior-failure.test.ts";
const mockedMcpDisposer = name === "98-mcp-a-direct-disposer.test.ts";
const failedRunCancellation = name === "97-mcp-a-cancel-failure.test.ts";
const skipped = name === "09-f-test-api-skipped.test.ts";
const lifecycle = ["09-d-test-api-producer.test.ts", "09-e-test-api-observer.test.ts"].includes(
name,
);
const count = crashed ? 0 : lifecycle ? 2 : 1;
expect(file.status, name).toBe(crashed || leakedWatchers ? "failed" : "passed");
expect(file.status, name).toBe(
crashed || leakedWatchers || uncertainMcp || mockedMcpDisposer || failedRunCancellation
? "failed"
: "passed",
);
if (leakedWatchers) {
expect(file.message, name).toMatch(
/^12-a-skills-watcher-leak\.test\.ts: skills watchers failed\nError: left skills watchers open /u,
);
} else if (uncertainMcp || mockedMcpDisposer) {
expect(file.message).toContain("MCP runtime custody failed");
expect(file.message).toContain(
uncertainMcp
? "MCP test teardown could not confirm cleanup"
: "MCP test teardown cannot use a mocked disposer",
);
} else if (failedRunCancellation) {
expect(file.message).toContain("run state failed");
expect(file.message).toContain("Synthetic run cancellation failed");
} else {
expect(file.message, name).toBe(crashed ? "synthetic collect failure" : "");
}
@ -546,6 +587,7 @@ class AlphabeticalSequencer extends BaseSequencer {
}
export default defineConfig({
cacheDir: ${JSON.stringify(path.join(root, ".vite"))},
plugins: sharedVitestConfig.plugins,
resolve: sharedVitestConfig.resolve,
test: {
name: "non-isolated-runner",
@ -568,7 +610,9 @@ export default defineConfig({
const reportPath = path.join(root, "report.json");
let child!: ChildProcess;
const result = await runVitestShutdownCommand({
const env = childEnv();
const workers = createVitestWorkerRun(env);
const command = runVitestShutdownCommand({
bin: resolveTestNodeExecPath(),
args: [
path.join(vitestPackageDir, "vitest.mjs"),
@ -585,13 +629,20 @@ export default defineConfig({
`--outputFile.json=${reportPath}`,
],
cwd: repoRoot,
env: childEnv(),
env,
workerRun: workers,
maxBytes: 16 * 1024 * 1024,
signal,
onReady(owned) {
child = owned;
},
});
let result: Awaited<typeof command>;
try {
result = await command;
} finally {
await workers.dispose();
}
const completion: ChildCompletion = {
exitCode: child.exitCode,
signalCode: child.signalCode,
@ -609,7 +660,12 @@ export default defineConfig({
.toSorted(),
reportPath,
};
await assertCompletion(completion, expected);
try {
await assertCompletion(completion, expected);
} catch (error) {
await fs.writeFile(path.join(root, "child-output.log"), completion.output, "utf8");
throw error;
}
// Replay faults against this one completed child, not new fixture executions.
// The same assertion path must reject incomplete proof even with a good summary.
@ -700,10 +756,10 @@ export default defineConfig({
{ reason: "interrupted" },
{ reason: "passed" },
{ unhandledErrors: 1 },
{ failedModules: 1 },
{ failedModules: 3 },
{ suiteErrors: 1 },
{ suiteErrors: 3 },
{ failedModules: 5 },
{ failedModules: 7 },
{ suiteErrors: 5 },
{ suiteErrors: 7 },
]) {
faults.push([
`invalid native end: ${JSON.stringify(patch)}`,

View file

@ -13,6 +13,7 @@ import {
type VitestTestRunner,
vi,
} from "vitest";
import type { SessionMcpRuntimeManager } from "../src/agents/agent-bundle-mcp-types.js";
import { resetAgentEventsForTest } from "../src/infra/agent-events.js";
import { loggingState } from "../src/logging/state.js";
import { clearNamedPluginRuntimeStoresForTest } from "../src/plugin-sdk/runtime-store-registry.js";
@ -22,7 +23,7 @@ import {
resetGatewayWorkAdmission,
} from "../src/process/gateway-work-admission.js";
import { hasOpenClawAgentDatabaseAsyncResources } from "../src/state/openclaw-agent-db-resources.js";
import { clearJsdomViewportFocus } from "./jsdom-compat.mts";
import { clearJsdomViewportFocus } from "./jsdom-compat.mjs";
import {
type CustomElementTracking,
dropRepoOwnedCustomElements,
@ -32,6 +33,7 @@ import { repositoryTestApiPublications } from "./repository-test-api-publication
import {
closeLeakedSkillsWatchers,
rememberSkillsWatcherGenerations,
setSkillsWatcherCaptureBeforeReset,
} from "./skills-watcher-test-lifecycle.ts";
import {
drainSqliteTestAgentOwner,
@ -79,6 +81,8 @@ const DIAGNOSTIC_EVENT_LISTENER_PRESENCE = Symbol.for(
);
const SESSION_SUSPENSION_TEST_API = Symbol.for("openclaw.sessionSuspensionTestApi");
const SECRET_REDACTION_TEST_API = Symbol.for("openclaw.secretRedactionRegistryTestApi");
const SESSION_MCP_RUNTIME_MANAGER = Symbol.for("openclaw.sessionMcpRuntimeManager");
const RETAINED_MCP_MANAGERS = Symbol.for("openclaw.nonIsolatedRetainedMcpManagers");
const SUBAGENT_REGISTRY_TEST_API = Symbol.for("openclaw.subagentRegistryTestApi");
// Shared-worker scoped: the registry lives on the worker global, not in the module graph.
const CUSTOM_ELEMENT_TRACKING = Symbol.for("openclaw.nonIsolatedCustomElementTracking");
@ -102,14 +106,6 @@ const nativeTimerGlobals = {
clearImmediate: globalThis.clearImmediate,
Date: globalThis.Date,
};
// vi.resetModules() inside a test clears module exports before the next task boundary.
// Remember skills watcher generations first so the file drain can still close them.
let beforeModuleReset: (() => void) | undefined;
const nativeResetModules = vi.resetModules;
vi.resetModules = () => {
beforeModuleReset?.();
return nativeResetModules();
};
function getSharedTestHome(): string | undefined {
const globalState = globalThis as typeof globalThis & {
@ -425,6 +421,43 @@ function resetOpenClawSecretRedactionState(): void {
api?.resetSecretRedactionRegistryForTest?.();
}
async function retireSessionMcpRuntimeManager(): Promise<void> {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const manager = globalStore[SESSION_MCP_RUNTIME_MANAGER] as SessionMcpRuntimeManager | undefined;
const retained = globalStore[RETAINED_MCP_MANAGERS] as Set<SessionMcpRuntimeManager> | undefined;
if (!manager || retained?.has(manager)) {
return;
}
try {
const { createAgentCleanupScope } = await vi.importActual<
typeof import("../src/agents/run-cleanup-timeout.js")
>("../src/agents/run-cleanup-timeout.js");
// Old manager modules and this scope share the process-global outcome carrier.
const scope = createAgentCleanupScope();
await scope.run(() => {
const dispose = manager.disposeAll;
if (vi.isMockFunction(dispose)) {
throw new Error("MCP test teardown cannot use a mocked disposer");
}
return dispose.call(manager);
});
if (scope.outcome !== "closed") {
throw new Error("MCP test teardown could not confirm cleanup");
}
} catch (error) {
// Reread after disposal: other cleanup may have retained another owner while we awaited.
const owners =
(globalStore[RETAINED_MCP_MANAGERS] as Set<SessionMcpRuntimeManager> | undefined) ??
new Set();
owners.add(manager);
globalStore[RETAINED_MCP_MANAGERS] = owners;
throw error;
}
if (globalStore[SESSION_MCP_RUNTIME_MANAGER] === manager) {
Reflect.deleteProperty(globalStore, SESSION_MCP_RUNTIME_MANAGER);
}
}
// Join the native owner's latest pass, including imports queued while cleanup waits.
async function drainMockerResolveMocks(mocker: ModuleMocker | undefined): Promise<void> {
if (!mocker) {
@ -444,7 +477,7 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
override onCollectStart(file: RunnerTestFile) {
super.onCollectStart(file);
beforeModuleReset = () => this.rememberSkillsWatchers();
setSkillsWatcherCaptureBeforeReset(() => this.rememberSkillsWatchers());
if (!this.config.isolate) {
installCustomElementTracking();
}
@ -530,8 +563,10 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
const clean = (phase: string, run: () => void) => {
try {
run();
return true;
} catch (error) {
recordFailure(phase, error);
return false;
}
};
const drain = async (phase: string, run: () => Promise<void>) => {
@ -543,14 +578,16 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
return false;
}
};
const publishFailures = () =>
this.onTaskUpdate?.(
[...failed].map((file) => [file.id, file.result, file.meta]),
[],
);
clean("Vitest file completion", () => super.onAfterRunFiles(files));
await drain("mock resolution", () => drainMockerResolveMocks(internals.moduleRunner?.mocker));
// The last test's scheduled closes must finish before cleanup restores shared state.
await settleSqliteTestAgentCloses();
// Mirror the missing cleanup from Vitest isolate mode so shared workers do
// not carry file-scoped timers, stubs, spies, or stale module state
// forward into the next file.
// Restore independent file state even when failed cancellation retains the runtime owners.
const testHome = getSharedTestHome();
for (const [phase, run] of [
["mock restoration", () => vi.restoreAllMocks()],
@ -561,23 +598,35 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
["environment stubs", () => vi.unstubAllEnvs()],
["test home", () => restoreSharedTestHomeAfterEnvUnstub(testHome)],
["mock history", () => vi.clearAllMocks()],
[
"Gateway drain admission",
() => {
if (isGatewayWorkAdmissionClosed()) {
markGatewayRestartDraining();
}
},
],
["run state", resetOpenClawGlobalRunState],
] as const) {
clean(phase, run);
}
clean("Gateway drain admission", () => {
if (isGatewayWorkAdmissionClosed()) {
markGatewayRestartDraining();
}
});
if (!clean("run state", resetOpenClawGlobalRunState)) {
// Failed cancellation retains the run's runtime, storage and module generation.
retainSqliteTestCustody();
await publishFailures();
return;
}
if (
!this.config.isolate &&
!(await drain("MCP runtime custody", retireSessionMcpRuntimeManager))
) {
retainSqliteTestCustody();
}
for (const [phase, run] of [
["agent events", resetAgentEventsForTest],
["diagnostic state", resetOpenClawGlobalDiagnosticState],
["session suspension", resetOpenClawSessionSuspensionState],
] as const) {
clean(phase, run);
}
// After the module reset nothing can reach this file's watchers, and their re-arms
// land on a later file's fake clock. Close them now and fail this file, not that one.
// Retire this file's watchers before module invalidation can orphan them.
this.rememberSkillsWatchers();
await drain("skills watchers", async () => {
const leaked = await closeLeakedSkillsWatchers();
@ -587,8 +636,7 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
);
}
});
// The runner's own module reset below must not retain this file's closed generation.
beforeModuleReset = undefined;
setSkillsWatcherCaptureBeforeReset(undefined);
if (
!(await drain("subagent registry", async () => {
const api = (globalThis as Record<PropertyKey, unknown>)[SUBAGENT_REGISTRY_TEST_API] as
@ -644,10 +692,7 @@ export default class OpenClawNonIsolatedRunner extends TestRunner {
}
}
if (failed.size) {
await this.onTaskUpdate?.(
[...failed].map((file) => [file.id, file.result, file.meta]),
[],
);
await publishFailures();
}
}
}

View file

@ -20,8 +20,35 @@ type SkillsWatchRegistry = Pick<
"pathWatchers" | "workspaceWatchOwners"
>;
// A test's vi.resetModules() replaces these exports; keep every generation the runner saw.
const generations = new Map<RefreshModule["closeSkillsWatchers"], SkillsWatchRegistry>();
type SkillsWatcherTestLifecycle = {
generations: Map<RefreshModule["closeSkillsWatchers"], SkillsWatchRegistry>;
beforeModuleReset: (() => void) | undefined;
};
const SKILLS_WATCHER_TEST_LIFECYCLE = Symbol.for("openclaw.skillsWatcherTestLifecycle");
const lifecycleStore = globalThis as typeof globalThis & {
[SKILLS_WATCHER_TEST_LIFECYCLE]?: SkillsWatcherTestLifecycle;
};
function createLifecycle(): SkillsWatcherTestLifecycle {
const state: SkillsWatcherTestLifecycle = {
generations: new Map(),
beforeModuleReset: undefined,
};
const nativeResetModules = vi.resetModules;
vi.resetModules = () => {
state.beforeModuleReset?.();
return nativeResetModules();
};
return state;
}
// Runner/helper re-evaluation must share custody and preserve the installed reset hook.
const lifecycle = (lifecycleStore[SKILLS_WATCHER_TEST_LIFECYCLE] ??= createLifecycle());
export function setSkillsWatcherCaptureBeforeReset(capture: (() => void) | undefined): void {
lifecycle.beforeModuleReset = capture;
}
function realExports(
node: EvaluatedModuleNode | undefined,
@ -54,7 +81,7 @@ export function rememberSkillsWatcherGenerations(
| undefined;
const { pathWatchers, workspaceWatchOwners } = registry ?? {};
if (pathWatchers instanceof Map && workspaceWatchOwners instanceof Map) {
generations.set(close, { pathWatchers, workspaceWatchOwners });
lifecycle.generations.set(close, { pathWatchers, workspaceWatchOwners });
}
break;
}
@ -63,8 +90,8 @@ export function rememberSkillsWatcherGenerations(
/** Closes every remembered generation's open watchers and returns their live entry count. */
export async function closeLeakedSkillsWatchers(): Promise<number> {
const remembered = [...generations];
generations.clear();
const remembered = [...lifecycle.generations];
lifecycle.generations.clear();
let leaked = 0;
const failures: unknown[] = [];
for (const [close, registry] of remembered) {

View file

@ -11,6 +11,7 @@ import { createRequireRecord } from "../../../test/helpers/record.js";
import { createTempDirTracker } from "../../../test/helpers/temp-dir.ts";
import type { ModelCatalogResult } from "../api/types.ts";
import type { ApplicationContext } from "../app/context.ts";
import type { ChatPageHost } from "../pages/chat/chat-state-host.ts";
import { waitForControlUiGatewayReady } from "../test-helpers/control-ui-e2e-readiness.ts";
import { revealChatModelOption } from "../test-helpers/select-picker-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
@ -88,12 +89,14 @@ suite.define(() => {
return result.stdout;
};
const key = "agent:main:partial-refresh";
await call("sessions.create", {
key,
agentId: "main",
label: "Partial refresh",
model: "openai/gpt-5.4",
});
const { sessionId }: { sessionId: string } = JSON.parse(
await call("sessions.create", {
key,
agentId: "main",
label: "Partial refresh",
model: "openai/gpt-5.4",
}),
);
await call("sessions.patch", { key, thinkingLevel: "high" });
let catalog: ModelCatalogResult = JSON.parse(
await call("models.list", { agentId: "main", view: "configured", refresh: true }),
@ -124,15 +127,18 @@ suite.define(() => {
async ({ page }) => {
let currentSocket: WebSocket | undefined;
let latestDiscovery: { socket: WebSocket; id: string; complete: boolean } | undefined;
let latestHistory: { socket: WebSocket; id: string; complete: boolean } | undefined;
page.on("websocket", (socket) => {
currentSocket = socket;
socket.on("framesent", ({ payload }) => {
const frame = requireRecord(JSON.parse(payload.toString()));
if (
frame.type !== "req" ||
frame.method !== "sessions.catalog.list" ||
typeof frame.id !== "string"
) {
if (frame.type !== "req" || typeof frame.id !== "string") {
return;
}
if (frame.method === "chat.startup" || frame.method === "chat.history") {
latestHistory = { socket, id: frame.id, complete: false };
}
if (frame.method !== "sessions.catalog.list") {
return;
}
const params = requireRecord(frame.params);
@ -150,6 +156,18 @@ suite.define(() => {
) {
latestDiscovery.complete = frame.ok === true;
}
if (
frame.type === "res" &&
latestHistory !== undefined &&
frame.id === latestHistory.id &&
latestHistory.socket === socket
) {
const historyResult = frame.ok === true ? requireRecord(frame.payload) : undefined;
latestHistory.complete =
historyResult !== undefined &&
historyResult.kind !== "reset" &&
requireRecord(historyResult.sessionInfo).sessionId === sessionId;
}
});
});
await page.addInitScript(() => {
@ -236,6 +254,40 @@ suite.define(() => {
path: path.join(suite.artifactDir, `${stage}-composer.png`),
animations: "disabled",
});
if (route !== "new") {
// Model browsing is ready before history hydration unlocks effort changes.
await expect
.poll(async () => {
const history = latestHistory;
if (!history?.complete || history.socket !== currentSocket) {
return false;
}
const rendered = await page.evaluate(
({ key: expectedKey, sessionId: expectedSessionId }) => {
const pane = document.querySelector<
HTMLElement & {
state?: ChatPageHost;
transcriptReady: boolean;
hasUpdated: boolean;
isUpdatePending: boolean;
}
>('openclaw-chat-pane[aria-hidden="false"]');
const state = pane?.state;
return (
pane?.hasUpdated === true &&
!pane.isUpdatePending &&
pane.transcriptReady &&
state?.sessionKey === expectedKey &&
state.currentSessionId === expectedSessionId &&
!state.chatLoading
);
},
{ key, sessionId },
);
return rendered && latestHistory === history && currentSocket === history.socket;
})
.toBe(true);
}
const effort = composer.locator("[data-chat-thinking-select]");
await expect.poll(() => effort.isVisible()).toBe(true);
await expect.poll(() => effort.getAttribute("aria-disabled")).toBe("false");