From 230ac15cbf80cbfdc992be957d6ad6438d911a46 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:36:14 +0800 Subject: [PATCH] fix: restore ACP slash command replies such as /status (#1200) --- electron/services/acp-chat-service.ts | 5 +- .../specs/tasks/acp-slash-command-replies.md | 43 ++++++ .../chat-acp-slash-command-replies.spec.ts | 141 ++++++++++++++++++ tests/unit/acp-chat-service.test.ts | 21 +++ 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 harness/specs/tasks/acp-slash-command-replies.md create mode 100644 tests/e2e/chat-acp-slash-command-replies.spec.ts diff --git a/electron/services/acp-chat-service.ts b/electron/services/acp-chat-service.ts index 20024843..f346ab14 100644 --- a/electron/services/acp-chat-service.ts +++ b/electron/services/acp-chat-service.ts @@ -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, diff --git a/harness/specs/tasks/acp-slash-command-replies.md b/harness/specs/tasks/acp-slash-command-replies.md new file mode 100644 index 00000000..1f94ef12 --- /dev/null +++ b/harness/specs/tasks/acp-slash-command-replies.md @@ -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. diff --git a/tests/e2e/chat-acp-slash-command-replies.spec.ts b/tests/e2e/chat-acp-slash-command-replies.spec.ts new file mode 100644 index 00000000..ee0f1c3f --- /dev/null +++ b/tests/e2e/chat-acp-slash-command-replies.spec.ts @@ -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) + .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; + const handlers = (ipcMain as unknown as { _invokeHandlers?: Map })._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); + } + }); +}); diff --git a/tests/unit/acp-chat-service.test.ts b/tests/unit/acp-chat-service.test.ts index 8fa1350e..8e1f438b 100644 --- a/tests/unit/acp-chat-service.test.ts +++ b/tests/unit/acp-chat-service.test.ts @@ -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();