Compare commits

...

2 Commits

Author SHA1 Message Date
pashpashpash
8364730c41 test: support child logger in restart sentinel test 2026-05-01 12:40:24 -04:00
pashpashpash
60c6915b6c agents: default codex replies to message tool 2026-05-01 12:34:41 -04:00
11 changed files with 142 additions and 9 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- Plugins/CLI: add first-class `git:` plugin installs with ref checkout, commit metadata, normal scanner/staging, and `plugins update` support for recorded git sources. Thanks @badlogic.
- Google Meet: add live caption health for Chrome transcribe mode, including caption observer state, transcript counters, last caption text, and recent transcript lines in status and doctor output. Refs #72478. Thanks @DougButdorf.
- Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.
- Agents/Codex: default Codex-harness direct source replies to the OpenClaw `message` tool when visible reply delivery is not explicitly configured, keeping channel-visible output as a deliberate tool call. Thanks @pashpashpash.
- macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.
- Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.
- Discord: keep active buttons, selects, and forms working across Gateway restarts until they expire, so multi-step Discord interactions are less likely to break during upgrades or restarts. Thanks @amknight.

View File

@@ -47,7 +47,7 @@ If the message tool is unavailable under the active tool policy, OpenClaw falls
back to automatic visible replies instead of silently suppressing the response.
`openclaw doctor` warns about this mismatch.
For direct chats and any other source turn, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
For direct chats and any other source turn, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. Harnesses can also choose this as their unset default; the Codex harness does this for Codex-mode direct chats. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms.
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.

View File

@@ -15,6 +15,13 @@ discovery, native thread resume, native compaction, and app-server execution.
OpenClaw still owns chat channels, session files, model selection, tools,
approvals, media delivery, and the visible transcript mirror.
When a source chat turn runs through the Codex harness, visible replies default
to the OpenClaw `message` tool if the deployment has not explicitly configured
`messages.visibleReplies`. The agent can still finish its Codex turn privately;
it only posts to the channel when it calls `message(action="send")`. Set
`messages.visibleReplies: "automatic"` to keep direct-chat final replies on the
legacy automatic delivery path.
If you are trying to orient yourself, 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,

View File

