fix: restore verbose command and ACP cleanup controls

This commit is contained in:
Peter Steinberger
2026-04-29 11:20:03 +01:00
parent ae57eb635c
commit 20ed597495
8 changed files with 117 additions and 9 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc.
- Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg.
- Channels/Discord: split long CJK replies at punctuation and code-point-safe fallback boundaries so Discord chunking stays readable without corrupting astral characters. Fixes #38597; repairs #71384. Thanks @p3nchan.
- Browser/gateway: ignore Playwright dialog-close races from `Page.handleJavaScriptDialog` so browser automation no longer crashes the Gateway when a dialog disappears before Playwright accepts it. (#40067) Thanks @randyjtw.

View File

@@ -1297,6 +1297,8 @@ export class AcpSessionManager {
(acpError.code === "ACP_BACKEND_MISSING" ||
acpError.code === "ACP_BACKEND_UNAVAILABLE" ||
(input.discardPersistentState && acpError.code === "ACP_SESSION_INIT_FAILED") ||
(input.discardPersistentState &&
acpError.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") ||
this.isRecoverableAcpxExitError(acpError.message))
) {
if (input.discardPersistentState) {

View File

@@ -1555,6 +1555,44 @@ describe("AcpSessionManager", () => {
});
});
it("treats unsupported close controls as recoverable during discard cleanup", async () => {
const runtimeState = createRuntime();
runtimeState.close.mockRejectedValueOnce(
new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
'ACP backend "acpx" does not support session/close.',
),
);
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:openclaw:acp:session-1",
storeSessionKey: "agent:openclaw:acp:session-1",
acp: readySessionMeta({
agent: "openclaw",
}),
});
const manager = new AcpSessionManager();
const closeResult = await manager.closeSession({
cfg: baseCfg,
sessionKey: "agent:openclaw:acp:session-1",
reason: "terminal-task-cleanup",
allowBackendUnavailable: true,
discardPersistentState: true,
clearMeta: true,
});
expect(closeResult.runtimeClosed).toBe(false);
expect(closeResult.runtimeNotice).toContain("does not support session/close");
expect(closeResult.metaCleared).toBe(true);
expect(runtimeState.prepareFreshSession).toHaveBeenCalledWith({
sessionKey: "agent:openclaw:acp:session-1",
});
});
it("clears persisted resume identity when close discards persistent state", async () => {
const runtimeState = createRuntime();
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";

View File

@@ -742,6 +742,56 @@ describe("acp setSessionConfigOption bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
it("accepts forwarded timeout config options without failing OpenClaw ACP bridge turns", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: null,
model: null,
contextTokens: null,
},
sessions: [
{
key: "timeout-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
expect(method).not.toBe("sessions.patch");
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("timeout-session"));
await expect(
agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("timeout-session", "timeout", "180"),
),
).resolves.toEqual(
expect.objectContaining({
configOptions: expect.any(Array),
}),
);
expect(request).not.toHaveBeenCalledWith("sessions.patch", expect.anything());
sessionStore.clearAllSessionsForTest();
});
it("rejects non-string ACP config option values", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();

View File

@@ -58,6 +58,8 @@ const ACP_TRACE_LEVEL_CONFIG_ID = "trace_level";
const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level";
const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage";
const ACP_ELEVATED_LEVEL_CONFIG_ID = "elevated_level";
const ACP_TIMEOUT_CONFIG_ID = "timeout";
const ACP_TIMEOUT_SECONDS_CONFIG_ID = "timeout_seconds";
const ACP_LOAD_SESSION_REPLAY_LIMIT = 1_000_000;
const ACP_GATEWAY_DISCONNECT_GRACE_MS = 5_000;
@@ -664,10 +666,12 @@ export class AcpGatewayAgent implements Agent {
const sessionPatch = this.resolveSessionConfigPatch(params.configId, params.value);
try {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
...sessionPatch.patch,
});
if (sessionPatch.patch) {
await this.gateway.request("sessions.patch", {
key: session.sessionKey,
...sessionPatch.patch,
});
}
this.log(
`setSessionConfigOption: ${session.sessionId} -> ${params.configId}=${params.value}`,
);
@@ -1291,7 +1295,7 @@ export class AcpGatewayAgent implements Agent {
value: string | boolean,
): {
overrides: Partial<GatewaySessionPresentationRow>;
patch: Record<string, string | boolean>;
patch?: Record<string, string | boolean>;
} {
if (typeof value !== "string") {
throw new Error(
@@ -1334,6 +1338,11 @@ export class AcpGatewayAgent implements Agent {
patch: { elevatedLevel: value },
overrides: { elevatedLevel: value },
};
case ACP_TIMEOUT_CONFIG_ID:
case ACP_TIMEOUT_SECONDS_CONFIG_ID:
return {
overrides: {},
};
default:
throw new Error(`ACP bridge mode does not support session config option "${configId}".`);
}

View File

@@ -62,7 +62,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string {
"/think <level>",
"/model <id>",
"/fast status|on|off",
"/verbose on|off",
"/verbose on|off|full",
"/trace on|off|raw",
];
if (isCommandFlagEnabled(cfg, "config")) {

View File

@@ -746,12 +746,11 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
args: [
{
name: "mode",
description: "on or off",
description: "on, off, or full",
type: "string",
choices: ["on", "off"],
choices: ["on", "off", "full"],
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "trace",

View File

@@ -461,6 +461,15 @@ describe("commands registry args", () => {
]);
});
it("keeps verbose full available while preserving no-arg status dispatch", () => {
const verbose = listChatCommands().find((command) => command.key === "verbose");
expect(verbose?.args?.[0]?.choices).toEqual(["on", "off", "full"]);
expect(
resolveCommandArgMenu({ command: verbose!, args: undefined, cfg: {} as never }),
).toBeNull();
});
it("does not show menus when arg already provided", () => {
const command = createUsageModeCommand();