diff --git a/docs/plugins/sdk-testing.md b/docs/plugins/sdk-testing.md index 414cbdaf63e6..d6bac767a514 100644 --- a/docs/plugins/sdk-testing.md +++ b/docs/plugins/sdk-testing.md @@ -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 diff --git a/extensions/qa-lab/src/scenario-catalog.delegation.test.ts b/extensions/qa-lab/src/scenario-catalog.delegation.test.ts index 2c4318a9851d..3218f3b664f1 100644 --- a/extensions/qa-lab/src/scenario-catalog.delegation.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.delegation.test.ts @@ -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 }); }); diff --git a/src/agents/agent-bundle-mcp-manager-lifecycle.ts b/src/agents/agent-bundle-mcp-manager-lifecycle.ts index eb67674e82c8..8be45712a08f 100644 --- a/src/agents/agent-bundle-mcp-manager-lifecycle.ts +++ b/src/agents/agent-bundle-mcp-manager-lifecycle.ts @@ -121,6 +121,7 @@ function scopedCatalogToolsSignature(tools: readonly McpCatalogTool[]): string { } export function createSessionMcpRuntimeManagerLifecycle(store: SessionMcpRuntimeManagerStore) { + let cleanupUncertain = false; const schedulers = new Set(); 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(); + } }); }; diff --git a/src/agents/agent-bundle-mcp-manager.lifecycle.test.ts b/src/agents/agent-bundle-mcp-manager.lifecycle.test.ts index 198c3e65494d..be3106f3cbfb 100644 --- a/src/agents/agent-bundle-mcp-manager.lifecycle.test.ts +++ b/src/agents/agent-bundle-mcp-manager.lifecycle.test.ts @@ -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 () => { diff --git a/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts b/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts index a1f9d7cac650..5ae1c0a5922f 100644 --- a/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.agent-bundle.test.ts @@ -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(); diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index d34b1d06c68f..0e27a92a3ab4 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -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((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(); } }, ); diff --git a/src/plugin-sdk/plugin-test-runtime.ts b/src/plugin-sdk/plugin-test-runtime.ts index 02c80bec71f9..6429cbd4e47c 100644 --- a/src/plugin-sdk/plugin-test-runtime.ts +++ b/src/plugin-sdk/plugin-test-runtime.ts @@ -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"; diff --git a/test/helpers/vitest-shutdown-command.ts b/test/helpers/vitest-shutdown-command.ts index 3a6e4125958c..c1b53a45adba 100644 --- a/test/helpers/vitest-shutdown-command.ts +++ b/test/helpers/vitest-shutdown-command.ts @@ -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[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() : undefined; + let borrowerCompletion: Promise | 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; } } diff --git a/test/jsdom-compat.mts b/test/jsdom-compat.mts index b4cd9aba8f5c..135fc10f02bd 100644 --- a/test/jsdom-compat.mts +++ b/test/jsdom-compat.mts @@ -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); + } } } }, diff --git a/test/non-isolated-runner.mcp-fixtures.ts b/test/non-isolated-runner.mcp-fixtures.ts new file mode 100644 index 000000000000..e352fcd664ba --- /dev/null +++ b/test/non-isolated-runner.mcp-fixtures.ts @@ -0,0 +1,201 @@ +import path from "node:path"; + +export function mcpManagerFixtureFiles(repoRoot: string): Record { + 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); +}); +`, + }; +} diff --git a/test/non-isolated-runner.test.ts b/test/non-isolated-runner.test.ts index 311d188dcbfa..0d72fbe0cedf 100644 --- a/test/non-isolated-runner.test.ts +++ b/test/non-isolated-runner.test.ts @@ -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; + 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)}`, diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index f7eaa2da1888..cd692e779d90 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -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 { + const globalStore = globalThis as Record; + const manager = globalStore[SESSION_MCP_RUNTIME_MANAGER] as SessionMcpRuntimeManager | undefined; + const retained = globalStore[RETAINED_MCP_MANAGERS] as Set | 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 | 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 { 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) => { @@ -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)[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(); } } } diff --git a/test/skills-watcher-test-lifecycle.ts b/test/skills-watcher-test-lifecycle.ts index 45c8d6e4be6b..2c790d0f7395 100644 --- a/test/skills-watcher-test-lifecycle.ts +++ b/test/skills-watcher-test-lifecycle.ts @@ -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(); +type SkillsWatcherTestLifecycle = { + generations: Map; + 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 { - const remembered = [...generations]; - generations.clear(); + const remembered = [...lifecycle.generations]; + lifecycle.generations.clear(); let leaked = 0; const failures: unknown[] = []; for (const [close, registry] of remembered) { diff --git a/ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts b/ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts index ee55ba997081..7d692031caf5 100644 --- a/ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts +++ b/ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts @@ -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");