@@ -23,6 +23,9 @@ export function createCodexAppServerAgentHarness(options?: {
return {
id: options?.id ?? "codex",
label: options?.label ?? "Codex agent harness",
deliveryDefaults: {
sourceVisibleReplies: "message_tool",
},
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (providerIds.has(provider)) {

View File

@@ -44,6 +44,7 @@ describe("codex plugin", () => {
expect(registerAgentHarness.mock.calls[0]?.[0]).toMatchObject({
id: "codex",
label: "Codex agent harness",
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
dispose: expect.any(Function),
});
expect(registerMediaUnderstandingProvider.mock.calls[0]?.[0]).toMatchObject({
@@ -89,6 +90,7 @@ describe("codex plugin", () => {
it("only claims the codex provider by default", () => {
const harness = createCodexAppServerAgentHarness();
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
expect(
harness.supports({ provider: "codex", modelId: "gpt-5.4", requestedRuntime: "auto" })
.supported,

View File

@@ -27,10 +27,19 @@ export type AgentHarnessResultClassification =
| "ok"
| NonNullable<AgentHarnessAttemptResult["agentHarnessResultClassification"]>;
export type AgentHarnessDeliveryDefaults = {
/**
* Preferred default for visible source replies when user config has not
* explicitly selected automatic or message-tool delivery.
*/
sourceVisibleReplies?: "automatic" | "message_tool";
};
export type AgentHarness = {
id: string;
label: string;
pluginId?: string;
deliveryDefaults?: AgentHarnessDeliveryDefaults;
supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport;
runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>;
classify?(

View File

@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
clearApprovalNativeRouteStateForTest,
@@ -695,6 +696,7 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
clearAgentHarnesses();
const discordTestPlugin = {
...createChannelTestPluginBase({
id: "discord",
@@ -4402,6 +4404,42 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("uses harness defaults for direct source delivery when config is unset", async () => {
setNoAbort();
registerAgentHarness({
id: "codex",
label: "Codex",
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
supports: () => ({ supported: true, priority: 100 }),
runAttempt: vi.fn(async () => ({}) as never),
});
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
agentHarnessId: "codex",
sendPolicy: "allow",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
return { text: "final reply" } satisfies ReplyPayload;
});
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
ChatType: "direct",
SessionKey: "agent:main:main",
}),
cfg: emptyConfig,
dispatcher,
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
setNoAbort();
const dispatcher = createDispatcher();

View File

@@ -5,6 +5,7 @@ import {
resolveAgentWorkspaceDir,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
import { selectAgentHarness } from "../../agents/harness/selection.js";
import {
isToolAllowedByPolicies,
resolveEffectiveToolPolicy,
@@ -292,6 +293,41 @@ const createShouldEmitVerboseProgress = (params: {
return params.fallbackLevel !== "off";
};
};
const resolveHarnessSourceVisibleRepliesDefault = (params: {
cfg: OpenClawConfig;
ctx: FinalizedMsgContext;
entry?: SessionEntry;
sessionAgentId: string;
sessionKey?: string;
}): "automatic" | "message_tool" | undefined => {
if (params.ctx.CommandSource === "native") {
return undefined;
}
try {
const provider =
normalizeOptionalString(params.entry?.modelProvider) ??
normalizeOptionalString(params.ctx.Provider) ??
normalizeOptionalString(params.ctx.Surface) ??
"";
const harness = selectAgentHarness({
provider,
modelId: normalizeOptionalString(params.entry?.model),
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey,
agentHarnessId:
normalizeOptionalString(params.entry?.agentHarnessId) ??
normalizeOptionalString(params.entry?.agentRuntimeOverride),
});
return harness.deliveryDefaults?.sourceVisibleReplies;
} catch (error) {
logVerbose(
`dispatch-from-config: could not resolve harness visible-reply defaults: ${formatErrorMessage(error)}`,
);
return undefined;
}
};
export type {
DispatchFromConfigParams,
DispatchFromConfigResult,
@@ -625,13 +661,24 @@ export async function dispatchReplyFromConfig(
chatType === "group" || chatType === "channel"
? (cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies)
: cfg.messages?.visibleReplies;
const harnessDefaultVisibleReplies =
configuredVisibleReplies === undefined && chatType !== "group" && chatType !== "channel"
? resolveHarnessSourceVisibleRepliesDefault({
cfg,
ctx,
entry: sessionStoreEntry.entry,
sessionAgentId,
sessionKey: acpDispatchSessionKey,
})
: undefined;
const effectiveVisibleReplies = configuredVisibleReplies ?? harnessDefaultVisibleReplies;
const prefersMessageToolDelivery =
params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" ||
(params.replyOptions?.sourceReplyDeliveryMode === undefined &&
ctx.CommandSource !== "native" &&
(chatType === "group" || chatType === "channel"
? configuredVisibleReplies !== "automatic"
: configuredVisibleReplies === "message_tool"));
? effectiveVisibleReplies !== "automatic"
: effectiveVisibleReplies === "message_tool"));
const runtimeProfileAlsoAllow = prefersMessageToolDelivery ? ["message"] : [];
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), [
...(profileAlsoAllow ?? []),
@@ -690,6 +737,7 @@ export async function dispatchReplyFromConfig(
explicitSuppressTyping: params.replyOptions?.suppressTyping === true,
shouldSuppressTyping,
messageToolAvailable,
defaultVisibleReplies: harnessDefaultVisibleReplies,
});
const {
sourceReplyDeliveryMode,

View File

@@ -56,6 +56,23 @@ describe("resolveSourceReplyDeliveryMode", () => {
}
});
it("allows harnesses to default direct chats to message-tool-only delivery", () => {
expect(
resolveSourceReplyDeliveryMode({
cfg: emptyConfig,
ctx: { ChatType: "direct" },
defaultVisibleReplies: "message_tool",
}),
).toBe("message_tool_only");
expect(
resolveSourceReplyDeliveryMode({
cfg: { messages: { visibleReplies: "automatic" } },
ctx: { ChatType: "direct" },
defaultVisibleReplies: "message_tool",
}),
).toBe("automatic");
});
it("lets group/channel config override the global visible reply mode", () => {
expect(
resolveSourceReplyDeliveryMode({

View File

@@ -13,6 +13,7 @@ export function resolveSourceReplyDeliveryMode(params: {
ctx: SourceReplyDeliveryModeContext;
requested?: SourceReplyDeliveryMode;
messageToolAvailable?: boolean;
defaultVisibleReplies?: "automatic" | "message_tool";
}): SourceReplyDeliveryMode {
if (params.requested) {
return params.messageToolAvailable === false && params.requested === "message_tool_only"
@@ -29,8 +30,8 @@ export function resolveSourceReplyDeliveryMode(params: {
params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
} else {
mode =
params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
const configuredMode = params.cfg.messages?.visibleReplies ?? params.defaultVisibleReplies;
mode = configuredMode === "message_tool" ? "message_tool_only" : "automatic";
}
if (mode === "message_tool_only" && params.messageToolAvailable === false) {
return "automatic";
@@ -58,12 +59,14 @@ export function resolveSourceReplyVisibilityPolicy(params: {
explicitSuppressTyping?: boolean;
shouldSuppressTyping?: boolean;
messageToolAvailable?: boolean;
defaultVisibleReplies?: "automatic" | "message_tool";
}): SourceReplyVisibilityPolicy {
const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
requested: params.requested,
messageToolAvailable: params.messageToolAvailable,
defaultVisibleReplies: params.defaultVisibleReplies,
});
const sendPolicyDenied = params.sendPolicy === "deny";
const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only";

View File

@@ -208,13 +208,18 @@ vi.mock("../infra/heartbeat-wake.js", async () => {
};
});
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
vi.mock("../logging/subsystem.js", () => {
const logger = {
info: mocks.logInfo,
warn: mocks.logWarn,
error: mocks.logError,
})),
}));
child: vi.fn(),
};
logger.child.mockReturnValue(logger);
return {
createSubsystemLogger: vi.fn(() => logger),
};
});
vi.mock("./server-methods/agent-timestamp.js", () => ({
injectTimestamp: mocks.injectTimestamp,