Files
ClawX/tests/e2e/chat-acp-inline-timeline.spec.ts
T

1426 lines
56 KiB
TypeScript

import type { ElectronApplication } from '@playwright/test';
import {
closeElectronApp,
expect,
getRecordedHostInvocations,
getStableWindow,
installIpcMocks,
test,
} from './fixtures/electron';
import { expandAcpToolCallsGroup } from './fixtures/acp-timeline';
const MAIN_SESSION_KEY = 'agent:main:main';
const MAIN_WORKSPACE = '/workspace';
const DEFAULT_WORKSPACE = '~/.openclaw/workspace';
const REVIEWER_SESSION_KEY = 'agent:reviewer:main';
const REVIEWER_WORKSPACE = '/workspace/reviewer';
const IMAGE_TASK_ID = '0d2ee919-2dfd-4b72-9da3-d87e6ee56747';
const GENERATED_IMAGE_PATH = '/workspace/.openclaw/media/tool-image-generation/generated-image.png';
const GENERATED_IMAGE_PREVIEW = 'data:image/png;base64,iVBORw0KGgo=';
const GENERATED_IMAGE_IDENTITY = 'e2e-transcript-generated-image';
const DEFAULT_WORKSPACE_SEGMENT = '~%2F.openclaw%2Fworkspace';
type AcpSessionUpdate = Record<string, unknown> & { sessionUpdate: string };
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(',')}}`;
}
function defaultWorkspaceSessionGroupTestId(): string {
return `workspace-session-group-${DEFAULT_WORKSPACE_SEGMENT}`;
}
function baseHostApiMocks(loadResult: Record<string, unknown> = { success: true, generation: 1 }) {
return {
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE }])]: loadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: MAIN_WORKSPACE, cwd: MAIN_WORKSPACE, createIfMissing: true }])]: loadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: loadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE, createIfMissing: true }])]: loadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: '/', cwd: '/' }])]: loadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: MAIN_SESSION_KEY, workspaceRoot: '/', cwd: '/', createIfMissing: true }])]: loadResult,
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [{
id: 'main',
name: 'main',
workspace: MAIN_WORKSPACE,
mainSessionKey: MAIN_SESSION_KEY,
}],
},
},
},
};
}
async function installAcpChatMocks(
app: ElectronApplication,
loadResult: Record<string, unknown> = { success: true, generation: 1 },
) {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE }],
},
},
},
hostApi: baseHostApiMocks(loadResult),
recordHostInvocations: true,
});
}
async function installAcpLoadReplayMock(
app: ElectronApplication,
updates: AcpSessionUpdate[],
timings: Array<{
normalizedUserText: string;
userOccurrenceFromTail: number;
durationMs: number;
}> = [],
) {
await app.evaluate(async ({ app: _app }, payload) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: { id?: string; module?: string; action?: string; args?: unknown[] }) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: { id?: string; module?: string; action?: string; args?: unknown[] }) => {
if (request?.module === 'chat' && request.action === 'loadAcpSession') {
return {
id: request.id,
ok: true,
data: {
success: true,
generation: 1,
sessionUpdates: (payload.updates as AcpSessionUpdate[]).map((update) => ({
sessionKey: payload.sessionKey,
generation: 1,
historical: true,
notification: {
sessionId: payload.sessionKey,
update,
},
})),
},
};
}
if (request?.module === 'sessions' && request.action === 'turnTimings') {
return {
id: request.id,
ok: true,
data: { success: true, timings: payload.timings },
};
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, { sessionKey: MAIN_SESSION_KEY, updates, timings });
}
async function installAcpLoadRecorderMock(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }, payload) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type HostInvokeRequest = {
id?: string;
module?: string;
action?: string;
payload?: Record<string, unknown>;
args?: unknown[];
};
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');
const globals = globalThis as unknown as { __acpLoadSessionKeys?: string[] };
globals.__acpLoadSessionKeys = [];
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: HostInvokeRequest) => {
if (request?.module === 'chat' && request.action === 'loadAcpSession') {
const requestPayload = request.payload ?? (Array.isArray(request.args) ? request.args[0] : undefined);
const sessionKey = requestPayload && typeof requestPayload === 'object'
? String((requestPayload as Record<string, unknown>).sessionKey ?? '')
: '';
globals.__acpLoadSessionKeys?.push(sessionKey);
if (sessionKey === payload.mainSessionKey) {
return {
id: request.id,
ok: true,
data: { success: false, error: 'Unexpected heartbeat-only session load in E2E test' },
};
}
if (/^agent:main:session-/.test(sessionKey)) {
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
return {
id: request.id,
ok: true,
data: { success: false, error: `Unexpected ACP session load in E2E test: ${sessionKey}` },
};
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, { mainSessionKey: MAIN_SESSION_KEY });
}
async function getRecordedAcpLoadSessionKeys(app: ElectronApplication) {
return await app.evaluate(async ({ app: _app }) => {
return (globalThis as unknown as { __acpLoadSessionKeys?: string[] }).__acpLoadSessionKeys ?? [];
});
}
async function installMediaSaveRecorder(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type HostInvokeRequest = {
id?: string;
module?: string;
action?: string;
payload?: Record<string, unknown>;
};
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');
const globals = globalThis as unknown as { __mediaSaveImagePayloads?: Record<string, unknown>[] };
globals.__mediaSaveImagePayloads = [];
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: HostInvokeRequest) => {
if (request?.module === 'media' && request.action === 'saveImage' && request.payload) {
globals.__mediaSaveImagePayloads?.push(request.payload);
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
});
}
async function getRecordedMediaSaveImagePayloads(app: ElectronApplication) {
return await app.evaluate(async ({ app: _app }) => {
return (globalThis as unknown as { __mediaSaveImagePayloads?: Record<string, unknown>[] }).__mediaSaveImagePayloads ?? [];
});
}
async function installAcpPromptSuccessMock(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: { id?: string; module?: string; action?: string }) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: { id?: string; module?: string; action?: string }) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
});
}
async function installAcpPromptTimingMock(
app: ElectronApplication,
timing: { normalizedUserText: string; durationMs: number },
) {
await app.evaluate(async ({ app: _app }, transcriptTiming) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: {
id?: string;
module?: string;
action?: string;
}) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: {
id?: string;
module?: string;
action?: string;
}) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
if (request?.module === 'sessions' && request.action === 'turnTimings') {
return {
id: request.id,
ok: true,
data: {
success: true,
timings: [{
...transcriptTiming,
userOccurrenceFromTail: 1,
}],
},
};
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, timing);
}
async function installAcpPromptFailureMock(app: ElectronApplication, error: string) {
await app.evaluate(async ({ app: _app }, promptError) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: { id?: string; module?: string; action?: string }) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: { id?: string; module?: string; action?: string }) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
return { id: request.id, ok: true, data: { success: false, error: promptError } };
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, error);
}
async function installTargetAgentRequestRecorder(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }, targetSessionKey) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type HostInvokeRequest = {
id?: string;
module?: string;
action?: string;
payload?: Record<string, unknown>;
args?: unknown[];
};
type RecordedRequest = { action: string; payload: Record<string, unknown> };
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');
const globals = globalThis as unknown as { __targetAgentRequests?: RecordedRequest[] };
globals.__targetAgentRequests = [];
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: HostInvokeRequest) => {
const requestPayload = request.payload ?? (Array.isArray(request.args) ? request.args[0] : undefined);
if (
request?.module === 'chat'
&& (request.action === 'loadAcpSession' || request.action === 'sendAcpPrompt')
&& requestPayload?.sessionKey === targetSessionKey
) {
globals.__targetAgentRequests?.push({
action: request.action,
payload: requestPayload,
});
return { id: request.id, ok: true, data: { success: true, generation: 1 } };
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
}, REVIEWER_SESSION_KEY);
}
async function getTargetAgentRequests(app: ElectronApplication) {
return await app.evaluate(async ({ app: _app }) => {
return (globalThis as unknown as {
__targetAgentRequests?: Array<{ action: string; payload: Record<string, unknown> }>;
}).__targetAgentRequests ?? [];
});
}
async function installAcpPromptDeferredMock(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }) => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type IpcInvokeHandler = (event: unknown, request: { id?: string; module?: string; action?: string }) => Promise<unknown>;
const handlers = (ipcMain as unknown as { _invokeHandlers?: Map<string, IpcInvokeHandler> })._invokeHandlers;
const originalHostInvoke = handlers?.get('host:invoke');
ipcMain.removeHandler('host:invoke');
ipcMain.handle('host:invoke', async (event: unknown, request: { id?: string; module?: string; action?: string }) => {
if (request?.module === 'chat' && request.action === 'sendAcpPrompt') {
return await new Promise((resolve) => {
(globalThis as unknown as { __resolveAcpPrompt?: () => void }).__resolveAcpPrompt = () => resolve({ id: request.id, ok: true, data: { success: true, generation: 1 } });
});
}
return originalHostInvoke?.(event, request) ?? { id: request?.id, ok: true, data: {} };
});
});
}
async function resolveDeferredAcpPrompt(app: ElectronApplication) {
await app.evaluate(async ({ app: _app }) => {
(globalThis as unknown as { __resolveAcpPrompt?: () => void }).__resolveAcpPrompt?.();
});
}
async function emitAcpSessionUpdates(
app: ElectronApplication,
updates: AcpSessionUpdate[],
generation = 1,
) {
await app.evaluate(
async ({ app: _app }, payload) => {
const { BrowserWindow } = process.mainModule!.require('electron') as typeof import('electron');
for (const update of payload.updates) {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('chat:acp-session-update', {
sessionKey: payload.sessionKey,
generation: payload.generation,
notification: {
sessionId: payload.sessionKey,
update,
},
});
}
}
},
{ sessionKey: MAIN_SESSION_KEY, generation, updates },
);
}
async function openChat(app: ElectronApplication) {
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('main-layout')).toBeVisible();
await expect(page.getByTestId('chat-page')).toBeVisible();
return page;
}
test.describe('ClawX ACP inline timeline', () => {
test('does not use legacy history on startup or current-session clicks', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click();
await page.waitForTimeout(100);
expect((await getRecordedHostInvocations(app)).some((call) => (
call.module === 'gateway'
&& call.action === 'rpc'
&& call.payload?.method === 'chat.history'
))).toBe(false);
} finally {
await closeElectronApp(app);
}
});
test('supplements an ACP-replayed assistant turn with historical duration', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message_chunk',
messageId: 'timed-history-user',
content: { type: 'text', text: 'Measure this historical turn' },
},
{
sessionUpdate: 'agent_message_chunk',
messageId: 'timed-history-assistant',
content: { type: 'text', text: 'Historical turn measured' },
},
], [{
normalizedUserText: 'Measure this historical turn',
userOccurrenceFromTail: 1,
durationMs: 6_400,
}]);
const page = await openChat(app);
await expect(page.getByText('Historical turn measured')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-turn-duration')).toHaveText('Took 6 sec');
} finally {
await closeElectronApp(app);
}
});
test('reconciles a completed live turn to transcript timing', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const prompt = 'Keep this live duration stable';
try {
await installAcpChatMocks(app);
await installAcpPromptTimingMock(app, {
normalizedUserText: prompt,
durationMs: 6_400,
});
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-input').fill(prompt);
await page.getByTestId('chat-composer-send').click();
await emitAcpSessionUpdates(app, [{
sessionUpdate: 'agent_message_chunk',
messageId: 'timed-live-assistant',
content: { type: 'text', text: 'Live turn measured' },
}]);
await expect(page.getByText('Live turn measured')).toBeVisible();
await expect(page.getByTestId('acp-turn-duration')).toHaveText('Took 6 sec');
} finally {
await closeElectronApp(app);
}
});
test('commits a long historical replay without exposing partial assistant text', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const paragraphChunks = Array.from({ length: 12 }, (_, index) => `Paragraph ${index + 1}.\n\n`);
const sessionUpdates = [
{
sessionKey: MAIN_SESSION_KEY,
generation: 1,
historical: true,
notification: {
sessionId: MAIN_SESSION_KEY,
update: {
sessionUpdate: 'user_message_chunk',
messageId: 'long-history-user',
content: { type: 'text', text: 'Write a 12-paragraph article' },
},
},
},
...paragraphChunks.map((text) => ({
sessionKey: MAIN_SESSION_KEY,
generation: 1,
historical: true,
notification: {
sessionId: MAIN_SESSION_KEY,
update: {
sessionUpdate: 'agent_message_chunk',
messageId: 'long-history-assistant',
content: { type: 'text', text },
},
},
})),
];
try {
await installAcpChatMocks(app, { success: true, generation: 1, sessionUpdates });
const initialPage = await getStableWindow(app);
await initialPage.addInitScript(() => {
const observedLengths: number[] = [];
Object.defineProperty(window, '__acpObservedAssistantLengths', {
value: observedLengths,
configurable: true,
});
const observer = new MutationObserver(() => {
const assistant = document.querySelector('[data-testid="acp-assistant-message"]');
const length = assistant?.textContent?.length ?? 0;
if (length > 0 && observedLengths.at(-1) !== length) observedLengths.push(length);
});
const observe = () => {
observer.observe(document.documentElement, { childList: true, characterData: true, subtree: true });
};
if (document.documentElement) observe();
else window.addEventListener('DOMContentLoaded', observe, { once: true });
});
const page = await openChat(app);
const assistant = page.getByTestId('acp-assistant-message');
await expect(assistant).toContainText('Paragraph 1.', { timeout: 30_000 });
await expect(assistant).toContainText('Paragraph 12.');
const finalLength = await assistant.evaluate((element) => element.textContent?.length ?? 0);
const observedLengths = await page.evaluate(() => (
(window as unknown as { __acpObservedAssistantLengths?: number[] }).__acpObservedAssistantLengths ?? []
));
expect(observedLengths).toEqual([finalLength]);
} finally {
await closeElectronApp(app);
}
});
test('renders ACP tool updates inline', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'user_message',
messageId: 'inline-user',
content: [{ type: 'text', text: 'Inspect the project files' }],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'read-package',
title: 'Read package.json',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'Loaded package metadata' } }],
locations: [],
},
]);
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-tool-call-card')).toBeVisible();
await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read package.json');
await expect(page.getByTestId('acp-tool-call-card')).toContainText('Loaded package metadata');
} finally {
await closeElectronApp(app);
}
});
test('shows optimistic user messages immediately and coalesces streamed assistant chunks', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpPromptSuccessMock(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-input').fill('Plan the migration');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Plan the migration')).toBeVisible();
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'agent_message_chunk',
messageId: 'assistant-stream',
content: { type: 'text', text: 'Streaming' },
},
{
sessionUpdate: 'agent_message_chunk',
messageId: 'assistant-stream',
content: { type: 'text', text: ' response' },
},
]);
await expect(page.locator('.prose').filter({ hasText: 'Streaming response' })).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('continues an ACP response while Chat is unmounted and shows the latest stream on return', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpPromptDeferredMock(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-input').fill('Keep working while I navigate');
await page.getByTestId('chat-composer-send').click();
await emitAcpSessionUpdates(app, [{
sessionUpdate: 'agent_message_chunk',
messageId: 'navigation-stream',
content: { type: 'text', text: 'Before navigation. ' },
}]);
await expect(page.getByTestId('acp-assistant-message')).toContainText('Before navigation.');
const duration = page.getByTestId('acp-turn-duration');
await expect(duration).toContainText('elapsed');
const beforeNavigationSeconds = Number.parseFloat((await duration.textContent()) ?? '0');
await page.getByTestId('sidebar-nav-settings').click();
await expect(page.getByTestId('settings-page')).toBeVisible();
await page.waitForTimeout(1_100);
await emitAcpSessionUpdates(app, [{
sessionUpdate: 'agent_message_chunk',
messageId: 'navigation-stream',
content: { type: 'text', text: 'While away. ' },
}]);
await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click();
await expect(page.getByTestId('chat-page')).toBeVisible();
await expect(page.getByTestId('acp-assistant-message')).toContainText('Before navigation. While away.');
await expect(duration).toContainText('elapsed');
expect(Number.parseFloat((await duration.textContent()) ?? '0')).toBeGreaterThan(beforeNavigationSeconds);
await emitAcpSessionUpdates(app, [{
sessionUpdate: 'agent_message_chunk',
messageId: 'navigation-stream',
content: { type: 'text', text: 'After return.' },
}]);
await expect(page.getByTestId('acp-assistant-message')).toContainText(
'Before navigation. While away. After return.',
);
await resolveDeferredAcpPrompt(app);
await expect(page.getByTestId('chat-composer-send')).toBeVisible();
await expect(duration).toContainText('Took');
const completedDuration = await duration.textContent();
await page.waitForTimeout(1_100);
await expect(duration).toHaveText(completedDuration ?? '');
} finally {
await closeElectronApp(app);
}
});
test('shows assistant identity and copies ACP assistant text', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: (value: string) => {
(window as unknown as { __acpCopiedText?: string }).__acpCopiedText = value;
return Promise.resolve();
},
},
configurable: true,
});
});
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'agent_message',
messageId: 'assistant-copy',
content: [{ type: 'text', text: 'Copy this ACP answer' }],
},
]);
const assistantMessage = page.getByTestId('acp-assistant-message');
await expect(assistantMessage).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-assistant-avatar')).toBeVisible();
await assistantMessage.hover();
const copyButton = page.getByTestId('acp-assistant-copy');
await expect.poll(async () => {
const [assistantBox, copyBox] = await Promise.all([
assistantMessage.boundingBox(),
copyButton.boundingBox(),
]);
return !!assistantBox && !!copyBox && copyBox.x <= assistantBox.x + 8;
}).toBe(true);
await copyButton.click();
await expect(copyButton).toHaveAttribute('aria-label', 'Copied');
await expect.poll(() => page.evaluate(() => (window as unknown as { __acpCopiedText?: string }).__acpCopiedText)).toBe('Copy this ACP answer');
} finally {
await closeElectronApp(app);
}
});
test('preserves ACP tool output newlines and indentation', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
const output = 'line one\n indented line\ncolumn_a\tcolumn_b';
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'tool_call',
toolCallId: 'format-output',
title: 'Inspect formatted output',
status: 'in_progress',
content: [{ type: 'content', content: { type: 'text', text: output } }],
locations: [],
},
]);
const pre = page.getByTestId('acp-tool-output-pre');
await expect(pre).toBeVisible({ timeout: 30_000 });
await expect.poll(() => pre.evaluate((element) => element.textContent)).toBe(output);
await expect.poll(() => pre.evaluate((element) => getComputedStyle(element).whiteSpace)).toBe('pre');
} finally {
await closeElectronApp(app);
}
});
test('groups assistant text and tool calls into one assistant turn', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'agent_message_chunk',
messageId: 'assistant-turn',
content: { type: 'text', text: 'I will inspect the file.' },
},
{
sessionUpdate: 'tool_call',
toolCallId: 'read-grouped',
title: 'Read grouped file',
status: 'in_progress',
content: [{ type: 'content', content: { type: 'text', text: 'grouped output' } }],
locations: [],
},
{
sessionUpdate: 'agent_message_chunk',
messageId: 'assistant-turn',
content: { type: 'text', text: ' The file is safe.' },
},
]);
await expect(page.getByTestId('acp-assistant-turn')).toHaveCount(1, { timeout: 30_000 });
await expect(page.getByTestId('acp-assistant-avatar')).toHaveCount(1);
await expect(page.getByTestId('acp-assistant-copy')).toHaveCount(1);
await expect(page.getByTestId('acp-tool-call-card')).toContainText('Read grouped file');
await expect.poll(async () => await page.getByTestId('acp-tool-call-card').evaluate((element) => Boolean(element.closest('[data-testid="acp-assistant-turn"]')))).toBe(true);
await expect(page.getByTestId('acp-assistant-turn')).toContainText('I will inspect the file.');
await expect(page.getByTestId('acp-assistant-turn')).toContainText('The file is safe.');
} finally {
await closeElectronApp(app);
}
});
test('auto-collapses completed tool cards and respects manual override', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'tool_call',
toolCallId: 'collapse-tool',
title: 'Collapsible tool',
status: 'in_progress',
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output' } }],
locations: [],
},
]);
const card = page.getByTestId('acp-tool-call-card');
await expect(card).toHaveAttribute('data-expanded', 'true', { timeout: 30_000 });
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'tool_call_update',
toolCallId: 'collapse-tool',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output' } }],
locations: [],
},
]);
await expect(card).toHaveAttribute('data-expanded', 'false', { timeout: 30_000 });
await page.getByTestId('acp-tool-toggle').click();
await expect(card).toHaveAttribute('data-expanded', 'true');
await emitAcpSessionUpdates(app, [
{
sessionUpdate: 'tool_call_update',
toolCallId: 'collapse-tool',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output after override' } }],
locations: [],
},
]);
await page.waitForTimeout(1_200);
await expect(card).toHaveAttribute('data-expanded', 'true');
} finally {
await closeElectronApp(app);
}
});
test('renders ledger-style replayed ACP tool events as historical tool cards', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message',
messageId: 'history-user',
content: [{ type: 'text', text: 'Replay the tool call' }],
},
{
sessionUpdate: 'agent_message_chunk',
messageId: 'history-assistant-before',
content: { type: 'text', text: 'Before the historical tool' },
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool',
title: 'Historical tool',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'historical output' } }],
locations: [],
},
{
sessionUpdate: 'agent_message_chunk',
messageId: 'history-assistant-after',
content: { type: 'text', text: 'After the historical tool' },
},
]);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
const card = page.getByTestId('acp-tool-call-card');
await expect(card).toContainText('Historical tool');
await expect(card).toHaveAttribute('data-expanded', 'false');
await page.getByTestId('acp-tool-toggle').click();
await expect(card).toHaveAttribute('data-expanded', 'true');
await expect(card).toContainText('historical output');
const turn = page.getByTestId('acp-assistant-turn');
await expect(turn).toContainText('Before the historical tool');
await expect(turn).toContainText('After the historical tool');
const orderedParts = turn.locator('[data-testid="acp-assistant-message"], [data-testid="acp-tool-call-card"]');
await expect(orderedParts).toHaveCount(3);
await expect(orderedParts.nth(0)).toContainText('Before the historical tool');
await expect(orderedParts.nth(1)).toContainText('Historical tool');
await expect(orderedParts.nth(2)).toContainText('After the historical tool');
} finally {
await closeElectronApp(app);
}
});
test('collapses multiple replayed tool calls into one group after the turn completes', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message',
messageId: 'history-user',
content: [{ type: 'text', text: 'Check weather' }],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-1',
title: 'web_search',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'search results' } }],
locations: [],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-2',
title: 'web_fetch',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'fetch results' } }],
locations: [],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-tool-3',
title: 'browser',
status: 'failed',
content: [{ type: 'content', content: { type: 'text', text: 'browser failed' } }],
locations: [],
},
{
sessionUpdate: 'agent_message',
messageId: 'history-assistant',
content: [{ type: 'text', text: 'Hangzhou is cloudy today.' }],
},
], [{
normalizedUserText: 'Check weather',
userOccurrenceFromTail: 0,
durationMs: 12_000,
}]);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
const group = page.getByTestId('acp-tool-calls-group');
await expect(group).toBeVisible();
await expect(group).toHaveAttribute('data-collapsed', 'true');
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
await expandAcpToolCallsGroup(page);
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(3);
await expect(page.getByTestId('acp-assistant-turn')).toContainText('Hangzhou is cloudy today.');
} finally {
await closeElectronApp(app);
}
});
test('hydrates historical image-generation completions from transcript history when ACP replay omits them', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE }],
},
},
},
hostApi: {
...baseHostApiMocks(),
[stableStringify(['sessions', 'history', { sessionKey: MAIN_SESSION_KEY, limit: 1000 }])]: {
success: true,
messages: [
{
id: 'transcript-image-start',
role: 'toolresult',
toolName: 'image_generate',
toolCallId: 'history-image-tool',
content: `Background task started for image generation (${IMAGE_TASK_ID})`,
details: { taskId: IMAGE_TASK_ID },
},
{
id: 'transcript-image-complete',
role: 'assistant',
content: `Here is the generated image.\nMEDIA:${GENERATED_IMAGE_PATH}`,
},
],
},
[stableStringify(['files', 'resolveAttachment', {
ref: {
sessionKey: MAIN_SESSION_KEY,
generation: 1,
uri: GENERATED_IMAGE_PATH,
transcriptMessageId: 'transcript-image-complete',
},
mimeType: 'image/png',
}])]: {
ok: true,
identity: GENERATED_IMAGE_IDENTITY,
displayName: 'generated-image.png',
mimeType: 'image/png',
size: 128,
target: {
kind: 'local',
scope: 'openclaw-media',
ref: {
sessionKey: MAIN_SESSION_KEY,
generation: 1,
uri: GENERATED_IMAGE_PATH,
transcriptMessageId: 'transcript-image-complete',
},
},
},
[stableStringify(['media', 'thumbnails', {
paths: [{
attachmentFileRef: {
sessionKey: MAIN_SESSION_KEY,
generation: 1,
uri: GENERATED_IMAGE_PATH,
transcriptMessageId: 'transcript-image-complete',
},
key: GENERATED_IMAGE_IDENTITY,
mimeType: 'image/png',
}],
}])]: {
[GENERATED_IMAGE_IDENTITY]: { preview: GENERATED_IMAGE_PREVIEW, fileSize: 128 },
},
[stableStringify(['media', 'saveImage', {
base64: 'iVBORw0KGgo=',
mimeType: 'image/png',
defaultFileName: 'generated-image.png',
}])]: {
success: true,
savedPath: '/tmp/generated-image.png',
},
},
});
await installMediaSaveRecorder(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message',
messageId: 'history-image-user',
content: [{ type: 'text', text: 'Generate an image' }],
},
{
sessionUpdate: 'tool_call',
toolCallId: 'history-image-tool',
title: 'Generate image',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: `Background task started for image generation (${IMAGE_TASK_ID})` } }],
locations: [],
},
]);
const page = await openChat(app);
await page.evaluate(() => {
class TestClipboardItem {
readonly items: Record<string, Blob>;
constructor(items: Record<string, Blob>) {
this.items = items;
}
}
Object.defineProperty(window, 'ClipboardItem', { value: TestClipboardItem, configurable: true });
Object.defineProperty(navigator, 'clipboard', {
value: {
write: (items: unknown[]) => {
const first = items[0] as { items?: Record<string, Blob> } | undefined;
(window as unknown as { __imageClipboardTypes?: string[] }).__imageClipboardTypes = Object.keys(first?.items ?? {});
return Promise.resolve();
},
},
configurable: true,
});
});
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-tool-call-card')).toContainText('Generate image');
const imagePart = page.getByTestId('acp-image-part');
const image = imagePart.locator('img');
await expect(image).toBeVisible();
await expect(image).toHaveAttribute('src', GENERATED_IMAGE_PREVIEW);
await imagePart.hover();
await expect(page.getByTestId('acp-image-copy')).toBeVisible();
await expect(page.getByTestId('acp-image-save')).toBeVisible();
await page.getByTestId('acp-image-copy').click();
await expect.poll(() => page.evaluate(() => (window as unknown as { __imageClipboardTypes?: string[] }).__imageClipboardTypes ?? [])).toEqual(['image/png']);
await page.getByTestId('acp-image-save').click();
await expect.poll(async () => await getRecordedMediaSaveImagePayloads(app)).toEqual([{
base64: 'iVBORw0KGgo=',
mimeType: 'image/png',
defaultFileName: 'generated-image.png',
}]);
} finally {
await closeElectronApp(app);
}
});
test('does not synthesize tool cards for transcript fallback text replay', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpLoadReplayMock(app, [
{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'Old transcript prompt' },
},
{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Transcript text mentions tool_call but has no structured tool event.' },
},
]);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('Transcript text mentions tool_call')).toBeVisible();
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('starts on a new empty chat instead of selecting a heartbeat-only ClawX session', async ({ launchElectronApp }) => {
const now = 1711111111111;
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{
key: MAIN_SESSION_KEY,
displayName: 'ClawX',
workspacePath: MAIN_WORKSPACE,
lastMessagePreview: '[OpenClaw heartbeat poll]',
updatedAt: new Date(now).toISOString(),
}],
},
},
},
hostApi: baseHostApiMocks(),
});
await installAcpLoadRecorderMock(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`)).toHaveCount(0);
await expect(page.getByText('[OpenClaw heartbeat poll]')).toHaveCount(0);
expect(await getRecordedAcpLoadSessionKeys(app)).toEqual([]);
await page.getByTestId('chat-composer-input').fill('Start a real conversation');
await page.getByTestId('chat-composer-send').click();
await expect.poll(async () => {
const loadSessionKeys = await getRecordedAcpLoadSessionKeys(app);
return loadSessionKeys.some((sessionKey) => /^agent:main:session-/.test(sessionKey));
}, { timeout: 30_000 }).toBe(true);
const loadSessionKeys = await getRecordedAcpLoadSessionKeys(app);
expect(loadSessionKeys).not.toContain(MAIN_SESSION_KEY);
} finally {
await closeElectronApp(app);
}
});
test('shows the composer dot pulse and thinking label only while sending', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installAcpChatMocks(app);
await installAcpPromptDeferredMock(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('chat-composer-working-indicator')).toHaveCount(0);
await expect(page.getByTestId('chat-composer-dot-pulse')).toHaveCount(0);
await page.getByTestId('chat-composer-input').fill('Hold the send state');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByTestId('chat-composer-working-indicator')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('chat-composer-working-indicator')).toContainText('Thinking…');
await expect(page.getByTestId('chat-composer-dot-pulse')).toBeVisible();
await expect(page.getByTestId('chat-composer-zoomies')).toHaveCount(0);
await resolveDeferredAcpPrompt(app);
await expect(page.getByTestId('chat-composer-working-indicator')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByTestId('chat-composer-dot-pulse')).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('keeps a blank new chat interactive after a recoverable initial ACP load failure', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE, updatedAt: new Date().toISOString() }],
},
},
},
hostApi: baseHostApiMocks({
success: false,
error: "Error invoking remote method 'host:invoke': reply was never sent",
}),
});
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-error-banner')).toHaveCount(0);
await expect(page.getByTestId('chat-composer-input')).toBeEnabled();
} finally {
await closeElectronApp(app);
}
});
test('creates and sends the first prompt to a newly targeted agent workspace', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [
{ key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE, updatedAt: new Date().toISOString() },
],
},
},
},
hostApi: {
...baseHostApiMocks(),
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [
{
id: 'main',
name: 'main',
workspace: MAIN_WORKSPACE,
mainSessionKey: MAIN_SESSION_KEY,
},
{
id: 'reviewer',
name: 'reviewer',
workspace: REVIEWER_WORKSPACE,
mainSessionKey: REVIEWER_SESSION_KEY,
modelDisplay: 'mock-model',
},
],
},
},
},
},
});
await installTargetAgentRequestRecorder(app);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-agent').click();
await page.getByRole('button', { name: 'reviewer mock-model' }).click();
await page.getByTestId('chat-composer-input').fill('Hello reviewer');
await page.getByTestId('chat-composer-send').click();
await expect.poll(async () => {
const requests = await getTargetAgentRequests(app);
return requests.some((request) => request.action === 'sendAcpPrompt');
}).toBe(true);
const requests = await getTargetAgentRequests(app);
expect(requests.filter((request) => request.action === 'loadAcpSession')).toEqual([{
action: 'loadAcpSession',
payload: {
sessionKey: REVIEWER_SESSION_KEY,
workspaceRoot: REVIEWER_WORKSPACE,
cwd: REVIEWER_WORKSPACE,
createIfMissing: true,
},
}]);
expect(requests.some((request) => (
request.action === 'sendAcpPrompt'
&& request.payload.sessionKey === REVIEWER_SESSION_KEY
&& request.payload.cwd === REVIEWER_WORKSPACE
&& request.payload.message === 'Hello reviewer'
))).toBe(true);
} finally {
await closeElectronApp(app);
}
});
test('keeps recoverable target-agent prompt failures visible after switching sessions', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const error = "Error invoking remote method 'host:invoke': reply was never sent";
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [
{ key: MAIN_SESSION_KEY, displayName: 'main', workspacePath: MAIN_WORKSPACE, updatedAt: new Date().toISOString() },
{ key: REVIEWER_SESSION_KEY, displayName: 'reviewer', workspacePath: REVIEWER_WORKSPACE, updatedAt: new Date().toISOString() },
],
},
},
},
hostApi: {
...baseHostApiMocks(),
[stableStringify(['chat', 'loadAcpSession', { sessionKey: REVIEWER_SESSION_KEY, workspaceRoot: REVIEWER_WORKSPACE, cwd: REVIEWER_WORKSPACE }])]: {
success: true,
generation: 1,
},
[stableStringify(['chat', 'loadAcpSession', { sessionKey: REVIEWER_SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: {
success: true,
generation: 1,
},
[stableStringify(['sessions', 'summaries', { sessionKeys: [MAIN_SESSION_KEY, REVIEWER_SESSION_KEY] }])]: {
summaries: [
{ sessionKey: MAIN_SESSION_KEY, workspacePath: MAIN_WORKSPACE },
{ sessionKey: REVIEWER_SESSION_KEY, workspacePath: REVIEWER_WORKSPACE },
],
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: {
success: true,
agents: [
{
id: 'main',
name: 'main',
workspace: MAIN_WORKSPACE,
mainSessionKey: MAIN_SESSION_KEY,
},
{
id: 'reviewer',
name: 'reviewer',
workspace: REVIEWER_WORKSPACE,
mainSessionKey: REVIEWER_SESSION_KEY,
modelDisplay: 'mock-model',
},
],
},
},
},
},
});
await installAcpPromptFailureMock(app, error);
const page = await openChat(app);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('workspace-session-group-%2Fworkspace%2Freviewer')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('chat-composer-agent').click();
await page.getByRole('button', { name: 'reviewer mock-model' }).click();
await page.getByTestId('chat-composer-input').fill('Trigger target send failure');
await page.getByTestId('chat-composer-send').click();
await expect(page.getByTestId('acp-error-banner')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('acp-error-banner')).toContainText(error);
} finally {
await closeElectronApp(app);
}
});
test('hides heartbeat-only ClawX sessions from the sidebar without hiding normal sessions', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const updatedAt = new Date().toISOString();
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', gatewayReady: true, port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [
{
key: 'agent:main:heartbeat',
displayName: 'ClawX',
lastMessagePreview: '[OpenClaw heartbeat poll]',
updatedAt,
},
{
key: 'agent:main:session-1710000000000',
displayName: 'ClawX',
derivedTitle: 'ClawX',
lastMessagePreview: 'Summarize the repository structure',
updatedAt,
},
],
},
},
},
hostApi: baseHostApiMocks(),
});
const page = await openChat(app);
await expect(page.getByTestId(defaultWorkspaceSessionGroupTestId())).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('sidebar-session-agent:main:heartbeat')).toHaveCount(0);
await expect(page.getByTestId('sidebar-session-agent:main:session-1710000000000')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});