Skip to content

Commit a6d9926

Browse files
committed
fix: keep acp management commands local
1 parent 9123c81 commit a6d9926

9 files changed

Lines changed: 199 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ Docs: https://docs.openclaw.ai
8181
- Gateway/pairing: stop corrupt or unreadable device/node pairing stores from
8282
being treated as empty state, preserving `paired.json` for repair instead of
8383
overwriting approved pairings. Fixes #71873. Thanks @iret77.
84+
- ACP: keep `/acp` management commands, plus local `/status` and `/unfocus`,
85+
on the Gateway path inside ACP-bound threads so they are not consumed as ACP
86+
prompt text. Fixes #66298. Thanks @kindomLee.
8487
- ACP: wait for the configured runtime backend to become healthy before startup
8588
identity reconciliation, avoiding transient acpx warnings during Gateway boot.
8689
Fixes #40566.

docs/tools/acp-agents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ Notes:
150150
- `--bind here` only works on channels that advertise current-conversation binding; OpenClaw returns a clear unsupported message otherwise. Bindings persist across gateway restarts.
151151
- On Discord, `spawnAcpSessions` is only required when OpenClaw needs to create a child thread for `--thread auto|here` — not for `--bind here`.
152152
- If you spawn to a different ACP agent without `--cwd`, OpenClaw inherits the **target agent's** workspace by default. Missing inherited paths (`ENOENT`/`ENOTDIR`) fall back to the backend default; other access errors (e.g. `EACCES`) surface as spawn errors.
153+
- Gateway management commands stay local in bound conversations. In
154+
particular, `/acp ...` commands are handled by OpenClaw even when normal
155+
follow-up text routes to the bound ACP session; `/status` and `/unfocus` also
156+
stay local whenever command handling is enabled for that surface.
153157

154158
### Thread-bound sessions
155159

@@ -159,6 +163,8 @@ When thread bindings are enabled for a channel adapter, ACP sessions can be boun
159163
- Follow-up messages in that thread route to the bound ACP session.
160164
- ACP output is delivered back to the same thread.
161165
- Unfocus/close/archive/idle-timeout or max-age expiry removes the binding.
166+
- `/acp close`, `/acp cancel`, `/acp status`, `/status`, and `/unfocus` are
167+
Gateway commands, not prompts to the ACP harness.
162168

163169
Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
164170

docs/tools/slash-commands.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ title: "Slash commands"
99
Commands are handled by the Gateway. Most commands must be sent as a **standalone** message that starts with `/`.
1010
The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
1111

12+
When a conversation or thread is bound to an ACP session, normal follow-up text
13+
routes to that ACP harness. Gateway management commands still stay local:
14+
`/acp ...` always reaches the OpenClaw ACP command handler, and `/status` plus
15+
`/unfocus` stay local whenever command handling is enabled for the surface.
16+
1217
There are two related systems:
1318

1419
- **Commands**: standalone `/...` messages.

