feat(agents): support chat to agent (#403)
This commit is contained in:
@@ -72,6 +72,36 @@ describe('agent config lifecycle', () => {
|
||||
await expect(listConfiguredAgentIds()).resolves.toEqual(['main']);
|
||||
});
|
||||
|
||||
it('includes canonical per-agent main session keys in the snapshot', async () => {
|
||||
await writeOpenClawJson({
|
||||
session: {
|
||||
mainKey: 'desk',
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{ id: 'main', name: 'Main', default: true },
|
||||
{ id: 'research', name: 'Research' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
|
||||
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
expect(snapshot.agents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'main',
|
||||
mainSessionKey: 'agent:main:desk',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'research',
|
||||
mainSessionKey: 'agent:research:desk',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
|
||||
await writeOpenClawJson({
|
||||
agents: {
|
||||
|
||||
138
tests/unit/chat-input.test.tsx
Normal file
138
tests/unit/chat-input.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ChatInput } from '@/pages/Chat/ChatInput';
|
||||
|
||||
const { agentsState, chatState, gatewayState } = vi.hoisted(() => ({
|
||||
agentsState: {
|
||||
agents: [] as Array<Record<string, unknown>>,
|
||||
},
|
||||
chatState: {
|
||||
currentAgentId: 'main',
|
||||
},
|
||||
gatewayState: {
|
||||
status: { state: 'running', port: 18789 },
|
||||
},
|
||||
}));
|
||||
|
||||
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('@/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.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';
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: translate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ChatInput agent targeting', () => {
|
||||
beforeEach(() => {
|
||||
agentsState.agents = [];
|
||||
chatState.currentAgentId = 'main';
|
||||
gatewayState.status = { state: 'running', port: 18789 };
|
||||
});
|
||||
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTitle('Choose agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatInput onSend={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');
|
||||
});
|
||||
});
|
||||
190
tests/unit/chat-target-routing.test.ts
Normal file
190
tests/unit/chat-target-routing.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({
|
||||
gatewayRpcMock: vi.fn(),
|
||||
hostApiFetchMock: vi.fn(),
|
||||
agentsState: {
|
||||
agents: [] as Array<Record<string, unknown>>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gateway', () => ({
|
||||
useGatewayStore: {
|
||||
getState: () => ({
|
||||
rpc: gatewayRpcMock,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/agents', () => ({
|
||||
useAgentsStore: {
|
||||
getState: () => agentsState,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
}));
|
||||
|
||||
describe('chat target routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-11T12:00:00Z'));
|
||||
window.localStorage.clear();
|
||||
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
|
||||
gatewayRpcMock.mockReset();
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'chat.history') {
|
||||
return { messages: [] };
|
||||
}
|
||||
if (method === 'chat.send') {
|
||||
return { runId: 'run-text' };
|
||||
}
|
||||
if (method === 'chat.abort') {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === 'sessions.list') {
|
||||
return { sessions: [] };
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
|
||||
hostApiFetchMock.mockReset();
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, result: { runId: 'run-media' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('switches to the selected agent main session before sending text', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [{ role: 'assistant', content: 'Existing main history' }],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
showThinking: true,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('Hello direct agent', undefined, 'research');
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.currentSessionKey).toBe('agent:research:desk');
|
||||
expect(state.currentAgentId).toBe('research');
|
||||
expect(state.sessions.some((session) => session.key === 'agent:research:desk')).toBe(true);
|
||||
expect(state.messages.at(-1)?.content).toBe('Hello direct agent');
|
||||
|
||||
const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history');
|
||||
expect(historyCall?.[1]).toEqual({ sessionKey: 'agent:research:desk', limit: 200 });
|
||||
|
||||
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
|
||||
expect(sendCall?.[1]).toMatchObject({
|
||||
sessionKey: 'agent:research:desk',
|
||||
message: 'Hello direct agent',
|
||||
deliver: false,
|
||||
});
|
||||
expect(typeof (sendCall?.[1] as { idempotencyKey?: unknown })?.idempotencyKey).toBe('string');
|
||||
});
|
||||
|
||||
it('uses the selected agent main session for attachment sends', async () => {
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
showThinking: true,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage(
|
||||
'',
|
||||
[
|
||||
{
|
||||
fileName: 'design.png',
|
||||
mimeType: 'image/png',
|
||||
fileSize: 128,
|
||||
stagedPath: '/tmp/design.png',
|
||||
preview: 'data:image/png;base64,abc',
|
||||
},
|
||||
],
|
||||
'research',
|
||||
);
|
||||
|
||||
expect(useChatStore.getState().currentSessionKey).toBe('agent:research:desk');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/chat/send-with-media',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(String),
|
||||
}),
|
||||
);
|
||||
|
||||
const payload = JSON.parse(
|
||||
(hostApiFetchMock.mock.calls[0]?.[1] as { body: string }).body,
|
||||
) as {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
media: Array<{ filePath: string }>;
|
||||
};
|
||||
|
||||
expect(payload.sessionKey).toBe('agent:research:desk');
|
||||
expect(payload.message).toBe('Process the attached file(s).');
|
||||
expect(payload.media[0]?.filePath).toBe('/tmp/design.png');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user