fix: restore ACP slash command replies such as /status (#1200)

This commit is contained in:
paisley
2026-07-28 18:36:14 +08:00
committed by GitHub
parent 3a241cf09f
commit 230ac15cbf
4 changed files with 209 additions and 1 deletions
+4 -1
View File
@@ -394,12 +394,15 @@ export class AcpChatService {
}
this.permissionsEnabled = true;
const messageId = payload.messageId ?? randomUUID();
const isSlashCommand = payload.message?.trimStart().startsWith('/') === true;
await connection.prompt({
sessionId: acpSessionId,
prompt,
// ACP 1.1 removed messageId from the PromptRequest wire shape. Keep
// ClawX correlation metadata in the protocol extension envelope.
_meta: { sessionKey: payload.sessionKey, prefixCwd: true, messageId },
// OpenClaw must receive slash commands without its textual cwd prefix
// so the Gateway can classify and fold command replies into chat final.
_meta: { sessionKey: payload.sessionKey, prefixCwd: !isSlashCommand, messageId },
});
this.trace('session/prompt:success', {
sessionKey: payload.sessionKey,
@@ -0,0 +1,43 @@
---
id: acp-slash-command-replies
title: Preserve ACP slash command replies
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Let OpenClaw recognize slash commands sent through the ACP bridge so command replies are projected into the visible chat timeline.
touchedAreas:
- harness/specs/tasks/acp-slash-command-replies.md
- electron/services/acp-chat-service.ts
- tests/unit/acp-chat-service.test.ts
- tests/e2e/chat-acp-slash-command-replies.spec.ts
expectedUserBehavior:
- Sending /status in ClawX produces a visible assistant status reply.
- Existing slash commands such as /compact continue to produce visible replies.
- Ordinary prompts continue to receive the working-directory prefix used by OpenClaw ACP.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- host-events-fallback-policy
- comms-regression
- docs-sync
requiredTests:
- pnpm exec vitest run tests/unit/acp-chat-service.test.ts
- pnpm exec playwright test tests/e2e/chat-acp-slash-command-replies.spec.ts
- pnpm run typecheck
- pnpm run comms:replay
- pnpm run comms:compare
acceptance:
- ACP prompts whose trimmed text starts with / disable OpenClaw's cwd text prefix.
- Ordinary ACP prompts retain the cwd text prefix.
- Slash command replies continue through the existing ACP session-update timeline path without transcript reconstruction or synthetic Renderer replies.
- Renderer does not add direct IPC, Gateway HTTP, or Gateway WebSocket calls.
docs:
required: false
---
OpenClaw classifies text slash commands before folding streamed command blocks into
the final chat message. A working-directory text prefix prevents that classification
and can leave commands such as `/status` without a visible ACP assistant reply.
@@ -0,0 +1,141 @@
import type { ElectronApplication } from '@playwright/test';
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
const SESSION_KEY = 'agent:main:main';
const MAIN_WORKSPACE = '/workspace';
const DEFAULT_WORKSPACE = '~/.openclaw/workspace';
function stableStringify(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
return `{${entries.join(',')}}`;
}
async function installSlashCommandReplyMock(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }, sessionKey) => {
const { BrowserWindow, ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type HostInvokeRequest = {
id?: string;
module?: string;
action?: string;
payload?: { message?: string };
};
type IpcInvokeHandler = (event: unknown, request: HostInvokeRequest) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
let replySequence = 0;
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: HostInvokeRequest) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
const command = request.payload?.message?.trim();
const text = command === '/status'
? 'OpenClaw status: connected'
: command === '/compact'
? 'Compaction complete'
: `Unexpected command: ${command ?? ''}`;
replySequence += 1;
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('chat:acp-session-update', {
sessionKey,
generation: 1,
notification: {
sessionId: sessionKey,
update: {
sessionUpdate: 'agent_message_chunk',
messageId: `slash-command-reply-${replySequence}`,
content: { type: 'text', text },
},
},
});
}
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
return originalHostInvoke?.(event, request) ?? { id: request.id, ok: true, data: {} };
});
}, SESSION_KEY);
}
test.describe('ClawX ACP slash-command replies', () => {
test('shows replies for /status and /compact in the chat timeline', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE }],
},
},
},
hostApi: {
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE }])]: {
success: true,
generation: 1,
},
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE, createIfMissing: true }])]: {
success: true,
generation: 1,
},
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: {
success: true,
generation: 1,
},
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE, createIfMissing: true }])]: {
success: true,
generation: 1,
},
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{ id: 'main', name: 'main', workspace: MAIN_WORKSPACE, mainSessionKey: SESSION_KEY }],
},
},
},
},
});
await installSlashCommandReplyMock(app);
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error;
}
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
const input = page.getByTestId('chat-composer-input');
await input.fill('/status');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByTestId('acp-assistant-message').filter({ hasText: 'OpenClaw status: connected' }))
.toBeVisible({ timeout: 30_000 });
await input.fill('/compact');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByTestId('acp-assistant-message').filter({ hasText: 'Compaction complete' }))
.toBeVisible({ timeout: 30_000 });
} finally {
await closeElectronApp(app);
}
});
});
+21
View File
@@ -243,6 +243,27 @@ describe('AcpChatService', () => {
});
});
it.each([
'/status',
' /compact',
])('disables the ACP cwd prefix for %s', async (message) => {
const { service, connection } = await createService();
await service.loadSession({ sessionKey: 'agent:pi:session-123', workspaceRoot: '/repo', cwd: '/repo', createIfMissing: true });
await expect(service.sendPrompt({
sessionKey: 'agent:pi:session-123',
cwd: '/repo',
message,
messageId: 'msg-command',
})).resolves.toEqual({ success: true, generation: 1 });
expect(connection.prompt).toHaveBeenCalledWith({
sessionId: 'acp-session-1',
prompt: [{ type: 'text', text: message.trim() }],
_meta: { sessionKey: 'agent:pi:session-123', prefixCwd: false, messageId: 'msg-command' },
});
});
it('rewrites fresh-session ACP updates to the ClawX session key for the renderer', async () => {
const { service, send } = await createService();