scripts/test-projects.test-support.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ const SOURCE_TEST_TARGETS = new Map([
290290
"src/auto-reply/reply/dispatch-from-config.test.ts",
291291
],
292292
],
293+
["src/auto-reply/reply/commands-acp.ts", ["src/auto-reply/reply/commands-acp.test.ts"]],
294+
[
295+
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",
296+
["src/auto-reply/reply/dispatch-acp-command-bypass.test.ts"],
297+
],
293298
]);
294299
const GENERATED_CHANGED_TEST_TARGETS = new Set([
295300
"src/canvas-host/a2ui/.bundle.hash",

src/auto-reply/reply/commands-acp.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,24 @@ describe("/acp command", () => {
16371637
expect(result?.reply?.text).toContain("Removed 1 binding");
16381638
});
16391639

1640+
it("handles /acp close in a bound thread when text commands are disabled", async () => {
1641+
mockBoundThreadSession();
1642+
hoisted.sessionBindingUnbindMock.mockResolvedValue([
1643+
createBoundThreadSession() as SessionBindingRecord,
1644+
]);
1645+
1646+
const result = await handleAcpCommand(createThreadParams("/acp close", baseCfg), false);
1647+
1648+
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
1649+
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith(
1650+
expect.objectContaining({
1651+
targetSessionKey: defaultAcpSessionKey,
1652+
reason: "manual",
1653+
}),
1654+
);
1655+
expect(result?.reply?.text).toContain("Removed 1 binding");
1656+
});
1657+
16401658
it("lists ACP sessions from the session store", async () => {
16411659
hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) =>
16421660
key === defaultAcpSessionKey ? [createBoundThreadSession(key) as SessionBindingRecord] : [],

src/auto-reply/reply/commands-acp.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,7 @@ const ACP_MUTATING_ACTIONS = new Set<AcpAction>([
8585
"reset-options",
8686
]);
8787

88-
export const handleAcpCommand: CommandHandler = async (params, allowTextCommands) => {
89-
if (!allowTextCommands) {
90-
return null;
91-
}
92-
88+
export const handleAcpCommand: CommandHandler = async (params, _allowTextCommands) => {
9389
const normalized = params.command.commandBodyNormalized;
9490
if (!normalized.startsWith(COMMAND)) {
9591
return null;

src/auto-reply/reply/dispatch-acp-command-bypass.test.ts

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
import { describe, expect, it } from "vitest";
1+
import { beforeEach, describe, expect, it } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
3+
import { setActivePluginRegistry } from "../../plugins/runtime.js";
4+
import {
5+
createChannelTestPluginBase,
6+
createTestRegistry,
7+
} from "../../test-utils/channel-plugins.js";
38
import { shouldBypassAcpDispatchForCommand } from "./dispatch-acp-command-bypass.js";
49
import { buildTestCtx } from "./test-ctx.js";
510

611
describe("shouldBypassAcpDispatchForCommand", () => {
12+
beforeEach(() => {
13+
setActivePluginRegistry(createTestRegistry([]));
14+
});
15+
716
it("returns false for plain-text ACP turns", () => {
817
const ctx = buildTestCtx({
918
Provider: "discord",
@@ -15,7 +24,7 @@ describe("shouldBypassAcpDispatchForCommand", () => {
1524
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(false);
1625
});
1726

18-
it("returns false for ACP slash commands", () => {
27+
it("returns true for ACP slash commands", () => {
1928
const ctx = buildTestCtx({
2029
Provider: "discord",
2130
Surface: "discord",
@@ -24,9 +33,58 @@ describe("shouldBypassAcpDispatchForCommand", () => {
2433
BodyForAgent: "/acp cancel",
2534
});
2635

36+
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
37+
});
38+
39+
it("returns true for native ACP slash commands", () => {
40+
const ctx = buildTestCtx({
41+
Provider: "discord",
42+
Surface: "discord",
43+
CommandSource: "native",
44+
CommandBody: "/acp close",
45+
BodyForCommands: "/acp close",
46+
BodyForAgent: "/acp close",
47+
});
48+
49+
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
50+
});
51+
52+
it("returns false for ACP slash commands addressed to another bot", () => {
53+
const ctx = buildTestCtx({
54+
Provider: "discord",
55+
Surface: "discord",
56+
CommandBody: "/acp@otherbot cancel",
57+
BodyForCommands: "/acp@otherbot cancel",
58+
BodyForAgent: "/acp@otherbot cancel",
59+
});
60+
2761
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(false);
2862
});
2963

64+
it("returns true for local status commands", () => {
65+
const ctx = buildTestCtx({
66+
Provider: "discord",
67+
Surface: "discord",
68+
CommandBody: "/status",
69+
BodyForCommands: "/status",
70+
BodyForAgent: "/status",
71+
});
72+
73+
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
74+
});
75+
76+
it("returns true for local unfocus commands", () => {
77+
const ctx = buildTestCtx({
78+
Provider: "discord",
79+
Surface: "discord",
80+
CommandBody: "/unfocus",
81+
BodyForCommands: "/unfocus",
82+
BodyForAgent: "/unfocus",
83+
});
84+
85+
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
86+
});
87+
3088
it("returns true for ACP reset-tail slash commands", () => {
3189
const ctx = buildTestCtx({
3290
Provider: "discord",
@@ -52,7 +110,25 @@ describe("shouldBypassAcpDispatchForCommand", () => {
52110
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
53111
});
54112

55-
it("returns false for slash commands when text commands are disabled", () => {
113+
it("returns false for unrelated slash commands when text commands are disabled", () => {
114+
const ctx = buildTestCtx({
115+
Provider: "discord",
116+
Surface: "discord",
117+
CommandBody: "/foo cancel",
118+
BodyForCommands: "/foo cancel",
119+
BodyForAgent: "/foo cancel",
120+
CommandSource: "text",
121+
});
122+
const cfg = {
123+
commands: {
124+
text: false,
125+
},
126+
} as OpenClawConfig;
127+
128+
expect(shouldBypassAcpDispatchForCommand(ctx, cfg)).toBe(false);
129+
});
130+
131+
it("returns true for ACP slash commands when text commands are disabled", () => {
56132
const ctx = buildTestCtx({
57133
Provider: "discord",
58134
Surface: "discord",
@@ -67,9 +143,58 @@ describe("shouldBypassAcpDispatchForCommand", () => {
67143
},
68144
} as OpenClawConfig;
69145

146+
expect(shouldBypassAcpDispatchForCommand(ctx, cfg)).toBe(true);
147+
});
148+
149+
it("returns false for local status commands when text commands are disabled on text-native surfaces", () => {
150+
setActivePluginRegistry(
151+
createTestRegistry([
152+
{
153+
pluginId: "discord",
154+
plugin: createChannelTestPluginBase({
155+
id: "discord",
156+
capabilities: { nativeCommands: true, chatTypes: ["direct"] },
157+
}),
158+
source: "test",
159+
},
160+
]),
161+
);
162+
163+
const ctx = buildTestCtx({
164+
Provider: "discord",
165+
Surface: "discord",
166+
CommandBody: "/status",
167+
BodyForCommands: "/status",
168+
BodyForAgent: "/status",
169+
CommandSource: "text",
170+
});
171+
const cfg = {
172+
commands: {
173+
text: false,
174+
},
175+
} as OpenClawConfig;
176+
70177
expect(shouldBypassAcpDispatchForCommand(ctx, cfg)).toBe(false);
71178
});
72179

180+
it("returns true for native local status commands when text commands are disabled", () => {
181+
const ctx = buildTestCtx({
182+
Provider: "discord",
183+
Surface: "discord",
184+
CommandBody: "/status",
185+
BodyForCommands: "/status",
186+
BodyForAgent: "/status",
187+
CommandSource: "native",
188+
});
189+
const cfg = {
190+
commands: {
191+
text: false,
192+
},
193+
} as OpenClawConfig;
194+
195+
expect(shouldBypassAcpDispatchForCommand(ctx, cfg)).toBe(true);
196+
});
197+
73198
it("returns false for unauthorized bang-prefixed commands", () => {
74199
const ctx = buildTestCtx({
75200
Provider: "discord",

src/auto-reply/reply/dispatch-acp-command-bypass.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ function isResetCommandCandidate(text: string): boolean {
2727
return /^\/(?:new|reset)(?:\s|$)/i.test(text);
2828
}
2929

30+
function isAcpCommandCandidate(text: string): boolean {
31+
return /^\/acp(?:\s|$)/i.test(text);
32+
}
33+
34+
function isLocalCommandCandidate(text: string): boolean {
35+
return /^\/(?:status|unfocus)(?:\s|$)/i.test(text);
36+
}
37+
3038
export function shouldBypassAcpDispatchForCommand(
3139
ctx: FinalizedMsgContext,
3240
cfg: OpenClawConfig,
@@ -49,6 +57,14 @@ export function shouldBypassAcpDispatchForCommand(
4957
return true;
5058
}
5159

60+
if (isAcpCommandCandidate(normalized)) {
61+
return true;
62+
}
63+
64+
if (isLocalCommandCandidate(normalized)) {
65+
return allowTextCommands;
66+
}
67+
5268
if (!normalized.startsWith("!")) {
5369
return false;
5470
}

test/scripts/test-projects.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,23 @@ describe("scripts/test-projects changed-target routing", () => {
302302
});
303303
});
304304

305+
it("routes ACP command source files to ACP command regression tests", () => {
306+
expect(
307+
resolveChangedTestTargetPlan([
308+
"src/auto-reply/reply/commands-acp.ts",
309+
"src/auto-reply/reply/commands-acp.test.ts",
310+
"src/auto-reply/reply/dispatch-acp-command-bypass.ts",
311+
"src/auto-reply/reply/dispatch-acp-command-bypass.test.ts",
312+
]),
313+
).toEqual({
314+
mode: "targets",
315+
targets: [
316+
"src/auto-reply/reply/commands-acp.test.ts",
317+
"src/auto-reply/reply/dispatch-acp-command-bypass.test.ts",
318+
],
319+
});
320+
});
321+
305322
it("routes Google Meet CLI edits to the lightweight CLI tests", () => {
306323
expect(resolveChangedTestTargetPlan(["extensions/google-meet/src/cli.ts"])).toEqual({
307324
mode: "targets",

0 commit comments

Comments
 (0)