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:
duanshuwen
2026-04-23 20:27:54 +08:00
parent 979fb0a0f6
commit df600272d6
29 changed files with 2041 additions and 384 deletions

View File

@@ -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',
}),
],
}),
}));
});

View 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-xlsxgithub-url。位置/tmp/minimax-xlsx',
toolStatuses: [
{
id: 'tool-3',
toolCallId: 'skills.install:run-3',
name: 'skills.install',
status: 'completed',
summary: '已安装并启用 skill minimax-xlsxgithub-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-xlsxgithub-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();
});
});

View 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'),
});
});
});

View File

@@ -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');
});
});

View File

@@ -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({

View 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-xlsxgithub-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',
}),
],
}),
}));
});
});