feat: add tool status management and localization for skill installation
- Updated chat message types to include tool statuses. - Enhanced localization files for English, Thai, and Chinese to support new tool status messages. - Modified HomePage and SkillsPage components to handle tool statuses in chat messages. - Implemented tool status merging and updating logic in the chat store. - Added handling for tool status events in the gateway event processing. - Created tests for chat message rendering with tool statuses and skill installation shortcuts. - Improved gateway event dispatching for tool lifecycle events.
This commit is contained in:
@@ -82,17 +82,50 @@ describe('gateway browser shortcut', () => {
|
||||
'http://www.baidu.com/',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
status: 'running',
|
||||
}));
|
||||
expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
status: 'completed',
|
||||
}));
|
||||
expect(mocks.appendMessage).toHaveBeenCalledWith(
|
||||
'agent:test:main',
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '已为你打开 http://www.baidu.com/(百度一下,你就知道)',
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'browser.open_url',
|
||||
status: 'completed',
|
||||
input: { url: 'http://www.baidu.com/' },
|
||||
result: expect.objectContaining({
|
||||
pageUrl: 'http://www.baidu.com/',
|
||||
title: '百度一下,你就知道',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
type: 'chat:final',
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
message: expect.objectContaining({
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'browser.open_url',
|
||||
status: 'completed',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -113,16 +146,45 @@ describe('gateway browser shortcut', () => {
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
runId: 'run-2',
|
||||
toolName: 'browser.open_url',
|
||||
status: 'running',
|
||||
}));
|
||||
expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
runId: 'run-2',
|
||||
toolName: 'browser.open_url',
|
||||
status: 'error',
|
||||
}));
|
||||
expect(mocks.appendMessage).toHaveBeenCalledWith(
|
||||
'agent:test:main',
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '打开失败:No browser context available',
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'browser.open_url',
|
||||
status: 'error',
|
||||
result: {
|
||||
error: 'No browser context available',
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
type: 'chat:final',
|
||||
runId: 'run-2',
|
||||
message: expect.objectContaining({
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'browser.open_url',
|
||||
status: 'error',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
153
tests/chat-message-list.test.tsx
Normal file
153
tests/chat-message-list.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setLocale } from '../src/i18n';
|
||||
import ChatMessageList from '../src/components/chat/ChatMessageList';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiOpenSkillPath: vi.fn(),
|
||||
apiOpenSkillReadme: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/lib/skills-api', () => ({
|
||||
apiOpenSkillPath: mocks.apiOpenSkillPath,
|
||||
apiOpenSkillReadme: mocks.apiOpenSkillReadme,
|
||||
}));
|
||||
|
||||
describe('ChatMessageList', () => {
|
||||
beforeEach(() => {
|
||||
setLocale('zh');
|
||||
vi.clearAllMocks();
|
||||
mocks.apiOpenSkillPath.mockResolvedValue(undefined);
|
||||
mocks.apiOpenSkillReadme.mockResolvedValue(undefined);
|
||||
mocks.writeText.mockResolvedValue(undefined);
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: mocks.writeText,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders standalone streaming tool status cards when there is no streaming assistant text yet', () => {
|
||||
render(
|
||||
<ChatMessageList
|
||||
messages={[
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
name: '你',
|
||||
time: '10:00',
|
||||
content: '帮我安装这个 skill',
|
||||
},
|
||||
]}
|
||||
streamingTools={[
|
||||
{
|
||||
id: 'tool-1',
|
||||
toolCallId: 'skills.install:run-1',
|
||||
name: 'skills.install',
|
||||
status: 'running',
|
||||
summary: 'Installing minimax-xlsx',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在执行工具...')).toBeTruthy();
|
||||
expect(screen.getByText('安装 Skill')).toBeTruthy();
|
||||
expect(screen.getByText('运行中')).toBeTruthy();
|
||||
expect(screen.getByText('Installing minimax-xlsx')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders streaming tool status cards above a streaming assistant message', () => {
|
||||
render(
|
||||
<ChatMessageList
|
||||
messages={[
|
||||
{
|
||||
id: 'assistant-stream',
|
||||
role: 'assistant',
|
||||
name: 'YINIAN',
|
||||
time: '10:01',
|
||||
content: '已安装完成',
|
||||
isStreaming: true,
|
||||
},
|
||||
]}
|
||||
streamingTools={[
|
||||
{
|
||||
id: 'tool-2',
|
||||
toolCallId: 'browser.open_url:run-2',
|
||||
name: 'browser.open_url',
|
||||
status: 'completed',
|
||||
durationMs: 1200,
|
||||
summary: '已为你打开 http://www.baidu.com/',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('打开网页')).toBeTruthy();
|
||||
expect(screen.getByText('已完成')).toBeTruthy();
|
||||
expect(screen.getByText('1.2s')).toBeTruthy();
|
||||
expect(screen.getByText('已为你打开 http://www.baidu.com/')).toBeTruthy();
|
||||
expect(screen.getByText('已安装完成')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders persistent skill-install tool cards with follow-up actions', async () => {
|
||||
render(
|
||||
<ChatMessageList
|
||||
messages={[
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
name: 'YINIAN',
|
||||
time: '10:03',
|
||||
content: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx',
|
||||
toolStatuses: [
|
||||
{
|
||||
id: 'tool-3',
|
||||
toolCallId: 'skills.install:run-3',
|
||||
name: 'skills.install',
|
||||
status: 'completed',
|
||||
summary: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx',
|
||||
durationMs: 980,
|
||||
updatedAt: Date.now(),
|
||||
input: {
|
||||
kind: 'github-url',
|
||||
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
|
||||
},
|
||||
result: {
|
||||
slug: 'minimax-xlsx',
|
||||
source: 'github-url',
|
||||
baseDir: '/tmp/minimax-xlsx',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx')).toHaveLength(1);
|
||||
expect(screen.getByText('Skill')).toBeTruthy();
|
||||
expect(screen.getByText('/tmp/minimax-xlsx')).toBeTruthy();
|
||||
expect(screen.getByText('后续动作')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText('打开目录'));
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiOpenSkillPath).toHaveBeenCalledWith(
|
||||
'minimax-xlsx',
|
||||
'minimax-xlsx',
|
||||
'/tmp/minimax-xlsx',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('复制路径'));
|
||||
await waitFor(() => {
|
||||
expect(mocks.writeText).toHaveBeenCalledWith('/tmp/minimax-xlsx');
|
||||
});
|
||||
expect(screen.getByText('路径已复制')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
137
tests/chat-runtime-context.test.ts
Normal file
137
tests/chat-runtime-context.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const sessionMessages: any[] = [];
|
||||
|
||||
return {
|
||||
sessionMessages,
|
||||
providerChat: vi.fn(),
|
||||
appendMessage: vi.fn((_: string, message: unknown) => {
|
||||
sessionMessages.push(message);
|
||||
}),
|
||||
getOrCreate: vi.fn(() => ({
|
||||
key: 'agent:test:main',
|
||||
messages: [...sessionMessages],
|
||||
updatedAt: Date.now(),
|
||||
})),
|
||||
setActiveRun: vi.fn(),
|
||||
clearActiveRun: vi.fn(),
|
||||
appendTranscriptLine: vi.fn(),
|
||||
maybeHandleBrowserOpenMessage: vi.fn(() => false),
|
||||
maybeHandleSkillInstallMessage: vi.fn(() => false),
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@electron/providers', () => ({
|
||||
createProvider: vi.fn(() => ({
|
||||
chat: mocks.providerChat,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/provider-api-service', () => ({
|
||||
providerApiService: {
|
||||
getDefault: () => ({ accountId: 'provider-1' }),
|
||||
getAccounts: () => [
|
||||
{
|
||||
id: 'provider-1',
|
||||
model: 'gpt-4o-mini',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI',
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/session-store', () => ({
|
||||
sessionStore: {
|
||||
appendMessage: mocks.appendMessage,
|
||||
getOrCreate: mocks.getOrCreate,
|
||||
setActiveRun: mocks.setActiveRun,
|
||||
clearActiveRun: mocks.clearActiveRun,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/token-usage-writer', () => ({
|
||||
appendTranscriptLine: mocks.appendTranscriptLine,
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/browser-shortcut', () => ({
|
||||
maybeHandleBrowserOpenMessage: mocks.maybeHandleBrowserOpenMessage,
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/skill-install-shortcut', () => ({
|
||||
maybeHandleSkillInstallMessage: mocks.maybeHandleSkillInstallMessage,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/logger', () => ({
|
||||
default: mocks.logger,
|
||||
}));
|
||||
|
||||
function createStream(chunks: Array<{ result?: string; usage?: unknown }>) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function flushAsyncTasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('chat runtime context', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.sessionMessages.length = 0;
|
||||
mocks.maybeHandleBrowserOpenMessage.mockReturnValue(false);
|
||||
mocks.maybeHandleSkillInstallMessage.mockReturnValue(false);
|
||||
mocks.providerChat.mockResolvedValue(createStream([{ result: 'done' }]));
|
||||
});
|
||||
|
||||
it('prepends the zn-ai runtime context before provider chat runs', async () => {
|
||||
const { handleChatSend } = await import('../electron/gateway/handlers/chat');
|
||||
|
||||
const result = handleChatSend(
|
||||
{
|
||||
sessionKey: 'agent:test:main',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: '帮我看一下这个网页',
|
||||
},
|
||||
},
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(result.runId).toBeTypeOf('string');
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(mocks.providerChat).toHaveBeenCalledTimes(1);
|
||||
const [messages, model] = mocks.providerChat.mock.calls[0] ?? [];
|
||||
|
||||
expect(model).toBe('gpt-4o-mini');
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'system',
|
||||
content: expect.stringContaining('browser.open_url'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: '帮我看一下这个网页',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: 'system',
|
||||
content: expect.stringContaining('skills.install'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -120,6 +120,48 @@ describe('gateway event dispatch', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches protocol tool lifecycle events onto the normalized tool status channel', () => {
|
||||
const { emitter, events } = createEmitterRecorder();
|
||||
|
||||
dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_STARTED, {
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
});
|
||||
dispatchProtocolEvent(emitter, GatewayEventType.TOOL_CALL_COMPLETED, {
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
durationMs: 42,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
event: 'tool:status',
|
||||
payload: {
|
||||
status: 'running',
|
||||
payload: {
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event: 'tool:status',
|
||||
payload: {
|
||||
status: 'completed',
|
||||
payload: {
|
||||
sessionKey: 'agent:test:main',
|
||||
runId: 'run-1',
|
||||
toolName: 'browser.open_url',
|
||||
durationMs: 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispatches unknown protocol events to notification listeners', () => {
|
||||
const { emitter, events } = createEmitterRecorder();
|
||||
|
||||
@@ -146,6 +188,11 @@ describe('gateway event dispatch', () => {
|
||||
method: GatewayEventType.MESSAGE_RECEIVED,
|
||||
params: { message: { text: 'hello' } },
|
||||
});
|
||||
dispatchJsonRpcNotification(emitter, {
|
||||
jsonrpc: '2.0',
|
||||
method: GatewayEventType.TOOL_CALL_STARTED,
|
||||
params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' },
|
||||
});
|
||||
dispatchJsonRpcNotification(emitter, {
|
||||
jsonrpc: '2.0',
|
||||
method: GatewayEventType.ERROR,
|
||||
@@ -176,9 +223,24 @@ describe('gateway event dispatch', () => {
|
||||
event: 'chat:message',
|
||||
payload: { message: { text: 'hello' } },
|
||||
});
|
||||
expect(events[4].event).toBe('notification');
|
||||
expect(events[5].event).toBe('error');
|
||||
expect((events[5].payload as Error).message).toBe('gateway boom');
|
||||
expect(events[4]).toEqual({
|
||||
event: 'notification',
|
||||
payload: {
|
||||
jsonrpc: '2.0',
|
||||
method: GatewayEventType.TOOL_CALL_STARTED,
|
||||
params: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' },
|
||||
},
|
||||
});
|
||||
expect(events[5]).toEqual({
|
||||
event: 'tool:status',
|
||||
payload: {
|
||||
status: 'running',
|
||||
payload: { sessionKey: 'agent:test:main', runId: 'run-2', toolName: 'browser.open_url' },
|
||||
},
|
||||
});
|
||||
expect(events[6].event).toBe('notification');
|
||||
expect(events[7].event).toBe('error');
|
||||
expect((events[7].payload as Error).message).toBe('gateway boom');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleProviderGetDefault: vi.fn(),
|
||||
handleSkillsStatus: vi.fn(),
|
||||
handleSkillsUpdate: vi.fn(),
|
||||
handleSkillsInstall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/handlers/chat', () => ({
|
||||
@@ -29,6 +30,7 @@ vi.mock('../electron/gateway/handlers/provider', () => ({
|
||||
vi.mock('../electron/gateway/handlers/skills', () => ({
|
||||
handleSkillsStatus: mocks.handleSkillsStatus,
|
||||
handleSkillsUpdate: mocks.handleSkillsUpdate,
|
||||
handleSkillsInstall: mocks.handleSkillsInstall,
|
||||
}));
|
||||
|
||||
describe('dispatchGatewayRpcMethod', () => {
|
||||
@@ -119,8 +121,16 @@ describe('dispatchGatewayRpcMethod', () => {
|
||||
mocks.handleProviderGetDefault.mockReturnValue({ accountId: 'provider-1' });
|
||||
mocks.handleSkillsStatus.mockReturnValue({ skills: [] });
|
||||
mocks.handleSkillsUpdate.mockReturnValue({ success: true });
|
||||
mocks.handleSkillsInstall.mockResolvedValue({
|
||||
success: true,
|
||||
slug: 'minimax-xlsx',
|
||||
baseDir: '/tmp/minimax-xlsx',
|
||||
source: 'github-url',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { dispatchGatewayRpcMethod } = await import('../electron/gateway/rpc-dispatch');
|
||||
const broadcast = vi.fn();
|
||||
|
||||
expect(
|
||||
dispatchGatewayRpcMethod('provider.getDefault', {}, vi.fn()),
|
||||
@@ -140,6 +150,20 @@ describe('dispatchGatewayRpcMethod', () => {
|
||||
handled: true,
|
||||
result: { success: true },
|
||||
});
|
||||
expect(
|
||||
dispatchGatewayRpcMethod(
|
||||
'skills.install',
|
||||
{ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md' },
|
||||
broadcast,
|
||||
),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
result: expect.any(Promise),
|
||||
});
|
||||
expect(mocks.handleSkillsInstall).toHaveBeenCalledWith(
|
||||
{ kind: 'github-url', url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md' },
|
||||
broadcast,
|
||||
);
|
||||
expect(
|
||||
dispatchGatewayRpcMethod('gateway.ping', {}, vi.fn()),
|
||||
).toEqual({
|
||||
|
||||
174
tests/skill-install-shortcut.test.ts
Normal file
174
tests/skill-install-shortcut.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
appendMessage: vi.fn(),
|
||||
setActiveRun: vi.fn(),
|
||||
clearActiveRun: vi.fn(),
|
||||
handleSkillsInstall: vi.fn(),
|
||||
parseGitHubSkillUrl: vi.fn((url: string) => ({
|
||||
owner: 'MiniMax-AI',
|
||||
repo: 'skills',
|
||||
ref: 'main',
|
||||
skillPath: 'skills/minimax-xlsx',
|
||||
defaultSlug: 'minimax-xlsx',
|
||||
archiveUrl: 'https://api.github.com/repos/MiniMax-AI/skills/zipball/main',
|
||||
repositoryUrl: 'https://github.com/MiniMax-AI/skills.git',
|
||||
originalUrl: url,
|
||||
})),
|
||||
appendTranscriptLine: vi.fn(),
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/session-store', () => ({
|
||||
sessionStore: {
|
||||
appendMessage: mocks.appendMessage,
|
||||
setActiveRun: mocks.setActiveRun,
|
||||
clearActiveRun: mocks.clearActiveRun,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../electron/gateway/handlers/skills', () => ({
|
||||
handleSkillsInstall: mocks.handleSkillsInstall,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/skill-install-service', () => ({
|
||||
parseGitHubSkillUrl: mocks.parseGitHubSkillUrl,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/token-usage-writer', () => ({
|
||||
appendTranscriptLine: mocks.appendTranscriptLine,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/service/logger', () => ({
|
||||
default: mocks.logger,
|
||||
}));
|
||||
|
||||
function flushAsyncTasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('gateway skill install shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('extracts a github-url install intent from an explicit install request', async () => {
|
||||
const { extractSkillInstallIntent } = await import('../electron/gateway/skill-install-shortcut');
|
||||
|
||||
expect(
|
||||
extractSkillInstallIntent(
|
||||
'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill',
|
||||
),
|
||||
).toEqual({
|
||||
request: {
|
||||
kind: 'github-url',
|
||||
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
|
||||
},
|
||||
description: 'minimax-xlsx',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts a marketplace install intent when the user explicitly names a skill slug', async () => {
|
||||
const { extractSkillInstallIntent } = await import('../electron/gateway/skill-install-shortcut');
|
||||
|
||||
expect(
|
||||
extractSkillInstallIntent('帮我安装 minimax-xlsx 这个 skill'),
|
||||
).toEqual({
|
||||
request: {
|
||||
kind: 'marketplace',
|
||||
slug: 'minimax-xlsx',
|
||||
},
|
||||
description: 'minimax-xlsx',
|
||||
});
|
||||
});
|
||||
|
||||
it('runs a skill install shortcut and emits tool status before the final assistant message', async () => {
|
||||
mocks.handleSkillsInstall.mockResolvedValue({
|
||||
success: true,
|
||||
slug: 'minimax-xlsx',
|
||||
baseDir: '/tmp/minimax-xlsx',
|
||||
source: 'github-url',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { maybeHandleSkillInstallMessage } = await import('../electron/gateway/skill-install-shortcut');
|
||||
const broadcast = vi.fn();
|
||||
|
||||
const handled = maybeHandleSkillInstallMessage(
|
||||
'agent:test:main',
|
||||
'run-1',
|
||||
{
|
||||
role: 'user',
|
||||
content: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill',
|
||||
},
|
||||
broadcast,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mocks.setActiveRun).toHaveBeenCalledWith(
|
||||
'agent:test:main',
|
||||
'run-1',
|
||||
expect.any(AbortController),
|
||||
);
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(mocks.handleSkillsInstall).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: 'github-url',
|
||||
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
|
||||
},
|
||||
broadcast,
|
||||
);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
runId: 'run-1',
|
||||
toolName: 'skills.install',
|
||||
status: 'running',
|
||||
}));
|
||||
expect(broadcast).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
type: 'tool:status',
|
||||
runId: 'run-1',
|
||||
toolName: 'skills.install',
|
||||
status: 'completed',
|
||||
}));
|
||||
expect(mocks.appendMessage).toHaveBeenCalledWith(
|
||||
'agent:test:main',
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '已安装并启用 skill minimax-xlsx(github-url)。位置:/tmp/minimax-xlsx',
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'skills.install',
|
||||
status: 'completed',
|
||||
input: {
|
||||
kind: 'github-url',
|
||||
url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md',
|
||||
},
|
||||
result: expect.objectContaining({
|
||||
slug: 'minimax-xlsx',
|
||||
baseDir: '/tmp/minimax-xlsx',
|
||||
source: 'github-url',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(broadcast).toHaveBeenNthCalledWith(3, expect.objectContaining({
|
||||
type: 'chat:final',
|
||||
runId: 'run-1',
|
||||
message: expect.objectContaining({
|
||||
_toolStatuses: [
|
||||
expect.objectContaining({
|
||||
name: 'skills.install',
|
||||
status: 'completed',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user