mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix: restore ACP slash command replies such as /status (#1200)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user