diff --git a/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts b/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts index 654f2da40c92..0b6bcc5acb9b 100644 --- a/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts +++ b/extensions/browser/src/gateway/browser-request.shared-control-state.test.ts @@ -1,7 +1,8 @@ // Browser tests cover browser request.shared control state plugin behavior. +import { createServer } from "node:http"; import { expectDefined } from "@openclaw/normalization-core"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { getFreePort } from "../browser/test-port.js"; import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ @@ -31,6 +32,11 @@ vi.mock("../browser/control-auth.js", () => ({ shouldAutoGenerateBrowserAuth: mocks.shouldAutoGenerateBrowserAuth, })); +// This suite tests shared state; server auth/bind suites cover real HTTP listeners. +vi.mock("../browser/http-listen.js", () => ({ + listenBrowserHttpServer: vi.fn(async () => createServer()), +})); + vi.mock("../browser/server-lifecycle.js", () => ({ stopKnownBrowserProfiles: mocks.stopKnownBrowserProfiles, })); @@ -109,6 +115,7 @@ async function browserRequestStatus(): Promise { } describe("browser.request local control state", () => { + const controlPort = 18_791; afterEach(async () => { await stopBrowserControlService(); await stopBrowserControlServer(); @@ -117,7 +124,6 @@ describe("browser.request local control state", () => { }); it("uses the same resolved browser config as the HTTP control service", async () => { - const controlPort = await getFreePort(); const gatewayPort = controlPort - 2; mocks.runtimeConfig = browserConfig({ @@ -150,7 +156,6 @@ describe("browser.request local control state", () => { }); it("retains port auth until a failed stop is retried successfully", async () => { - const controlPort = await getFreePort(); mocks.runtimeConfig = browserConfig({ gatewayPort: controlPort - 2 }); mocks.runtimeSourceConfig = mocks.runtimeConfig; mocks.ensureBrowserControlAuth.mockResolvedValueOnce({ auth: { token: "test-token" } }); @@ -167,21 +172,20 @@ describe("browser.request local control state", () => { }); it("clears auth when a stop queues behind cold startup", async () => { - const controlPort = await getFreePort(); mocks.runtimeConfig = browserConfig({ gatewayPort: controlPort - 2 }); mocks.runtimeSourceConfig = mocks.runtimeConfig; - let releaseAuth!: () => void; - const authGate = new Promise((resolve) => { - releaseAuth = resolve; - }); + const authStarted = createDeferred(); + const authGate = createDeferred(); mocks.ensureBrowserControlAuth.mockImplementationOnce(async () => { - await authGate; + authStarted.resolve(); + await authGate.promise; return { auth: { token: "test-token" } }; }); const starting = startBrowserControlServerFromConfig(); + await authStarted.promise; const stopping = stopBrowserControlServer(); - releaseAuth(); + authGate.resolve(); await expect(starting).resolves.toBeTruthy(); await expect(stopping).resolves.toBeUndefined(); expect(getBridgeAuthForPort(controlPort)).toBeUndefined(); diff --git a/extensions/voice-call/src/cli-call-log.test.ts b/extensions/voice-call/src/cli-call-log.test.ts index 239ee4b506b4..6861d330b25f 100644 --- a/extensions/voice-call/src/cli-call-log.test.ts +++ b/extensions/voice-call/src/cli-call-log.test.ts @@ -185,9 +185,12 @@ describe("voice-call diagnostic stream ownership", () => { } const fd = opened.value; expect(fs.fstatSync(fd).isFile()).toBe(true); + // The OS can reuse fd before the command continuation resumes; observe the real close. + const close = vi.spyOn(fs, "closeSync"); process.stdout.emit("error", failed); expect(await running).toBe(failed); - expect(() => fs.fstatSync(fd)).toThrow(); + expect(close).toHaveBeenCalledExactlyOnceWith(fd); + expect(close).toHaveReturnedWith(undefined); expect(sleepMock).not.toHaveBeenCalled(); expect(process.stdout.listenerCount("drain")).toBe(drainListeners); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts index 4c93c0ef52f8..0fedb90ec511 100644 --- a/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts @@ -69,6 +69,7 @@ function normalizeMockProviderId(providerId?: string): string { type SessionManagerMocks = { getSessionTarget: Mock<() => undefined>; + getAppendParentId: Mock<() => string | null>; getLeafId: Mock<() => string | null>; getLeafEntry: UnknownMock; getEntry: UnknownMock; @@ -79,6 +80,7 @@ type SessionManagerMocks = { appendThinkingLevelChange: UnknownMock; appendModelChange: UnknownMock; appendCustomEntry: UnknownMock; + appendMessage: UnknownMock; appendSessionInfo: UnknownMock; appendLabelChange: UnknownMock; flushPendingPersistence: UnknownMock; @@ -277,6 +279,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const trajectoryEvents: CapturedTrajectoryEvent[] = []; const sessionManager = { getSessionTarget: vi.fn(() => undefined), + getAppendParentId: vi.fn<() => string | null>(() => null), getLeafId: vi.fn<() => string | null>(() => null), getLeafEntry: vi.fn(() => null), getEntry: vi.fn(() => undefined), @@ -287,6 +290,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { appendThinkingLevelChange: vi.fn(), appendModelChange: vi.fn(), appendCustomEntry: vi.fn(), + appendMessage: vi.fn(), appendSessionInfo: vi.fn(), appendLabelChange: vi.fn(), flushPendingPersistence: vi.fn(), @@ -792,20 +796,12 @@ vi.mock("../../sandbox/runtime-status.js", () => ({ }), })); -vi.mock("../../tool-call-id.js", async (importOriginal) => { - return await importOriginal(); -}); - vi.mock("../../tool-fs-policy.js", () => ({ resolveSessionPermissionExecMode: (policy: { mode: string }) => ({ "read-only": "deny", guarded: "ask", workspace: "auto", full: "full" })[policy.mode], resolveEffectiveToolFsWorkspaceOnly: () => false, })); -vi.mock("../../tool-policy.js", async (importOriginal) => { - return await importOriginal(); -}); - vi.mock("../../transcript-policy.js", () => ({ resolveTranscriptPolicy: () => ({ allowSyntheticToolResults: false, @@ -927,13 +923,6 @@ vi.mock("../thinking.js", async (importOriginal) => { }; }); -vi.mock("../tool-name-allowlist.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - }; -}); - vi.mock("../tool-split.js", () => ({ splitSdkTools: ({ tools }: { tools: unknown[] }) => ({ customTools: tools, @@ -1152,6 +1141,7 @@ export function resetEmbeddedAttemptHarness( hoisted.embeddedSystemPromptInputs.length = 0; hoisted.trajectoryEvents.length = 0; hoisted.sessionManager.getSessionTarget.mockReset().mockReturnValue(undefined); + hoisted.sessionManager.getAppendParentId.mockReset().mockReturnValue(null); hoisted.sessionManager.getLeafId.mockReset().mockReturnValue(null); hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); hoisted.sessionManager.getEntry.mockReset().mockReturnValue(undefined); @@ -1165,6 +1155,7 @@ export function resetEmbeddedAttemptHarness( hoisted.sessionManager.appendThinkingLevelChange.mockReset(); hoisted.sessionManager.appendModelChange.mockReset(); hoisted.sessionManager.appendCustomEntry.mockReset(); + hoisted.sessionManager.appendMessage.mockReset(); hoisted.sessionManager.appendSessionInfo.mockReset(); hoisted.sessionManager.appendLabelChange.mockReset(); hoisted.sessionManager.flushPendingPersistence.mockReset(); diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts b/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts index c386103fe390..146160ffa695 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts @@ -10,8 +10,10 @@ import { } from "../../../infra/diagnostic-events.js"; import { wrapToolWithBeforeToolCallHook } from "../../agent-tools.before-tool-call.js"; import type { createOpenClawCodingTools } from "../../agent-tools.js"; -import { Agent, type AgentTool } from "../../runtime/index.js"; +import { Agent, type AgentEvent, type AgentTool } from "../../runtime/index.js"; import { getInternalToolExecutionPreparer } from "../../runtime/internal-hooks.js"; +import { TOOL_EXECUTION_GATED_MESSAGE } from "../../tool-policy-shared.js"; +import { isToolResultError } from "../../tool-result-error.js"; import type { ToolSearchCatalogRef } from "../../tool-search.js"; import { createAgentsWaitTool } from "../../tools/agents-wait-tool.js"; import { createSessionsSpawnTool } from "../../tools/sessions-spawn-tool.js"; @@ -89,7 +91,7 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => { }, ])( "does not enter the original preparer or action through denied $mode", - async ({ toolName, code }) => { + async ({ mode, toolName, code }) => { const execute = vi.fn(async () => ({ content: [], details: {} })); const prepare = vi.fn(async (args: unknown) => args); const native = @@ -101,8 +103,7 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => { const source = wrapToolWithBeforeToolCallHook(native); expect(getInternalToolExecutionPreparer(source)).toBeDefined(); hoisted.createOpenClawCodingToolsMock.mockReturnValue([source]); - const observed: AssistantMessage["content"][] = []; - const outcomes: Array<{ toolName: string; isError: boolean }> = []; + const outcomes: Extract[] = []; await createContextEngineAttemptRunner({ contextEngine: createContextEngineBootstrapAndAssemble(), sessionKey: "agent:main:main", @@ -119,6 +120,10 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => { let turn = 0; const agent = new Agent({ initialState: { model: options.model, tools: allTools }, + // AgentSession's result middleware normally classifies structured tool failures. + afterToolCall: async ({ result, isError }) => ({ + isError: isError || isToolResultError(result), + }), streamFn: () => { const content: AssistantMessage["content"] = turn++ === 0 @@ -135,7 +140,6 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => { }, ] : [{ type: "text", text: "Denied as expected." }]; - observed.push(content); const message: AssistantMessage = { role: "assistant", content, @@ -190,10 +194,18 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => { config: { tools: { codeMode: Boolean(code), toolSearch: false } }, }, }); - expect(observed.length).toBeGreaterThanOrEqual(1); expect(outcomes).toContainEqual( expect.objectContaining({ toolName: code ? "exec" : toolName, isError: true }), ); + if (code) { + expect(outcomes[0]?.result).toMatchObject({ + details: { + error: expect.stringContaining( + mode === "joined Code Mode" ? "agents is not defined" : TOOL_EXECUTION_GATED_MESSAGE, + ), + }, + }); + } expect(prepare).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); }, diff --git a/src/process/supervisor/adapters/child.service-lifecycle.test.ts b/src/process/supervisor/adapters/child.service-lifecycle.test.ts index bea3b4ad4407..50a599c6b8fe 100644 --- a/src/process/supervisor/adapters/child.service-lifecycle.test.ts +++ b/src/process/supervisor/adapters/child.service-lifecycle.test.ts @@ -171,6 +171,7 @@ describe.skipIf(process.platform === "win32")("service-managed child lifecycle", const command = ` process.on("SIGTERM", () => { require("node:fs").writeFileSync(${JSON.stringify(termPath)}, String(process.pid)); + process.exit(0); }); require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid)); setInterval(() => {}, 1000); @@ -212,8 +213,8 @@ describe.skipIf(process.platform === "win32")("service-managed child lifecycle", await expect(supervisor.waitForScope(runId)).rejects.toThrow("cleanup identity lost"); await expect(run.waitForExtinction?.()).rejects.toThrow("cleanup identity lost"); await expect(supervisor.shutdown()).rejects.toThrow("cleanup identity lost"); - // Observe the anchor's TERM before its unchanged grace; neither the timeout - // result nor the failed join may disable its independent group cleanup. + // TERM must still reach the command after failed cleanup joins. Dedicated + // escalation cases cover commands that keep running through the TERM grace. await waitForPidFile(termPath, 5_000, realDelay); await waitFor(() => !isAlive(startedPid)); } finally {