From 0e8a7e12da4aead65ac18c651bc4453a498eb9e8 Mon Sep 17 00:00:00 2001 From: pashpashpash Date: Sun, 10 May 2026 16:18:03 -0700 Subject: [PATCH] Enable Codex native code mode for OpenClaw harness runs (#80001) * fix(codex): enable native code mode in harness * test(codex): update code mode prompt snapshots * test(codex): align code mode thread config expectations * chore(protocol): refresh generated Swift agent params * fix(codex): enable code-mode-only harness threads * test(discord): fix test mock type assertions * test: fix remaining test type assertions * test(matrix): guard avatar loader test callback --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 4 + docs/plugins/codex-harness.md | 5 + extensions/codex/index.test.ts | 7 +- .../codex/src/app-server/run-attempt.test.ts | 64 +++++++++-- .../src/app-server/thread-lifecycle.test.ts | 35 ++++++ .../codex/src/app-server/thread-lifecycle.ts | 17 ++- .../discord/src/actions/runtime.test.ts | 104 ++++++++++++------ .../src/monitor/message-handler.queue.test.ts | 36 ++++-- .../discord/src/monitor/monitor.test.ts | 2 +- .../matrix/src/matrix/monitor/startup.test.ts | 14 ++- .../discord-group-codex-message-tool.md | 4 + .../telegram-direct-codex-message-tool.md | 4 + .../telegram-heartbeat-codex-tool.md | 4 + 14 files changed, 241 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477b5939828..55ebe220795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Discord/voice: default test and source installs to the pure-JS `opusscript` decoder by ignoring optional native `@discordjs/opus` builds, avoiding slow native addon compiles outside dedicated voice-performance lanes. - Discord/voice: add an opt-in native `@discordjs/opus` install script and decoder preference for live voice-performance lanes without charging unrelated Docker/tests for native addon builds. - Gateway/skills: add an opt-in private skill archive upload install path gated by `skills.install.allowUploadedArchives`, so trusted Gateway clients can stage and install zip-backed skills only when operators explicitly enable the code-install surface. (#74430) Thanks @samzong. +- Codex app-server: enable Codex native code-mode-only for harness threads so deferred OpenClaw dynamic tools run through Codex's own searchable code execution surface instead of a PI-style wrapper. - Dependencies: refresh workspace pins and patch targets, including ACPX `@agentclientprotocol/claude-agent-acp` `0.33.1`, Codex ACP `0.14.0`, Baileys `7.0.0-rc10`, Google GenAI `2.0.1`, OpenAI `6.37.0`, AWS SDK `3.1045.0`, Kysely `0.29.0`, Tlon skill `0.3.6`, Aimock `1.19.5`, and tsdown `0.22.0`. - Agents/compaction: preserve scoped background exec/process session references across embedded compaction and after-turn runtime contexts without exposing sessions from unrelated scopes. Fixes #79284. (#79307) Thanks @TurboTheTurtle. - Agents/process: tell agents to inspect background sessions with `process log` before sending interactive input and to use `waitingForInput`/`stdinWritable` hints from `log`/`poll`. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2020b92481a..86f813cc979 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -716,6 +716,7 @@ public struct AgentParams: Codable, Sendable { public let bootstrapcontextmode: AnyCodable? public let bootstrapcontextrunkind: AnyCodable? public let acpturnsource: String? + public let internalruntimehandoffid: String? public let internalevents: [[String: AnyCodable]]? public let inputprovenance: [String: AnyCodable]? public let voicewaketrigger: String? @@ -752,6 +753,7 @@ public struct AgentParams: Codable, Sendable { bootstrapcontextmode: AnyCodable?, bootstrapcontextrunkind: AnyCodable?, acpturnsource: String?, + internalruntimehandoffid: String?, internalevents: [[String: AnyCodable]]?, inputprovenance: [String: AnyCodable]?, voicewaketrigger: String?, @@ -787,6 +789,7 @@ public struct AgentParams: Codable, Sendable { self.bootstrapcontextmode = bootstrapcontextmode self.bootstrapcontextrunkind = bootstrapcontextrunkind self.acpturnsource = acpturnsource + self.internalruntimehandoffid = internalruntimehandoffid self.internalevents = internalevents self.inputprovenance = inputprovenance self.voicewaketrigger = voicewaketrigger @@ -824,6 +827,7 @@ public struct AgentParams: Codable, Sendable { case bootstrapcontextmode = "bootstrapContextMode" case bootstrapcontextrunkind = "bootstrapContextRunKind" case acpturnsource = "acpTurnSource" + case internalruntimehandoffid = "internalRuntimeHandoffId" case internalevents = "internalEvents" case inputprovenance = "inputProvenance" case voicewaketrigger = "voiceWakeTrigger" diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index a39e2b19138..58cdf1806f8 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -21,6 +21,11 @@ Do not configure `openai-codex/gpt-*` model refs. `openai-codex` is the auth profile provider for Codex OAuth or Codex API-key profiles, not the model provider prefix for new agent config. +OpenClaw starts Codex app-server threads with Codex native code mode and +code-mode-only enabled. That keeps deferred/searchable OpenClaw dynamic tools +inside Codex's own code execution and tool-search surface instead of adding a +PI-style tool-search wrapper on top of Codex. + For the broader model/provider/runtime split, start with [Agent runtimes](/concepts/agent-runtimes). The short version is: `openai/gpt-5.5` is the model ref, `codex` is the runtime, and Telegram, diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 6a05c146a40..8cd9fe7bf9a 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -81,6 +81,7 @@ describe("codex plugin", () => { }); it("registers with capture APIs that do not expose conversation binding hooks yet", () => { + const registerProvider = vi.fn(); const api = createTestPluginApi({ id: "codex", name: "Codex", @@ -91,7 +92,7 @@ describe("codex plugin", () => { registerAgentHarness: vi.fn(), registerCommand: vi.fn(), registerMediaUnderstandingProvider: vi.fn(), - registerProvider: vi.fn(), + registerProvider, on: vi.fn(), }) as ReturnType & { onConversationBindingResolved?: ReturnType; @@ -99,8 +100,8 @@ describe("codex plugin", () => { delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved; plugin.register(api); - expect(api.registerProvider).toHaveBeenCalledTimes(1); - expect(api.registerProvider.mock.calls[0]?.[0].id).toBe("codex"); + expect(registerProvider).toHaveBeenCalledTimes(1); + expect(registerProvider.mock.calls[0]?.[0].id).toBe("codex"); }); it("only claims the codex provider by default", () => { diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index ed4193f96a3..945549d05b4 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -3791,6 +3791,11 @@ describe("runCodexAppServerAttempt", () => { "features.codex_hooks": true, "hooks.PreToolUse": [], }; + const expectedConfig = { + ...config, + "features.code_mode": true, + "features.code_mode_only": true, + }; await startOrResumeThread({ client: { request } as never, @@ -3811,8 +3816,8 @@ describe("runCodexAppServerAttempt", () => { const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); - expect(requestCalls[0]?.[1].config).toEqual(config); - expect(requestCalls[1]?.[1].config).toEqual(config); + expect(requestCalls[0]?.[1].config).toEqual(expectedConfig); + expect(requestCalls[1]?.[1].config).toEqual(expectedConfig); }); it("merges native hook relay config with plugin app config when starting a thread", async () => { @@ -3856,6 +3861,8 @@ describe("runCodexAppServerAttempt", () => { expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); expect(requestCalls[0]?.[1].config).toEqual({ "features.codex_hooks": true, + "features.code_mode": true, + "features.code_mode_only": true, hooks: { PreToolUse: [] }, ...createPluginAppConfigPatch(), }); @@ -3921,9 +3928,15 @@ describe("runCodexAppServerAttempt", () => { expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); expect(requestCalls[0]?.[1].config).toEqual({ "features.codex_hooks": true, + "features.code_mode": true, + "features.code_mode_only": true, ...createPluginAppConfigPatch(), }); - expect(requestCalls[1]?.[1].config).toEqual({ "features.codex_hooks": true }); + expect(requestCalls[1]?.[1].config).toEqual({ + "features.codex_hooks": true, + "features.code_mode": true, + "features.code_mode_only": true, + }); }); it("starts a new plugin app thread when full binding revalidation removes an app", async () => { @@ -3979,6 +3992,8 @@ describe("runCodexAppServerAttempt", () => { const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); expect(requestCalls[0]?.[1].config).toEqual({ + "features.code_mode": true, + "features.code_mode_only": true, apps: { _default: { enabled: false, @@ -4030,7 +4045,10 @@ describe("runCodexAppServerAttempt", () => { const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]); - expect("config" in (requestCalls[0]?.[1] ?? {})).toBe(false); + expect(requestCalls[0]?.[1].config).toEqual({ + "features.code_mode": true, + "features.code_mode_only": true, + }); const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.threadId).toBe("thread-existing"); expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1"); @@ -4081,7 +4099,11 @@ describe("runCodexAppServerAttempt", () => { expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); - expect(requestCalls[0]?.[1].config).toEqual(createPluginAppConfigPatch()); + expect(requestCalls[0]?.[1].config).toEqual({ + ...createPluginAppConfigPatch(), + "features.code_mode": true, + "features.code_mode_only": true, + }); const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.threadId).toBe("thread-recovered"); expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1"); @@ -4139,7 +4161,10 @@ describe("runCodexAppServerAttempt", () => { expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/resume"]); - expect("config" in (requestCalls[0]?.[1] ?? {})).toBe(false); + expect(requestCalls[0]?.[1].config).toEqual({ + "features.code_mode": true, + "features.code_mode_only": true, + }); }); it("rebuilds a partial plugin app binding after another plugin recovers", async () => { @@ -4186,7 +4211,11 @@ describe("runCodexAppServerAttempt", () => { expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); - expect(requestCalls[0]?.[1].config).toEqual(createTwoPluginAppConfigPatch()); + expect(requestCalls[0]?.[1].config).toEqual({ + ...createTwoPluginAppConfigPatch(), + "features.code_mode": true, + "features.code_mode_only": true, + }); const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.threadId).toBe("thread-recovered"); expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-2"); @@ -4242,7 +4271,11 @@ describe("runCodexAppServerAttempt", () => { expect(buildPluginThreadConfig).toHaveBeenCalledTimes(1); const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); - expect(requestCalls[0]?.[1].config).toEqual(createTwoCalendarAppConfigPatch()); + expect(requestCalls[0]?.[1].config).toEqual({ + ...createTwoCalendarAppConfigPatch(), + "features.code_mode": true, + "features.code_mode_only": true, + }); const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.threadId).toBe("thread-recovered"); expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-calendar-2"); @@ -4285,7 +4318,11 @@ describe("runCodexAppServerAttempt", () => { const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start"]); - expect(requestCalls[0]?.[1].config).toEqual(createPluginAppConfigPatch()); + expect(requestCalls[0]?.[1].config).toEqual({ + ...createPluginAppConfigPatch(), + "features.code_mode": true, + "features.code_mode_only": true, + }); const binding = await readCodexAppServerBinding(sessionFile); expect(binding?.threadId).toBe("thread-plugins"); expect(binding?.pluginAppsFingerprint).toBe("plugin-apps-config-1"); @@ -4349,6 +4386,11 @@ describe("runCodexAppServerAttempt", () => { model: "gpt-5.4-codex", approvalPolicy: "on-request", approvalsReviewer: "guardian_subagent", + config: expect.objectContaining({ + "features.codex_hooks": true, + "features.code_mode": true, + "features.code_mode_only": true, + }), sandbox: "danger-full-access", serviceTier: "priority", developerInstructions: expect.stringContaining(CODEX_GPT5_BEHAVIOR_CONTRACT), @@ -4451,6 +4493,10 @@ describe("runCodexAppServerAttempt", () => { model: "gpt-5.4-codex", approvalPolicy: "on-request", approvalsReviewer: "guardian_subagent", + config: { + "features.code_mode": true, + "features.code_mode_only": true, + }, sandbox: "danger-full-access", serviceTier: "flex", developerInstructions: expect.stringContaining(CODEX_GPT5_BEHAVIOR_CONTRACT), diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index aa932cee3b5..bf07bea107e 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -47,6 +47,41 @@ function createAppServerOptions() { } as const; } +describe("Codex app-server native code mode config", () => { + it("enables Codex code-mode-only on thread/start without clobbering other config", () => { + const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), { + cwd: "/repo", + dynamicTools: [], + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + config: { + "features.codex_hooks": true, + apps: { _default: { enabled: false } }, + }, + }); + + expect(request.config).toEqual({ + "features.codex_hooks": true, + apps: { _default: { enabled: false } }, + "features.code_mode": true, + "features.code_mode_only": true, + }); + }); + + it("enables Codex code-mode-only on thread/resume", () => { + const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), { + threadId: "thread-1", + appServer: createAppServerOptions() as never, + developerInstructions: "test instructions", + }); + + expect(request.config).toEqual({ + "features.code_mode": true, + "features.code_mode_only": true, + }); + }); +}); + describe("Codex app-server model provider selection", () => { it.each(["openai", "openai-codex"])( "omits public %s modelProvider when forwarding native Codex auth on thread/start", diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 6d0a01274d3..663d6d8b7c1 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -44,6 +44,11 @@ export type CodexPluginThreadConfigProvider = { build: () => Promise; }; +const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = { + "features.code_mode": true, + "features.code_mode_only": true, +}; + export async function startOrResumeThread(params: { client: CodexAppServerClient; params: EmbeddedRunAttemptParams; @@ -304,7 +309,7 @@ export function buildThreadStartParams( sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), serviceName: "OpenClaw", - ...(options.config ? { config: options.config } : {}), + config: buildCodexRuntimeThreadConfig(options.config), developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), dynamicTools: options.dynamicTools, experimentalRawEvents: true, @@ -337,12 +342,20 @@ export function buildThreadResumeParams( approvalsReviewer: options.appServer.approvalsReviewer, sandbox: options.appServer.sandbox, ...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}), - ...(options.config ? { config: options.config } : {}), + config: buildCodexRuntimeThreadConfig(options.config), developerInstructions: options.developerInstructions ?? buildDeveloperInstructions(params), persistExtendedHistory: true, }; } +function buildCodexRuntimeThreadConfig(config: JsonObject | undefined): JsonObject { + return ( + mergeCodexThreadConfigs(config, CODEX_CODE_MODE_THREAD_CONFIG) ?? { + ...CODEX_CODE_MODE_THREAD_CONFIG, + } + ); +} + export function buildTurnStartParams( params: EmbeddedRunAttemptParams, options: { diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 3acfb862132..d3972321713 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -87,6 +87,29 @@ const { const enableAllActions = () => true; const DISCORD_TEST_CFG = EMPTY_DISCORD_TEST_CONFIG; +type MockCallSource = { mock: { calls: Array> } }; + +function mockCall(source: MockCallSource, label: string, callIndex = 0): Array { + const call = source.mock.calls[callIndex]; + if (!call) { + throw new Error(`expected ${label} call ${callIndex}`); + } + return call; +} + +function mockObjectArg( + source: MockCallSource, + label: string, + callIndex: number, + argIndex: number, +): Record { + const value = mockCall(source, label, callIndex)[argIndex]; + if (!value || typeof value !== "object") { + throw new Error(`expected ${label} call ${callIndex} argument ${argIndex} to be an object`); + } + return value as Record; +} + function handleMessagingAction( action: string, params: Record, @@ -489,13 +512,14 @@ describe("handleDiscordMessagingAction", () => { { mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], mediaReadFile }, ); expect(sendMessageDiscord).toHaveBeenCalledTimes(1); - const [, , sendOptions] = sendMessageDiscord.mock.calls[0] ?? []; - expect(sendMessageDiscord.mock.calls[0]?.[0]).toBe("channel:123"); - expect(sendMessageDiscord.mock.calls[0]?.[1]).toBe("hello"); - expect(sendOptions?.mediaAccess).toBe(mediaAccess); - expect(sendOptions?.mediaUrl).toBe("/tmp/image.png"); - expect(sendOptions?.mediaLocalRoots).toEqual(["/tmp/agent-root"]); - expect(sendOptions?.mediaReadFile).toBe(mediaReadFile); + const call = mockCall(sendMessageDiscord, "sendMessageDiscord"); + const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2); + expect(call[0]).toBe("channel:123"); + expect(call[1]).toBe("hello"); + expect(sendOptions.mediaAccess).toBe(mediaAccess); + expect(sendOptions.mediaUrl).toBe("/tmp/image.png"); + expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]); + expect(sendOptions.mediaReadFile).toBe(mediaReadFile); }); it("allows media-only message sends", async () => { @@ -511,11 +535,13 @@ describe("handleDiscordMessagingAction", () => { { mediaLocalRoots: ["/tmp/agent-root"] }, ); expect(sendMessageDiscord).toHaveBeenCalledTimes(1); - const [, content, sendOptions] = sendMessageDiscord.mock.calls[0] ?? []; - expect(sendMessageDiscord.mock.calls[0]?.[0]).toBe("channel:123"); + const call = mockCall(sendMessageDiscord, "sendMessageDiscord"); + const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2); + expect(call[0]).toBe("channel:123"); + const content = call[1]; expect(content).toBe(""); - expect(sendOptions?.mediaUrl).toBe("/tmp/image.png"); - expect(sendOptions?.mediaLocalRoots).toEqual(["/tmp/agent-root"]); + expect(sendOptions.mediaUrl).toBe("/tmp/image.png"); + expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]); }); it("ignores empty components objects for regular media sends", async () => { @@ -537,11 +563,13 @@ describe("handleDiscordMessagingAction", () => { expect(sendDiscordComponentMessage).not.toHaveBeenCalled(); expect(sendMessageDiscord).toHaveBeenCalledTimes(1); - const [, content, sendOptions] = sendMessageDiscord.mock.calls[0] ?? []; - expect(sendMessageDiscord.mock.calls[0]?.[0]).toBe("channel:123"); + const call = mockCall(sendMessageDiscord, "sendMessageDiscord"); + const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2); + expect(call[0]).toBe("channel:123"); + const content = call[1]; expect(content).toBe("hello"); - expect(sendOptions?.mediaUrl).toBe("/tmp/image.png"); - expect(sendOptions?.mediaLocalRoots).toEqual(["/tmp/agent-root"]); + expect(sendOptions.mediaUrl).toBe("/tmp/image.png"); + expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]); }); it("forwards the optional filename into sendMessageDiscord", async () => { @@ -557,11 +585,13 @@ describe("handleDiscordMessagingAction", () => { enableAllActions, ); expect(sendMessageDiscord).toHaveBeenCalledTimes(1); - const [, content, sendOptions] = sendMessageDiscord.mock.calls[0] ?? []; - expect(sendMessageDiscord.mock.calls[0]?.[0]).toBe("channel:123"); + const call = mockCall(sendMessageDiscord, "sendMessageDiscord"); + const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2); + expect(call[0]).toBe("channel:123"); + const content = call[1]; expect(content).toBe("hello"); - expect(sendOptions?.mediaUrl).toBe("/tmp/generated-image"); - expect(sendOptions?.filename).toBe("image.png"); + expect(sendOptions.mediaUrl).toBe("/tmp/generated-image"); + expect(sendOptions.filename).toBe("image.png"); }); it("rejects voice messages that include content", async () => { @@ -670,9 +700,10 @@ describe("handleDiscordGuildAction", () => { cfg, accountId: "work", }); - expect(result.details?.ok).toBe(true); - expect(result.details?.status).toBe("online"); - expect(result.details?.activities).toEqual([]); + const details = result.details as Record; + expect(details.ok).toBe(true); + expect(details.status).toBe("online"); + expect(details.activities).toEqual([]); }); }); @@ -963,10 +994,11 @@ describe("handleDiscordModerationAction", () => { moderationEnabled, ); expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1); - expect(timeoutMemberDiscord.mock.calls[0]?.[0].guildId).toBe("G1"); - expect(timeoutMemberDiscord.mock.calls[0]?.[0].userId).toBe("U1"); - expect(timeoutMemberDiscord.mock.calls[0]?.[0].durationMinutes).toBe(5); - expect(timeoutMemberDiscord.mock.calls[0]?.[1]).toEqual({ + const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0); + expect(params.guildId).toBe("G1"); + expect(params.userId).toBe("U1"); + expect(params.durationMinutes).toBe(5); + expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({ cfg: DISCORD_TEST_CFG, accountId: "ops", }); @@ -990,9 +1022,13 @@ describe("handleDiscordAction per-account gating", () => { cfg, ); expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1); - expect(timeoutMemberDiscord.mock.calls[0]?.[0].guildId).toBe("G1"); - expect(timeoutMemberDiscord.mock.calls[0]?.[0].userId).toBe("U1"); - expect(timeoutMemberDiscord.mock.calls[0]?.[1]).toEqual({ cfg, accountId: "ops" }); + const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0); + expect(params.guildId).toBe("G1"); + expect(params.userId).toBe("U1"); + expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({ + cfg, + accountId: "ops", + }); }); it("blocks moderation when account omits it", async () => { @@ -1075,8 +1111,12 @@ describe("handleDiscordAction per-account gating", () => { ); expect(createChannelDiscord).toHaveBeenCalledTimes(1); - expect(createChannelDiscord.mock.calls[0]?.[0].guildId).toBe("G1"); - expect(createChannelDiscord.mock.calls[0]?.[0].name).toBe("alerts"); - expect(createChannelDiscord.mock.calls[0]?.[1]).toEqual({ cfg, accountId: "ops" }); + const params = mockObjectArg(createChannelDiscord, "createChannelDiscord", 0, 0); + expect(params.guildId).toBe("G1"); + expect(params.name).toBe("alerts"); + expect(mockCall(createChannelDiscord, "createChannelDiscord")[1]).toEqual({ + cfg, + accountId: "ops", + }); }); }); diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index 6ae6eb693cd..684c6c73c6f 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -29,15 +29,25 @@ vi.mock("./typing.js", () => ({ })); type SetStatusFn = (patch: Record) => void; +type MockCallSource = { mock: { calls: Array> } }; -function statusPatches(setStatus: { mock: { calls: Array<[Record]> } }) { - return setStatus.mock.calls.map(([patch]) => patch); +function mockCall(source: MockCallSource, label: string, callIndex = 0): Array { + const call = source.mock.calls[callIndex]; + if (!call) { + throw new Error(`expected ${label} call ${callIndex}`); + } + return call; } -function expectStatusPatch( - setStatus: { mock: { calls: Array<[Record]> } }, - expected: Record, -) { +function mockCalls(source: MockCallSource): Array> { + return source.mock.calls; +} + +function statusPatches(setStatus: MockCallSource) { + return setStatus.mock.calls.map(([patch]) => patch as Record); +} + +function expectStatusPatch(setStatus: MockCallSource, expected: Record) { expect( statusPatches(setStatus).some((patch) => Object.entries(expected).every(([key, value]) => patch[key] === value), @@ -184,7 +194,10 @@ describe("createDiscordMessageHandler queue behavior", () => { await flushQueueWork(); expect(earlyTypingMocks.createDiscordRestClient).toHaveBeenCalledTimes(1); - const [restClientParams] = earlyTypingMocks.createDiscordRestClient.mock.calls[0] ?? []; + const [restClientParams] = mockCall( + earlyTypingMocks.createDiscordRestClient, + "createDiscordRestClient", + ); expect((restClientParams as { accountId?: unknown } | undefined)?.accountId).toBe("default"); expect((restClientParams as { token?: unknown } | undefined)?.token).toBe("test-token"); expect(earlyTypingMocks.sendTyping).toHaveBeenCalledWith({ @@ -364,8 +377,9 @@ describe("createDiscordMessageHandler queue behavior", () => { await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined(); await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const runtimeError = params.runtime.error as unknown as MockCallSource; expect(params.runtime.error).toHaveBeenCalledTimes(1); - expect(String(params.runtime.error.mock.calls[0]?.[0])).toContain( + expect(String(mockCall(runtimeError, "runtime.error")[0])).toContain( "discord message run failed: DiscordRetryableInboundError: retry me", ); @@ -392,8 +406,9 @@ describe("createDiscordMessageHandler queue behavior", () => { await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined(); await flushQueueWork(); expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const runtimeError = params.runtime.error as unknown as MockCallSource; expect(params.runtime.error).toHaveBeenCalledTimes(1); - expect(String(params.runtime.error.mock.calls[0]?.[0])).toContain( + expect(String(mockCall(runtimeError, "runtime.error")[0])).toContain( "discord message run failed: Error: post-send failure", ); @@ -444,8 +459,9 @@ describe("createDiscordMessageHandler queue behavior", () => { expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); expect(capturedAbortSignals).toEqual([undefined]); + const runtimeError = params.runtime.error as unknown as MockCallSource; expect( - params.runtime.error.mock.calls.some(([message]) => String(message).includes("timed out")), + mockCalls(runtimeError).some(([message]) => String(message).includes("timed out")), ).toBe(false); firstRun.resolve(); diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 06a53cfc7ae..ef89ea50803 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -320,7 +320,7 @@ describe("discord component interactions", () => { expect(dispatchReplyMock).toHaveBeenCalledTimes(1); const dispatchParams = dispatchReplyMock.mock.calls[0]?.[0] as DispatchParams | undefined; expect(typeof dispatchParams?.dispatcherOptions.responsePrefixContextProvider).toBe("function"); - expect(typeof dispatchParams?.replyOptions.onModelSelected).toBe("function"); + expect(typeof dispatchParams?.replyOptions?.onModelSelected).toBe("function"); expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); diff --git a/extensions/matrix/src/matrix/monitor/startup.test.ts b/extensions/matrix/src/matrix/monitor/startup.test.ts index a393f405bf9..f92ec60873a 100644 --- a/extensions/matrix/src/matrix/monitor/startup.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup.test.ts @@ -159,15 +159,19 @@ describe("runMatrixStartupMaintenance", () => { if (!profileSyncParams) { throw new Error("profile sync params missing"); } + const loadAvatarFromUrl = profileSyncParams.loadAvatarFromUrl; + if (!loadAvatarFromUrl) { + throw new Error("profile sync params missing loadAvatarFromUrl"); + } expect(profileSyncParams).toStrictEqual({ client: params.client, userId: "@bot:example.org", displayName: "Ops Bot", avatarUrl: "https://example.org/avatar.png", - loadAvatarFromUrl: profileSyncParams.loadAvatarFromUrl, + loadAvatarFromUrl, }); await expect( - profileSyncParams.loadAvatarFromUrl("https://example.org/new-avatar.png", 123), + loadAvatarFromUrl("https://example.org/new-avatar.png", 123), ).resolves.toStrictEqual({ buffer: Buffer.from("avatar"), contentType: "image/png", @@ -180,7 +184,11 @@ describe("runMatrixStartupMaintenance", () => { { avatarUrl: "mxc://avatar" }, ); expect(params.replaceConfigFile).toHaveBeenCalledWith(updatedCfg as never); - expect(params.logVerboseMessage).toHaveBeenCalledWith( + const logVerboseMessage = params.logVerboseMessage; + if (!logVerboseMessage) { + throw new Error("expected logVerboseMessage"); + } + expect(logVerboseMessage).toHaveBeenCalledWith( "matrix: persisted converted avatar URL for account ops (mxc://avatar)", ); }); diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index b6bf0d1f36a..5555c0e4832 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -76,6 +76,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "cwd": "/tmp/openclaw-happy-path/workspace", @@ -112,6 +114,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "developerInstructions": "", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 90ae78f8849..121efcb1d4a 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -76,6 +76,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "cwd": "/tmp/openclaw-happy-path/workspace", @@ -112,6 +114,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "developerInstructions": "", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 9dc4c3f2dc1..56824b85fd0 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -76,6 +76,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "cwd": "/tmp/openclaw-happy-path/workspace", @@ -113,6 +115,8 @@ "approvalPolicy": "never", "approvalsReviewer": "user", "config": { + "features.code_mode": true, + "features.code_mode_only": true, "instructions": "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.\n\n# Project Context\n\nThe following project context files have been loaded:\nSOUL.md: persona/tone. Follow it unless higher-priority instructions override.\n\n## /tmp/openclaw-happy-path/workspace/SOUL.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n" }, "developerInstructions": "",