diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 7e0eb80109a..98f85af056e 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -14,7 +14,7 @@ RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt git WORKDIR /repo -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY ui/package.json ./ui/package.json COPY packages ./packages COPY extensions ./extensions diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 7e91a8dfe73..3638bb11b11 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -27,7 +27,7 @@ COPY --chown=appuser:appuser scripts/postinstall-bundled-plugins.mjs scripts/npm RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ pnpm install --frozen-lockfile -COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts vitest.shared.config.ts vitest.bundled-plugin-paths.ts openclaw.mjs ./ COPY --chown=appuser:appuser src ./src COPY --chown=appuser:appuser test ./test COPY --chown=appuser:appuser scripts ./scripts diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 814cf732794..3b8e7d0566e 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -45,7 +45,9 @@ start_gateway() { gateway_pid=$! for _ in $(seq 1 120); do - if grep -q "listening on ws://" "$log_file"; then + # Gateway startup logs changed; accept both the legacy listener line and the + # current structured ready line so this smoke stays stable across formats. + if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then return 0 fi if ! kill -0 "$gateway_pid" 2>/dev/null; then diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts index ea2d3405d44..dc5a1ab92f5 100644 --- a/src/agents/cli-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -11,7 +11,9 @@ import { } from "./bundle-mcp.test-harness.js"; import { runCliAgent } from "./cli-runner.js"; -const E2E_TIMEOUT_MS = 20_000; +// This e2e spins a real stdio MCP server plus a spawned CLI process, which is +// notably slower under Docker and cold Vitest imports. +const E2E_TIMEOUT_MS = 40_000; describe("runCliAgent bundle MCP e2e", () => { it( diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index 439e3361554..f82e54e6116 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -1167,6 +1167,8 @@ describe("createOpenAIWebSocketStreamFn", () => { releaseWsSession("sess-store-default"); releaseWsSession("sess-store-compat"); releaseWsSession("sess-max-tokens-zero"); + releaseWsSession("sess-runtime-fallback"); + releaseWsSession("sess-drop"); openAIWsStreamTesting.setDepsForTest(); }); @@ -1424,6 +1426,35 @@ describe("createOpenAIWebSocketStreamFn", () => { } }); + it("falls back to HTTP when WebSocket errors before any output in auto mode", async () => { + const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-fallback"); + const stream = streamFn( + modelStub as Parameters[0], + contextStub as Parameters[1], + { transport: "auto" } as Parameters[2], + ); + + await new Promise((r) => setImmediate(r)); + const manager = MockManager.lastInstance!; + manager.simulateEvent({ + type: "error", + message: "temporary upstream glitch", + code: "ws_runtime_error", + }); + + const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = []; + for await (const ev of await resolveStream(stream)) { + events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } }); + } + + expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1); + expect(manager.closeCallCount).toBeGreaterThanOrEqual(1); + expect(events.filter((event) => event.type === "start")).toHaveLength(1); + expect(events.some((event) => event.type === "error")).toBe(false); + const doneEvent = events.find((event) => event.type === "done"); + expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response"); + }); + it("tracks previous_response_id across turns (incremental send)", async () => { const sessionId = "sess-incremental"; const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId); @@ -1924,12 +1955,12 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(sent.tool_choice).toBe("auto"); }); - it("rejects promise when WebSocket drops mid-request", async () => { + it("keeps explicit websocket mode surfacing mid-request drops", async () => { const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-drop"); const stream = streamFn( modelStub as Parameters[0], contextStub as Parameters[1], - {} as Parameters[2], + { transport: "websocket" } as Parameters[2], ); // Let the send go through, then simulate connection drop before response.completed await new Promise((resolve) => { diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 013d1e3e4a6..24b16ef94bf 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -222,6 +222,15 @@ function resolveWsWarmup(options: Parameters[2]): boolean { return warmup === true; } +function resetWsSession(params: { sessionId: string; session: WsSession }): void { + try { + params.session.manager.close(); + } catch { + /* ignore */ + } + wsRegistry.delete(params.sessionId); +} + async function runWarmUp(params: { manager: OpenAIWebSocketManager; modelId: string; @@ -523,12 +532,7 @@ export function createOpenAIWebSocketStreamFn( ); // Fully reset session state so the next WS turn doesn't use stale // previous_response_id or lastContextLength from before the failure. - try { - session.manager.close(); - } catch { - /* ignore */ - } - wsRegistry.delete(sessionId); + resetWsSession({ sessionId, session }); return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal); } @@ -543,72 +547,101 @@ export function createOpenAIWebSocketStreamFn( // ── 5. Wait for response.completed ─────────────────────────────────── const capturedContextLength = context.messages.length; + let sawWsOutput = false; - await new Promise((resolve, reject) => { - // Honour abort signal - const abortHandler = () => { - cleanup(); - reject(new Error("aborted")); - }; - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - signal?.addEventListener("abort", abortHandler, { once: true }); - - // If the WebSocket drops mid-request, reject so we don't hang forever. - const closeHandler = (code: number, reason: string) => { - cleanup(); - reject( - new Error(`WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`), - ); - }; - session.manager.on("close", closeHandler); - - const cleanup = () => { - signal?.removeEventListener("abort", abortHandler); - session.manager.off("close", closeHandler); - unsubscribe(); - }; - - const unsubscribe = session.manager.onMessage((event) => { - if (event.type === "response.completed") { + try { + await new Promise((resolve, reject) => { + // Honour abort signal + const abortHandler = () => { cleanup(); - // Update session state - session.lastContextLength = capturedContextLength; - // Build and emit the assistant message - const assistantMsg = buildAssistantMessageFromResponse(event.response, { - api: model.api, - provider: model.provider, - id: model.id, - }); - const reason: Extract = - assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop"; - eventStream.push({ type: "done", reason, message: assistantMsg }); - resolve(); - } else if (event.type === "response.failed") { - cleanup(); - const errMsg = event.response?.error?.message ?? "Response failed"; - reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`)); - } else if (event.type === "error") { - cleanup(); - reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`)); - } else if (event.type === "response.output_text.delta") { - // Stream partial text updates for responsive UI - const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({ - model, - content: [{ type: "text", text: event.delta }], - stopReason: "stop", - }); - eventStream.push({ - type: "text_delta", - contentIndex: 0, - delta: event.delta, - partial: partialMsg, - }); + reject(new Error("aborted")); + }; + if (signal?.aborted) { + reject(new Error("aborted")); + return; } + signal?.addEventListener("abort", abortHandler, { once: true }); + + // If the WebSocket drops mid-request, reject so we don't hang forever. + const closeHandler = (code: number, reason: string) => { + cleanup(); + reject( + new Error( + `WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`, + ), + ); + }; + session.manager.on("close", closeHandler); + + const cleanup = () => { + signal?.removeEventListener("abort", abortHandler); + session.manager.off("close", closeHandler); + unsubscribe(); + }; + + const unsubscribe = session.manager.onMessage((event) => { + if ( + event.type === "response.output_item.added" || + event.type === "response.output_item.done" || + event.type === "response.content_part.added" || + event.type === "response.content_part.done" || + event.type === "response.output_text.delta" || + event.type === "response.output_text.done" || + event.type === "response.function_call_arguments.delta" || + event.type === "response.function_call_arguments.done" + ) { + sawWsOutput = true; + } + + if (event.type === "response.completed") { + cleanup(); + // Update session state + session.lastContextLength = capturedContextLength; + // Build and emit the assistant message + const assistantMsg = buildAssistantMessageFromResponse(event.response, { + api: model.api, + provider: model.provider, + id: model.id, + }); + const reason: Extract = + assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop"; + eventStream.push({ type: "done", reason, message: assistantMsg }); + resolve(); + } else if (event.type === "response.failed") { + cleanup(); + const errMsg = event.response?.error?.message ?? "Response failed"; + reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`)); + } else if (event.type === "error") { + cleanup(); + reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`)); + } else if (event.type === "response.output_text.delta") { + // Stream partial text updates for responsive UI + const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({ + model, + content: [{ type: "text", text: event.delta }], + stopReason: "stop", + }); + eventStream.push({ + type: "text_delta", + contentIndex: 0, + delta: event.delta, + partial: partialMsg, + }); + } + }); }); - }); + } catch (wsRunErr) { + if (transport !== "websocket" && !signal?.aborted && !sawWsOutput) { + log.warn( + `[ws-stream] session=${sessionId} runtime failure before output; falling back to HTTP. error=${String(wsRunErr)}`, + ); + resetWsSession({ sessionId, session }); + return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, { + suppressStart: true, + }); + } + throw wsRunErr; + } }; queueMicrotask(() => @@ -638,18 +671,22 @@ export function createOpenAIWebSocketStreamFn( async function fallbackToHttp( model: Parameters[0], context: Parameters[1], - options: Parameters[2], + streamOptions: Parameters[2], apiKey: string, eventStream: AssistantMessageEventStreamLike, signal?: AbortSignal, + fallbackOptions?: { suppressStart?: boolean }, ): Promise { const mergedOptions = { - ...options, + ...streamOptions, apiKey, ...(signal ? { signal } : {}), }; const httpStream = openAIWsStreamDeps.streamSimple(model, context, mergedOptions); for await (const event of httpStream) { + if (fallbackOptions?.suppressStart && event.type === "start") { + continue; + } eventStream.push(event); } } diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 0f232ba2015..ce12294be32 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -96,7 +96,7 @@ describe("docker build cache layout", () => { expect( indexOfPattern( dockerfile, - /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m, ), ).toBeLessThan(installIndex); expect( @@ -114,7 +114,7 @@ describe("docker build cache layout", () => { expect( indexOfPattern( dockerfile, - /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts openclaw\.mjs \.\/$/m, + /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts vitest\.shared\.config\.ts vitest\.bundled-plugin-paths\.ts openclaw\.mjs \.\/$/m, ), ).toBeGreaterThan(installIndex); expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m)).toBeGreaterThan( @@ -160,4 +160,19 @@ describe("docker build cache layout", () => { installIndex, ); }); + + it("copies .npmrc before install in the cleanup smoke image", async () => { + const dockerfile = await readRepoFile("scripts/docker/cleanup-smoke/Dockerfile"); + const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile"); + + expect( + indexOfPattern( + dockerfile, + /^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m, + ), + ).toBeLessThan(installIndex); + expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan( + installIndex, + ); + }); }); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 8a41037bdc7..0fd91f4e2ff 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -76,6 +76,7 @@ const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([ const GATEWAY_LIVE_EXEC_READ_NONCE_MISS_SKIP_MODEL_KEYS = new Set([ "google/gemini-3.1-flash-lite-preview", ]); +const GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS = new Set(["google/gemini-3-flash-preview"]); const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels(); const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS); const QUIET_LIVE_LOGS = process.env.OPENCLAW_LIVE_TEST_QUIET !== "0"; @@ -594,28 +595,43 @@ function isPromptProbeMiss(error: string): boolean { return msg.includes("not meaningful:") || msg.includes("missing required keywords:"); } -function shouldSkipToolNonceProbeMiss(provider: string): boolean { - return ( +function shouldSkipToolNonceProbeMissForLiveModel(modelKey?: string): boolean { + if (!modelKey) { + return false; + } + if (GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(modelKey)) { + return true; + } + const [provider, ...rest] = modelKey.split("/"); + if ( provider === "anthropic" || provider === "minimax" || provider === "opencode" || provider === "opencode-go" || provider === "xai" || provider === "zai" - ); + ) { + return true; + } + if (provider !== "google" || rest.length === 0) { + return false; + } + const normalizedKey = `${provider}/${normalizeGoogleModelId(rest.join("/"))}`; + return GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(normalizedKey); } -describe("shouldSkipToolNonceProbeMiss", () => { +describe("shouldSkipToolNonceProbeMissForLiveModel", () => { it.each([ - { provider: "anthropic", expected: true }, - { provider: "minimax", expected: true }, - { provider: "opencode", expected: true }, - { provider: "opencode-go", expected: true }, - { provider: "xai", expected: true }, - { provider: "zai", expected: true }, - { provider: "openai", expected: false }, - ])("returns $expected for $provider", ({ provider, expected }) => { - expect(shouldSkipToolNonceProbeMiss(provider)).toBe(expected); + { modelKey: "anthropic/claude-opus-4-6", expected: true }, + { modelKey: "minimax/minimax-m1", expected: true }, + { modelKey: "opencode/big-pickle", expected: true }, + { modelKey: "opencode-go/glm-5", expected: true }, + { modelKey: "xai/grok-4.1-fast", expected: true }, + { modelKey: "zai/glm-4.7", expected: true }, + { modelKey: "google/gemini-3-flash-preview", expected: true }, + { modelKey: "openai/gpt-5.2", expected: false }, + ])("returns $expected for $modelKey", ({ modelKey, expected }) => { + expect(shouldSkipToolNonceProbeMissForLiveModel(modelKey)).toBe(expected); }); }); @@ -1724,9 +1740,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (exec/read workspace isolation)`); break; } - if (shouldSkipToolNonceProbeMiss(model.provider) && isToolNonceProbeMiss(message)) { + if (shouldSkipToolNonceProbeMissForLiveModel(modelKey) && isToolNonceProbeMiss(message)) { skippedCount += 1; - logProgress(`${progressLabel}: skip (${model.provider} tool probe nonce miss)`); + logProgress(`${progressLabel}: skip (${modelKey} tool probe nonce miss)`); break; } if (isMissingProfileError(message)) { diff --git a/src/infra/vitest-e2e-config.test.ts b/src/infra/vitest-e2e-config.test.ts new file mode 100644 index 00000000000..0fb33ad5704 --- /dev/null +++ b/src/infra/vitest-e2e-config.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts"; +import e2eConfig from "../../vitest.e2e.config.ts"; + +describe("e2e vitest config", () => { + it("runs as a standalone config instead of inheriting unit projects", () => { + expect(e2eConfig.test?.projects).toBeUndefined(); + }); + + it("includes e2e test globs and runtime setup", () => { + expect(e2eConfig.test?.include).toEqual([ + "test/**/*.e2e.test.ts", + "src/**/*.e2e.test.ts", + BUNDLED_PLUGIN_E2E_TEST_GLOB, + ]); + expect(e2eConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts"); + }); +}); diff --git a/src/infra/vitest-live-config.test.ts b/src/infra/vitest-live-config.test.ts new file mode 100644 index 00000000000..cc4811bfc57 --- /dev/null +++ b/src/infra/vitest-live-config.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts"; +import liveConfig from "../../vitest.live.config.ts"; + +describe("live vitest config", () => { + it("runs as a standalone config instead of inheriting unit projects", () => { + expect(liveConfig.test?.projects).toBeUndefined(); + }); + + it("includes live test globs and runtime setup", () => { + expect(liveConfig.test?.include).toEqual([ + "src/**/*.live.test.ts", + BUNDLED_PLUGIN_LIVE_TEST_GLOB, + ]); + expect(liveConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts"); + }); +}); diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index f4657eadfe9..d10e91b16e4 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import * as tar from "tar"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { runCommandWithTimeout } from "../process/exec.js"; import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js"; import { expectIntegrityDriftRejected, @@ -10,8 +9,10 @@ import { } from "../test-utils/npm-spec-install-test-helpers.js"; import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js"; +const runCommandWithTimeoutMock = vi.fn(); + vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: vi.fn(), + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), })); let suiteTempRoot = ""; @@ -127,7 +128,7 @@ afterAll(() => { }); beforeEach(() => { - vi.clearAllMocks(); + runCommandWithTimeoutMock.mockReset(); vi.unstubAllEnvs(); }); @@ -137,7 +138,7 @@ describe("installPluginFromNpmSpec", () => { const extensionsDir = path.join(stateDir, "extensions"); fs.mkdirSync(extensionsDir, { recursive: true }); - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1"); let packTmpDir = ""; @@ -180,7 +181,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.npmResolution?.integrity).toBe("sha512-plugin-test"); expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls, + calls: run.mock.calls as Array<[unknown, unknown]>, expectedSpec: "@openclaw/voice-call@0.0.1", }); @@ -205,7 +206,7 @@ describe("installPluginFromNpmSpec", () => { }); const archiveBuffer = fs.readFileSync(archivePath); - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; let packTmpDir = ""; const packedName = "dangerous-plugin-1.0.0.tgz"; run.mockImplementation(async (argv, opts) => { @@ -253,7 +254,7 @@ describe("installPluginFromNpmSpec", () => { ), ).toBe(true); expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls, + calls: run.mock.calls as Array<[unknown, unknown]>, expectedSpec: "dangerous-plugin@1.0.0", }); expect(packTmpDir).not.toBe(""); @@ -270,7 +271,7 @@ describe("installPluginFromNpmSpec", () => { }); it("aborts when integrity drift callback rejects the fetched artifact", async () => { - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; mockNpmPackMetadataResult(run, { id: "@openclaw/voice-call@0.0.1", name: "@openclaw/voice-call", @@ -295,7 +296,7 @@ describe("installPluginFromNpmSpec", () => { }); it("classifies npm package-not-found errors with a stable error code", async () => { - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; run.mockResolvedValue({ code: 1, stdout: "", @@ -326,7 +327,7 @@ describe("installPluginFromNpmSpec", () => { }; { - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; mockNpmPackMetadataResult(run, prereleaseMetadata); const result = await installPluginFromNpmSpec({ @@ -340,10 +341,10 @@ describe("installPluginFromNpmSpec", () => { } } - vi.clearAllMocks(); + runCommandWithTimeoutMock.mockReset(); { - const run = vi.mocked(runCommandWithTimeout); + const run = runCommandWithTimeoutMock; let packTmpDir = ""; const packedName = "voice-call-0.0.2-beta.1.tgz"; const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1"); @@ -378,7 +379,7 @@ describe("installPluginFromNpmSpec", () => { expect(result.npmResolution?.version).toBe("0.0.2-beta.1"); expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1"); expectSingleNpmPackIgnoreScriptsCall({ - calls: run.mock.calls, + calls: run.mock.calls as Array<[unknown, unknown]>, expectedSpec: "@openclaw/voice-call@beta", }); expect(packTmpDir).not.toBe(""); diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index b589d109cfb..90dbe55e494 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -47,6 +47,12 @@ type LaneState = { generation: number; }; +type ActiveTaskWaiter = { + activeTaskIds: Set; + resolve: (value: { drained: boolean }) => void; + timeout?: ReturnType; +}; + function isExpectedNonErrorLaneFailure(err: unknown): boolean { return err instanceof Error && err.name === "LiveSessionModelSwitchError"; } @@ -61,6 +67,7 @@ function getQueueState() { return resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({ gatewayDraining: false, lanes: new Map(), + activeTaskWaiters: new Set(), nextTaskId: 1, })); } @@ -99,6 +106,38 @@ function completeTask(state: LaneState, taskId: number, taskGeneration: number): return true; } +function hasPendingActiveTasks(taskIds: Set): boolean { + const queueState = getQueueState(); + for (const state of queueState.lanes.values()) { + for (const taskId of state.activeTaskIds) { + if (taskIds.has(taskId)) { + return true; + } + } + } + return false; +} + +function resolveActiveTaskWaiter(waiter: ActiveTaskWaiter, result: { drained: boolean }): void { + const queueState = getQueueState(); + if (!queueState.activeTaskWaiters.delete(waiter)) { + return; + } + if (waiter.timeout) { + clearTimeout(waiter.timeout); + } + waiter.resolve(result); +} + +function notifyActiveTaskWaiters(): void { + const queueState = getQueueState(); + for (const waiter of Array.from(queueState.activeTaskWaiters)) { + if (waiter.activeTaskIds.size === 0 || !hasPendingActiveTasks(waiter.activeTaskIds)) { + resolveActiveTaskWaiter(waiter, { drained: true }); + } + } +} + function drainLane(lane: string) { const state = getLaneState(lane); if (state.draining) { @@ -136,6 +175,7 @@ function drainLane(lane: string) { const result = await entry.task(); const completedCurrentGeneration = completeTask(state, taskId, taskGeneration); if (completedCurrentGeneration) { + notifyActiveTaskWaiters(); diag.debug( `lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.activeTaskIds.size} queued=${state.queue.length}`, ); @@ -155,6 +195,7 @@ function drainLane(lane: string) { ); } if (completedCurrentGeneration) { + notifyActiveTaskWaiters(); pump(); } entry.reject(err); @@ -263,6 +304,9 @@ export function resetCommandQueueStateForTest(): void { const queueState = getQueueState(); queueState.gatewayDraining = false; queueState.lanes.clear(); + for (const waiter of Array.from(queueState.activeTaskWaiters)) { + resolveActiveTaskWaiter(waiter, { drained: true }); + } queueState.nextTaskId = 1; } @@ -296,6 +340,7 @@ export function resetAllLanes(): void { for (const lane of lanesToDrain) { drainLane(lane); } + notifyActiveTaskWaiters(); } /** @@ -320,9 +365,6 @@ export function getActiveTaskCount(): number { * already executing are waited on. */ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolean }> { - // Keep shutdown/drain checks responsive without busy looping. - const POLL_INTERVAL_MS = 50; - const deadline = Date.now() + timeoutMs; const queueState = getQueueState(); const activeAtStart = new Set(); for (const state of queueState.lanes.values()) { @@ -331,36 +373,22 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea } } + if (activeAtStart.size === 0) { + return Promise.resolve({ drained: true }); + } + if (timeoutMs <= 0) { + return Promise.resolve({ drained: false }); + } + return new Promise((resolve) => { - const check = () => { - if (activeAtStart.size === 0) { - resolve({ drained: true }); - return; - } - - let hasPending = false; - for (const state of queueState.lanes.values()) { - for (const taskId of state.activeTaskIds) { - if (activeAtStart.has(taskId)) { - hasPending = true; - break; - } - } - if (hasPending) { - break; - } - } - - if (!hasPending) { - resolve({ drained: true }); - return; - } - if (Date.now() >= deadline) { - resolve({ drained: false }); - return; - } - setTimeout(check, POLL_INTERVAL_MS); + const waiter: ActiveTaskWaiter = { + activeTaskIds: activeAtStart, + resolve, }; - check(); + waiter.timeout = setTimeout(() => { + resolveActiveTaskWaiter(waiter, { drained: false }); + }, timeoutMs); + queueState.activeTaskWaiters.add(waiter); + notifyActiveTaskWaiters(); }); } diff --git a/vitest.bundled-plugin-paths.ts b/vitest.bundled-plugin-paths.ts new file mode 100644 index 00000000000..aebf71ffeeb --- /dev/null +++ b/vitest.bundled-plugin-paths.ts @@ -0,0 +1,5 @@ +export const BUNDLED_PLUGIN_ROOT_DIR = "extensions"; +export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`; +export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`; +export const BUNDLED_PLUGIN_E2E_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.e2e.test.ts`; +export const BUNDLED_PLUGIN_LIVE_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.live.test.ts`; diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index d3c75d8868a..81f08ad0d1c 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,6 @@ import os from "node:os"; import { defineConfig } from "vitest/config"; -import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs"; +import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts"; import baseConfig from "./vitest.config.ts"; const base = baseConfig as unknown as Record; @@ -15,8 +15,14 @@ const e2eWorkers = : defaultWorkers; const verboseE2E = process.env.OPENCLAW_E2E_VERBOSE === "1"; -const baseTest = - (baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {}; +const baseTestWithProjects = + (baseConfig as { test?: { exclude?: string[]; projects?: string[]; setupFiles?: string[] } }) + .test ?? {}; +const { projects: _projects, ...baseTest } = baseTestWithProjects as { + exclude?: string[]; + projects?: string[]; + setupFiles?: string[]; +}; const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.e2e.test.ts"); export default defineConfig({ diff --git a/vitest.extensions.config.ts b/vitest.extensions.config.ts index 0caebed9e7a..d55713073e9 100644 --- a/vitest.extensions.config.ts +++ b/vitest.extensions.config.ts @@ -1,4 +1,4 @@ -import { BUNDLED_PLUGIN_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs"; +import { BUNDLED_PLUGIN_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts"; import { extensionExcludedChannelTestGlobs } from "./vitest.channel-paths.mjs"; import { loadPatternListFromEnv } from "./vitest.pattern-file.ts"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; diff --git a/vitest.live.config.ts b/vitest.live.config.ts index 4b339090b51..f2d15d6de70 100644 --- a/vitest.live.config.ts +++ b/vitest.live.config.ts @@ -1,10 +1,15 @@ import { defineConfig } from "vitest/config"; -import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs"; +import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts"; import baseConfig from "./vitest.config.ts"; const base = baseConfig as unknown as Record; -const baseTest = +const baseTestWithProjects = (baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {}; +const { projects: _projects, ...baseTest } = baseTestWithProjects as { + exclude?: string[]; + projects?: string[]; + setupFiles?: string[]; +}; const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.live.test.ts"); export default defineConfig({ diff --git a/vitest.shared.config.ts b/vitest.shared.config.ts index 0eddf717b8d..c0809c62c30 100644 --- a/vitest.shared.config.ts +++ b/vitest.shared.config.ts @@ -1,11 +1,11 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs"; import { BUNDLED_PLUGIN_ROOT_DIR, BUNDLED_PLUGIN_TEST_GLOB, -} from "./scripts/lib/bundled-plugin-paths.mjs"; -import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs"; +} from "./vitest.bundled-plugin-paths.ts"; import { loadVitestExperimentalConfig } from "./vitest.performance-config.ts"; const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));