diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 37f815e8bfb..15579e18fac 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -369,6 +369,7 @@ describe("runCodexAppServerAttempt", () => { afterEach(async () => { __testing.resetCodexAppServerClientFactoryForTests(); + __testing.resetOpenClawCodingToolsFactoryForTests(); resetCodexRateLimitCacheForTests(); nativeHookRelayTesting.clearNativeHookRelaysForTests(); resetAgentEventsForTest(); @@ -475,6 +476,18 @@ describe("runCodexAppServerAttempt", () => { params.config = { tools: { profile: "coding" } }; params.sourceReplyDeliveryMode = "message_tool_only"; params.messageProvider = "whatsapp"; + let seenForceMessageTool: boolean | undefined; + __testing.setOpenClawCodingToolsFactoryForTests((options) => { + seenForceMessageTool = options?.forceMessageTool; + return [ + { + name: "message", + description: "message test tool", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ] as never; + }); const dynamicTools = await __testing.buildDynamicTools({ params, @@ -489,6 +502,7 @@ describe("runCodexAppServerAttempt", () => { }); const dynamicToolNames = dynamicTools.map((tool) => tool.name); + expect(seenForceMessageTool).toBe(true); expect(dynamicToolNames).toContain("message"); }); @@ -517,6 +531,18 @@ describe("runCodexAppServerAttempt", () => { }, }), ); + let seenRunSessionKey: string | undefined; + __testing.setOpenClawCodingToolsFactoryForTests((options) => { + seenRunSessionKey = options?.runSessionKey; + return [ + { + name: "session_status", + description: "session status test tool", + parameters: { type: "object", properties: {} }, + execute: vi.fn(async () => ({ details: { sessionKey: options?.runSessionKey } })), + }, + ] as never; + }); const dynamicTools = await __testing.buildDynamicTools({ params, @@ -533,6 +559,7 @@ describe("runCodexAppServerAttempt", () => { expect(sessionStatus).toBeDefined(); const result = await sessionStatus?.execute("call-current", { sessionKey: "current" }); + expect(seenRunSessionKey).toBe("agent:main:main"); expect((result?.details as { sessionKey?: string } | undefined)?.sessionKey).toBe( "agent:main:main", ); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index be8b1d73f68..3147e54a48a 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; @@ -46,8 +47,8 @@ import { resolveCodexAppServerAuthProfileIdForAgent, } from "./auth-bridge.js"; import { - createCodexAppServerClientFactoryTestHooks, defaultCodexAppServerClientFactory, + type CodexAppServerClientFactory, } from "./client-factory.js"; import { isCodexAppServerApprovalRequest, @@ -126,8 +127,16 @@ const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([ type OpenClawCodingToolsOptions = NonNullable< Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0] >; +type OpenClawCodingToolsFactory = + (typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]; -let clientFactory = defaultCodexAppServerClientFactory; +const testClientFactoryStorage = new AsyncLocalStorage(); +const clientFactory = defaultCodexAppServerClientFactory; +let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined; + +function resolveCodexAppServerClientFactory(): CodexAppServerClientFactory { + return testClientFactoryStorage.getStore() ?? clientFactory; +} function emitCodexAppServerEvent( params: EmbeddedRunAttemptParams, @@ -351,7 +360,7 @@ export async function runCodexAppServerAttempt( } = {}, ): Promise { const attemptStartedAt = Date.now(); - const attemptClientFactory = clientFactory; + const attemptClientFactory = resolveCodexAppServerClientFactory(); const pluginConfig = readCodexPluginConfig(options.pluginConfig); const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig }); const resolvedWorkspace = resolveUserPath(params.workspaceDir); @@ -1478,7 +1487,9 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { } const modelHasVision = params.model.input?.includes("image") ?? false; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId); - const { createOpenClawCodingTools } = await import("openclaw/plugin-sdk/agent-harness"); + const createOpenClawCodingTools = + openClawCodingToolsFactoryForTests ?? + (await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools; const allTools = createOpenClawCodingTools({ agentId: input.sessionAgentId, ...buildEmbeddedAttemptToolRunContext(params), @@ -1963,7 +1974,16 @@ export const __testing = { buildDynamicTools, filterToolsForVisionInputs, handleDynamicToolCallWithTimeout, - ...createCodexAppServerClientFactoryTestHooks((factory) => { - clientFactory = factory; - }), + setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void { + openClawCodingToolsFactoryForTests = factory; + }, + resetOpenClawCodingToolsFactoryForTests(): void { + openClawCodingToolsFactoryForTests = undefined; + }, + setCodexAppServerClientFactoryForTests(factory: CodexAppServerClientFactory): void { + testClientFactoryStorage.enterWith(factory); + }, + resetCodexAppServerClientFactoryForTests(): void { + testClientFactoryStorage.enterWith(undefined); + }, } as const; diff --git a/src/agents/provider-model-normalization.runtime.ts b/src/agents/provider-model-normalization.runtime.ts index af0f9cc7467..34cc0996065 100644 --- a/src/agents/provider-model-normalization.runtime.ts +++ b/src/agents/provider-model-normalization.runtime.ts @@ -12,11 +12,16 @@ const PROVIDER_RUNTIME_CANDIDATES = [ ] as const; let providerRuntimeModule: ProviderRuntimeModule | undefined; +let providerRuntimeLoadAttempted = false; function loadProviderRuntime(): ProviderRuntimeModule | null { if (providerRuntimeModule) { return providerRuntimeModule; } + if (providerRuntimeLoadAttempted) { + return null; + } + providerRuntimeLoadAttempted = true; for (const candidate of PROVIDER_RUNTIME_CANDIDATES) { try { providerRuntimeModule = require(candidate) as ProviderRuntimeModule; diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index b94ab42ea05..f99aa7586b0 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -14,7 +14,7 @@ process.env.FORCE_COLOR = "0"; mockSessionsConfig(); -import { sessionsCommand } from "./sessions.js"; +import { sessionsCommand, __testing } from "./sessions.js"; describe("sessionsCommand", () => { beforeEach(() => { @@ -226,31 +226,8 @@ describe("sessionsCommand", () => { expect(payload.sessions?.map((row) => row.key)).toEqual(["recent"]); }); - it("limits JSON output to the newest 100 sessions by default", async () => { - const entries: Record = {}; - for (let i = 0; i < 105; i += 1) { - entries[`session-${String(i).padStart(3, "0")}`] = { - sessionId: `session-${i}`, - updatedAt: Date.now() - i * 60_000, - model: "pi:opus", - }; - } - const store = writeStore(entries, "sessions-default-limit"); - - const payload = await runSessionsJson<{ - count?: number; - totalCount?: number; - limitApplied?: number | null; - hasMore?: boolean; - sessions?: Array<{ key: string }>; - }>(sessionsCommand, store); - - expect(payload.count).toBe(100); - expect(payload.totalCount).toBe(105); - expect(payload.limitApplied).toBe(100); - expect(payload.hasMore).toBe(true); - expect(payload.sessions?.at(0)?.key).toBe("session-000"); - expect(payload.sessions?.some((row) => row.key === "session-104")).toBe(false); + it("uses a default JSON output limit of 100 sessions", () => { + expect(__testing.parseSessionsLimit(undefined)).toBe(100); }); it("honors explicit JSON output limits", async () => { diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 0c9f829c7cd..5b841c305c4 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -415,3 +415,7 @@ export async function sessionsCommand( runtime.log(line.trimEnd()); } } + +export const __testing = { + parseSessionsLimit, +} as const; diff --git a/src/logging.ts b/src/logging.ts index 6662e939dd2..acadd0455c7 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -20,6 +20,7 @@ import { getResolvedLoggerSettings, isFileLogLevelEnabled, resetLogger, + setLoggerConfigLoaderForTests, setLoggerOverride, toPinoLikeLogger, } from "./logging/logger.js"; @@ -50,6 +51,7 @@ export { getResolvedLoggerSettings, isFileLogLevelEnabled, resetLogger, + setLoggerConfigLoaderForTests, setLoggerOverride, toPinoLikeLogger, createSubsystemLogger, diff --git a/src/logging/logger-settings.test.ts b/src/logging/logger-settings.test.ts index 48319bf4fa0..dddf56e169e 100644 --- a/src/logging/logger-settings.test.ts +++ b/src/logging/logger-settings.test.ts @@ -1,15 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { readLoggingConfigMock, shouldSkipMutatingLoggingConfigReadMock } = vi.hoisted(() => ({ - readLoggingConfigMock: vi.fn<() => unknown>(() => undefined), - shouldSkipMutatingLoggingConfigReadMock: vi.fn(() => false), -})); - -vi.mock("./config.js", () => ({ - readLoggingConfig: readLoggingConfigMock, - shouldSkipMutatingLoggingConfigRead: shouldSkipMutatingLoggingConfigReadMock, -})); - let originalTestFileLog: string | undefined; let originalOpenClawLogLevel: string | undefined; let logging: typeof import("../logging.js"); @@ -23,10 +13,6 @@ beforeEach(() => { originalOpenClawLogLevel = process.env.OPENCLAW_LOG_LEVEL; delete process.env.OPENCLAW_TEST_FILE_LOG; delete process.env.OPENCLAW_LOG_LEVEL; - readLoggingConfigMock.mockReset(); - readLoggingConfigMock.mockReturnValue(undefined); - shouldSkipMutatingLoggingConfigReadMock.mockReset(); - shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(false); logging.resetLogger(); logging.setLoggerOverride(null); }); @@ -44,23 +30,28 @@ afterEach(() => { } logging.resetLogger(); logging.setLoggerOverride(null); + logging.setLoggerConfigLoaderForTests(); vi.restoreAllMocks(); }); describe("getResolvedLoggerSettings", () => { it("uses a silent fast path in default Vitest mode without config reads", () => { + const readLoggingConfig = vi.fn(() => undefined); + logging.setLoggerConfigLoaderForTests(readLoggingConfig); + const settings = logging.getResolvedLoggerSettings(); + expect(settings.level).toBe("silent"); - expect(readLoggingConfigMock).not.toHaveBeenCalled(); + expect(readLoggingConfig).not.toHaveBeenCalled(); }); it("reads logging config when test file logging is explicitly enabled", () => { process.env.OPENCLAW_TEST_FILE_LOG = "1"; - readLoggingConfigMock.mockReturnValue({ + logging.setLoggerConfigLoaderForTests(() => ({ level: "debug", file: "/tmp/openclaw-configured.log", maxFileBytes: 2048, - }); + })); const settings = logging.getResolvedLoggerSettings(); @@ -71,9 +62,9 @@ describe("getResolvedLoggerSettings", () => { }); }); - it("uses defaults when config schema skips logging config reads", () => { + it("uses defaults when no logging config is available", () => { process.env.OPENCLAW_TEST_FILE_LOG = "1"; - shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(true); + logging.setLoggerConfigLoaderForTests(() => undefined); const settings = logging.getResolvedLoggerSettings(); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 5bc562cc4f9..abf1f8f5cb5 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -70,6 +70,7 @@ type ResolvedSettings = { }; export type LoggerResolvedSettings = ResolvedSettings; type TsLogRecord = Record; +type LoggerConfigLoader = () => OpenClawConfig["logging"] | undefined; type DiagnosticLogCode = { line?: number; @@ -78,6 +79,15 @@ type DiagnosticLogCode = { const MAX_DIAGNOSTIC_LOG_BINDINGS_JSON_CHARS = 8 * 1024; const MAX_DIAGNOSTIC_LOG_MESSAGE_CHARS = 4 * 1024; + +const loadLoggerConfigDefault: LoggerConfigLoader = () => readLoggingConfig(); +let loadLoggerConfig: LoggerConfigLoader = loadLoggerConfigDefault; + +export function setLoggerConfigLoaderForTests(loader?: LoggerConfigLoader): void { + loadLoggerConfig = loader ?? loadLoggerConfigDefault; + loggingState.cachedLogger = null; + loggingState.cachedSettings = null; +} const MAX_DIAGNOSTIC_LOG_ATTRIBUTE_COUNT = 32; const MAX_DIAGNOSTIC_LOG_ATTRIBUTE_VALUE_CHARS = 2 * 1024; const MAX_DIAGNOSTIC_LOG_NAME_CHARS = 120; @@ -473,7 +483,7 @@ function resolveSettings(): ResolvedSettings { } const cfg: OpenClawConfig["logging"] | undefined = - (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); + (loggingState.overrideSettings as LoggerSettings | null) ?? loadLoggerConfig(); const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel); @@ -670,6 +680,7 @@ export function resetLogger() { loggingState.cachedSettings = null; loggingState.cachedConsoleSettings = null; loggingState.overrideSettings = null; + loadLoggerConfig = loadLoggerConfigDefault; } export const __test__ = { diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 13d9a7defaa..82a2acfb6a4 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -87,7 +87,11 @@ export { } from "../agents/pi-embedded-subscribe.tools.js"; export { normalizeUsage } from "../agents/usage.js"; export { resolveOpenClawAgentDir } from "./agent-dir-compat.js"; -export { resolveAgentDir, resolveSessionAgentIds } from "../agents/agent-scope.js"; +export { + resolveAgentDir, + resolveDefaultAgentDir, + resolveSessionAgentIds, +} from "../agents/agent-scope.js"; export { resolveModelAuthMode } from "../agents/model-auth.js"; export { supportsModelTools } from "../agents/model-tool-support.js"; export { resolveAttemptSpawnWorkspaceDir } from "../agents/pi-embedded-runner/run/attempt.thread-helpers.js";