import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ChatInput } from '@/pages/Chat/ChatInput';
import { TooltipProvider } from '@/components/ui/tooltip';
import { hostApiFetch } from '@/lib/host-api';
const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks } = vi.hoisted(() => ({
agentsState: {
agents: [] as Array<Record<string, unknown>>,
defaultModelRef: null as string | null,
updateAgentModel: vi.fn(),
},
chatState: {
currentAgentId: 'main',
},
gatewayState: {
status: { state: 'running', port: 18789 },
},
providersState: {
accounts: [] as Array<Record<string, unknown>>,
statuses: [] as Array<Record<string, unknown>>,
defaultAccountId: null as string | null,
refreshProviderSnapshot: vi.fn(),
},
artifactPanelMocks: {
openPreview: vi.fn(),
},
}));
vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
}));
vi.mock('@/stores/chat', () => ({
useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
}));
vi.mock('@/stores/providers', () => ({
useProviderStore: (selector: (state: typeof providersState) => unknown) => selector(providersState),
}));
vi.mock('@/stores/artifact-panel', () => ({
useArtifactPanel: (selector: (state: typeof artifactPanelMocks) => unknown) => selector(artifactPanelMocks),
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: vi.fn(),
}));
vi.mock('@/lib/api-client', () => ({
invokeIpc: vi.fn(),
}));
function translate(key: string, vars?: Record<string, unknown>): string {
switch (key) {
case 'composer.attachFiles':
return 'Attach files';
case 'composer.pickSkill':
return 'Choose skill';
case 'composer.skillButton':
return 'Skill';
case 'composer.skillPickerTitle':
return `Quick skill access for ${String(vars?.agent ?? '')}`;
case 'composer.skillSearchPlaceholder':
return 'Search skills';
case 'composer.skillLoading':
return 'Loading skills...';
case 'composer.skillEmpty':
return 'No matching skills found';
case 'composer.pickAgent':
return 'Choose agent';
case 'composer.clearTarget':
return 'Clear target agent';
case 'composer.targetChip':
return `@${String(vars?.agent ?? '')}`;
case 'composer.agentPickerTitle':
return 'Route the next message to another agent';
case 'composer.gatewayDisconnectedPlaceholder':
return 'Gateway not connected...';
case 'composer.send':
return 'Send';
case 'composer.stop':
return 'Stop';
case 'composer.gatewayConnected':
return 'connected';
case 'composer.gatewayStatus':
return `gateway ${String(vars?.state ?? '')} | port: ${String(vars?.port ?? '')} ${String(vars?.pid ?? '')}`.trim();
case 'composer.retryFailedAttachments':
return 'Retry failed attachments';
case 'composer.skillPreviewTooltip':
return 'Preview SKILL.md';
case 'composer.skillPreviewNotFound':
return 'Skill not found';
default:
return key;
}
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: translate,
}),
}));
function renderChatInput(onSend = vi.fn()) {
return render(
<TooltipProvider>
<ChatInput onSend={onSend} />
</TooltipProvider>,
);
}
describe('ChatInput agent targeting', () => {
beforeEach(() => {
agentsState.agents = [];
agentsState.defaultModelRef = null;
agentsState.updateAgentModel.mockReset();
chatState.currentAgentId = 'main';
gatewayState.status = { state: 'running', port: 18789 };
providersState.accounts = [];
providersState.statuses = [];
providersState.defaultAccountId = null;
providersState.refreshProviderSnapshot.mockReset();
vi.mocked(hostApiFetch).mockReset();
artifactPanelMocks.openPreview.mockReset();
});
it('hides the @agent picker when only one agent is configured', () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
renderChatInput();
expect(screen.queryByTitle('Choose agent')).not.toBeInTheDocument();
});
it('uses native textarea rendering when no skill token is present', () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: '我没有填写Skill' } });
expect(textbox).toHaveValue('我没有填写Skill');
expect(screen.queryByTestId('chat-composer-skill-token')).not.toBeInTheDocument();
expect(textbox.className).not.toContain('text-transparent');
});
it('lets the user select an agent target and sends it with the message', () => {
const onSend = vi.fn();
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
{
id: 'research',
name: 'Research',
isDefault: false,
modelDisplay: 'Claude',
inheritedModel: false,
workspace: '~/.openclaw/workspace-research',
agentDir: '~/.openclaw/agents/research/agent',
mainSessionKey: 'agent:research:desk',
channelTypes: [],
},
];
renderChatInput(onSend);
fireEvent.click(screen.getByTitle('Choose agent'));
fireEvent.click(screen.getByText('Research'));
expect(screen.getByText('@Research')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello direct agent' } });
fireEvent.click(screen.getByTitle('Send'));
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research');
});
it('disables the input while gateway is running but not yet ready', () => {
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
agentsState.defaultModelRef = 'custom-aaaaaaaa/gpt-a';
const now = '2025-01-01T00:00:00.000Z';
providersState.accounts = [
{
id: 'aaaaaaaa',
vendorId: 'custom',
label: 'Alpha',
authMode: 'api_key',
baseUrl: 'http://127.0.0.1:1/v1',
model: 'custom-aaaaaaaa/gpt-a',
enabled: true,
isDefault: true,
createdAt: now,
updatedAt: now,
},
{
id: 'bbbbbbbb',
vendorId: 'custom',
label: 'Beta',
authMode: 'api_key',
baseUrl: 'http://127.0.0.1:2/v1',
model: 'custom-bbbbbbbb/gpt-b',
enabled: true,
isDefault: false,
createdAt: now,
updatedAt: now,
},
];
providersState.statuses = [
{ id: 'aaaaaaaa', name: 'Alpha', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
{ id: 'bbbbbbbb', name: 'Beta', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
];
providersState.defaultAccountId = 'aaaaaaaa';
renderChatInput();
expect(screen.getByTestId('chat-composer-input')).toBeDisabled();
expect(screen.getByTestId('chat-composer-skill')).toBeDisabled();
expect(screen.getByTestId('chat-model-picker-button')).toBeDisabled();
});
it('shows starting status while gateway is running but not yet ready', () => {
gatewayState.status = { state: 'running', port: 18789, gatewayReady: false };
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
renderChatInput();
expect(screen.getByText(/gateway starting \| port: 18789/i)).toBeInTheDocument();
});
it('renders the skill trigger after the @ agent picker', () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
{
id: 'research',
name: 'Research',
isDefault: false,
modelDisplay: 'Claude',
inheritedModel: false,
workspace: '~/.openclaw/workspace-research',
agentDir: '~/.openclaw/agents/research/agent',
mainSessionKey: 'agent:research:desk',
channelTypes: [],
},
];
renderChatInput();
const agentTrigger = screen.getByTestId('chat-composer-agent');
const skillTrigger = screen.getByTestId('chat-composer-skill');
expect(skillTrigger).toHaveTextContent('Skill');
expect(agentTrigger.compareDocumentPosition(skillTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('inserts the selected skill at the current cursor position and prefixes sends', async () => {
const onSend = vi.fn();
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
baseDir: '/tmp/workspace/skill/create-skill',
},
],
});
renderChatInput(onSend);
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: 'Draft a new helper' } });
textbox.focus();
textbox.setSelectionRange('Draft '.length, 'Draft '.length);
fireEvent.click(screen.getByTitle('Choose skill'));
expect(await screen.findByText('/create-skill')).toBeInTheDocument();
fireEvent.click(screen.getByText('/create-skill'));
expect(screen.getByTestId('chat-composer-skill')).toHaveTextContent('Skill');
expect(textbox).toHaveValue('Draft /create-skill a new helper');
expect(screen.getByTestId('chat-composer-skill-token')).toHaveTextContent('/create-skill');
fireEvent.click(screen.getByTitle('Send'));
expect(onSend).toHaveBeenCalledWith('Draft /create-skill a new helper', undefined, null);
expect(hostApiFetch).toHaveBeenCalledWith(
'/api/skills/quick-access',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
);
});
it('removes the full inline skill token with one backspace', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
baseDir: '/tmp/workspace/skill/create-skill',
},
],
});
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: 'Draft a new helper' } });
textbox.focus();
textbox.setSelectionRange('Draft '.length, 'Draft '.length);
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByText('/create-skill'));
expect(textbox).toHaveValue('Draft /create-skill a new helper');
textbox.setSelectionRange('Draft /create-skill '.length, 'Draft /create-skill '.length);
fireEvent.keyDown(textbox, { key: 'Backspace' });
expect(textbox).toHaveValue('Draft a new helper');
});
it('skips across the inline skill block with arrow keys', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
baseDir: '/tmp/workspace/skill/create-skill',
},
],
});
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: 'Draft a new helper' } });
textbox.focus();
textbox.setSelectionRange('Draft '.length, 'Draft '.length);
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByText('/create-skill'));
textbox.setSelectionRange('Draft '.length, 'Draft '.length);
fireEvent.keyDown(textbox, { key: 'ArrowRight' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(textbox.selectionStart).toBe('Draft /create-skill '.length);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(textbox.selectionStart).toBe('Draft '.length);
});
it('adds left spacing when inserting a skill after adjacent text', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'docx',
description: 'Work with Word documents.',
source: 'legacy',
sourceLabel: 'Legacy',
manifestPath: '/tmp/openclaw/skills/docx/SKILL.md',
baseDir: '/tmp/openclaw/skills/docx',
},
],
});
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: '哈哈哈哈你好' } });
textbox.focus();
textbox.setSelectionRange('哈哈哈哈'.length, '哈哈哈哈'.length);
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByText('/docx'));
expect(textbox).toHaveValue('哈哈哈哈 /docx 你好');
});
it('allows inserting the same skill multiple times as separate blocks', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'create-rule',
description: 'Create Cursor rules.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/workspace/skill/create-rule/SKILL.md',
baseDir: '/tmp/workspace/skill/create-rule',
},
],
});
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByTestId('chat-composer-skill-option-create-rule'));
textbox.setSelectionRange(textbox.value.length, textbox.value.length);
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByTestId('chat-composer-skill-option-create-rule'));
expect(textbox).toHaveValue('/create-rule /create-rule ');
expect(screen.getAllByTestId('chat-composer-skill-token')).toHaveLength(2);
});
it('opens the artifact preview panel when the inline skill token is clicked', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
vi.mocked(hostApiFetch).mockResolvedValue({
success: true,
skills: [
{
name: 'create-skill',
description: 'Create and refine reusable skills.',
source: 'workspace',
sourceLabel: 'Workspace',
manifestPath: '/tmp/workspace/skill/create-skill/SKILL.md',
baseDir: '/tmp/workspace/skill/create-skill',
},
],
});
renderChatInput();
const textbox = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(textbox, { target: { value: 'Draft a new helper' } });
textbox.focus();
textbox.setSelectionRange('Draft '.length, 'Draft '.length);
fireEvent.click(screen.getByTitle('Choose skill'));
fireEvent.click(await screen.findByText('/create-skill'));
fireEvent.click(screen.getByTestId('chat-composer-skill-token'));
expect(artifactPanelMocks.openPreview).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/workspace/skill/create-skill/SKILL.md',
fileName: 'SKILL.md',
}),
);
});
it('stages dropped folders via disk path instead of buffer upload', async () => {
vi.mocked(hostApiFetch).mockResolvedValueOnce([{
id: 'folder-id',
fileName: 'Archive',
mimeType: 'application/x-directory',
fileSize: 0,
stagedPath: '/tmp/project-folder',
preview: null,
}]);
const folderFile = new File([new Uint8Array(192)], 'Archive', { type: 'application/zip' });
Object.defineProperty(folderFile, 'path', { value: '/tmp/project-folder' });
const { container } = renderChatInput();
fireEvent.drop(container.firstElementChild as Element, {
dataTransfer: {
items: [{
kind: 'file',
getAsFile: () => folderFile,
webkitGetAsEntry: () => ({ isDirectory: true, isFile: false }),
}],
files: [folderFile],
},
});
await waitFor(() => {
expect(hostApiFetch).toHaveBeenCalledWith('/api/files/stage-paths', {
method: 'POST',
body: JSON.stringify({ filePaths: ['/tmp/project-folder'] }),
});
});
expect(await screen.findByText('Archive')).toBeInTheDocument();
});
});