Files
MyGoNavi/frontend/src/components/ai/useAIChatLocalTools.test.tsx
Syngnat f0afff68c4 ♻️ refactor(ai-chat): 拆分本地工具调用链
- 抽出 useAIChatLocalTools 承载工具执行、熔断和回灌模型逻辑

- 补齐重试消息的工具上下文依赖,避免配置变更后使用旧闭包

- 增加 hook 行为测试并同步 MCP 指南断言
2026-06-09 21:18:39 +08:00

195 lines
6.0 KiB
TypeScript

import React, { useRef, useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useStore } from '../../store';
import type { AIToolCall } from '../../types';
import { useAIChatLocalTools } from './useAIChatLocalTools';
const dispatchAIChatPayloadMock = vi.hoisted(() => vi.fn(async (_options: any) => 'stream'));
const executeLocalAIToolCallMock = vi.hoisted(() => vi.fn(async ({ toolCall }: { toolCall: AIToolCall }) => ({
content: `result:${toolCall.function.name}`,
success: true,
toolName: toolCall.function.name,
})));
vi.mock('./aiChatPayloadDispatch', () => ({
dispatchAIChatPayload: dispatchAIChatPayloadMock,
}));
vi.mock('./aiLocalToolExecutor', () => ({
executeLocalAIToolCall: executeLocalAIToolCallMock,
buildToolResultMessage: ({ id, timestamp, toolCall, execution }: any) => ({
id,
role: 'tool',
content: execution.content,
timestamp,
tool_call_id: toolCall.id,
tool_name: execution.toolName,
success: execution.success,
}),
}));
const SESSION_ID = 'session-local-tools';
const buildToolCall = (name: string): AIToolCall => ({
id: `call-${name}`,
type: 'function',
function: {
name,
arguments: '{}',
},
});
const updateMessage = (
sessionId: string,
messageId: string,
patch: Parameters<ReturnType<typeof useStore.getState>['updateAIChatMessage']>[2],
) => useStore.getState().updateAIChatMessage(sessionId, messageId, patch);
let latestHook: ReturnType<typeof useAIChatLocalTools> | undefined;
const LocalToolsHarness = () => {
const [sending, setSending] = useState(false);
const pendingJVMPlanContextRef = useRef<any>(undefined);
const pendingJVMDiagnosticPlanContextRef = useRef<any>(undefined);
latestHook = useAIChatLocalTools({
sid: SESSION_ID,
activeProviderModel: 'gpt-5',
availableTools: [{
type: 'function',
function: {
name: 'inspect_active_tab',
description: 'inspect tab',
parameters: { type: 'object', properties: {} },
},
}],
buildSystemContextMessages: async () => [{ role: 'system', content: 'system-context' }],
dynamicModels: ['gpt-5'],
mcpTools: [],
nextMessageId: () => `generated-${Math.random().toString(36).slice(2, 6)}`,
pendingJVMPlanContextRef,
pendingJVMDiagnosticPlanContextRef,
setSending,
skills: [],
updateAIChatMessage: updateMessage,
userPromptSettings: {
global: '',
database: '',
jvm: '',
jvmDiagnostic: '',
},
});
return <span data-sending={sending} />;
};
describe('useAIChatLocalTools', () => {
beforeEach(() => {
vi.useFakeTimers();
dispatchAIChatPayloadMock.mockClear();
executeLocalAIToolCallMock.mockClear();
latestHook = undefined;
useStore.setState({
activeContext: { connectionId: 'conn-1', dbName: 'crm' },
aiChatHistory: {
[SESSION_ID]: [
{ id: 'user-1', role: 'user', content: '查一下当前页签', timestamp: 1 },
{
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: 2,
loading: true,
phase: 'tool_calling',
tool_calls: [buildToolCall('inspect_active_tab')],
},
],
},
aiChatSessions: [{ id: SESSION_ID, title: '查一下当前页签', updatedAt: 1 }],
aiActiveSessionId: SESSION_ID,
connections: [{
id: 'conn-1',
name: '主库',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
},
}],
tabs: [{
id: 'tab-1',
title: '订单查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'crm',
query: 'select * from orders',
}],
activeTabId: 'tab-1',
aiContexts: {},
sqlLogs: [],
savedQueries: [],
sqlSnippets: [],
externalSQLDirectories: [],
});
});
afterEach(() => {
vi.useRealTimers();
useStore.setState({
activeContext: null,
aiChatHistory: {},
aiChatSessions: [],
aiActiveSessionId: null,
tabs: [],
activeTabId: null,
aiContexts: {},
sqlLogs: [],
savedQueries: [],
sqlSnippets: [],
externalSQLDirectories: [],
});
});
it('writes tool results, closes the tool-calling message, and excludes connecting placeholders from the chained request', async () => {
let renderer: ReactTestRenderer | undefined;
await act(async () => {
renderer = create(<LocalToolsHarness />);
});
expect(latestHook).toBeDefined();
const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1');
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
await run;
});
const messages = useStore.getState().aiChatHistory[SESSION_ID] || [];
const assistant = messages.find((message) => message.id === 'assistant-1');
const toolResult = messages.find((message) => message.role === 'tool');
const connecting = messages.find((message) => message.phase === 'connecting');
expect(executeLocalAIToolCallMock).toHaveBeenCalledTimes(1);
expect(assistant).toMatchObject({ loading: false, phase: 'idle' });
expect(toolResult).toMatchObject({
content: 'result:inspect_active_tab',
success: true,
tool_name: 'inspect_active_tab',
});
expect(connecting).toMatchObject({ content: '汇总探针执行结果中', loading: true });
expect(dispatchAIChatPayloadMock).toHaveBeenCalledTimes(1);
const dispatchArgs = dispatchAIChatPayloadMock.mock.calls[0][0] as any;
expect(dispatchArgs.messages[0]).toEqual({ role: 'system', content: 'system-context' });
expect(JSON.stringify(dispatchArgs.messages)).toContain('result:inspect_active_tab');
expect(JSON.stringify(dispatchArgs.messages)).not.toContain('汇总探针执行结果中');
expect(dispatchArgs.tools).toHaveLength(1);
await act(async () => {
renderer?.unmount();
});
});
});