mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 11:24:47 +00:00
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
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof createTestPluginApi> & {
|
||||
onConversationBindingResolved?: ReturnType<typeof vi.fn>;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -44,6 +44,11 @@ export type CodexPluginThreadConfigProvider = {
|
||||
build: () => Promise<CodexPluginThreadConfig>;
|
||||
};
|
||||
|
||||
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: {
|
||||
|
||||
@@ -87,6 +87,29 @@ const {
|
||||
const enableAllActions = () => true;
|
||||
const DISCORD_TEST_CFG = EMPTY_DISCORD_TEST_CONFIG;
|
||||
|
||||
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
||||
|
||||
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function handleMessagingAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,15 +29,25 @@ vi.mock("./typing.js", () => ({
|
||||
}));
|
||||
|
||||
type SetStatusFn = (patch: Record<string, unknown>) => void;
|
||||
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
||||
|
||||
function statusPatches(setStatus: { mock: { calls: Array<[Record<string, unknown>]> } }) {
|
||||
return setStatus.mock.calls.map(([patch]) => patch);
|
||||
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
|
||||
const call = source.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call ${callIndex}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function expectStatusPatch(
|
||||
setStatus: { mock: { calls: Array<[Record<string, unknown>]> } },
|
||||
expected: Record<string, unknown>,
|
||||
) {
|
||||
function mockCalls(source: MockCallSource): Array<Array<unknown>> {
|
||||
return source.mock.calls;
|
||||
}
|
||||
|
||||
function statusPatches(setStatus: MockCallSource) {
|
||||
return setStatus.mock.calls.map(([patch]) => patch as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function expectStatusPatch(setStatus: MockCallSource, expected: Record<string, unknown>) {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"developerInstructions": "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
|
||||
@@ -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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"developerInstructions": "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
|
||||
@@ -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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"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<SOUL.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/TOOLS.md\n\n<TOOLS.md contents will be here>\n\n## /tmp/openclaw-happy-path/workspace/HEARTBEAT.md\n\n<HEARTBEAT.md contents will be here>"
|
||||
},
|
||||
"developerInstructions": "<see Reconstructed Model-Bound Prompt Layers>",
|
||||
|
||||
Reference in New Issue
Block a user