diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3697a510a..0c388fd289e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 54caa751a5c..993f5a4d9b8 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -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) { diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index fd845080ab7..05491d6a27e 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -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"; diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index ca5ae0a17d9..bf305a1d20b 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -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(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 763b5bd8236..a0933e8ab1d 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -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; - patch: Record; + patch?: Record; } { 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}".`); } diff --git a/src/auto-reply/command-status-builders.ts b/src/auto-reply/command-status-builders.ts index a18a36c0197..30310f7551d 100644 --- a/src/auto-reply/command-status-builders.ts +++ b/src/auto-reply/command-status-builders.ts @@ -62,7 +62,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { "/think ", "/model ", "/fast status|on|off", - "/verbose on|off", + "/verbose on|off|full", "/trace on|off|raw", ]; if (isCommandFlagEnabled(cfg, "config")) { diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 363eb9c714f..4b0254e58a4 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -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", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index dac44e8785d..ab29d16342d 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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();