diff --git a/workspaces/lightspeed/.changeset/clean-tomatoes-clean.md b/workspaces/lightspeed/.changeset/clean-tomatoes-clean.md new file mode 100644 index 0000000000..8d90c48b6d --- /dev/null +++ b/workspaces/lightspeed/.changeset/clean-tomatoes-clean.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': patch +--- + +update how tool call info is retrived diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useConversationMessages.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useConversationMessages.test.tsx index 6211584b4b..4090fc2ae9 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useConversationMessages.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useConversationMessages.test.tsx @@ -559,6 +559,143 @@ data: {"event": "token", "data": {"id": 2, "token": ""}}\n expect(onComplete).toHaveBeenCalledWith('Hi from conversation 1!'); }); + it('should parse MCP-style tool_call and tool_result (name/args and content)', async () => { + const toolCallId = 'mcp_list_67d3a067-e262-4bef-b467-6c82f622bd4a'; + const mcpStream = createSSEStream([ + { + event: 'start', + data: { conversation_id: 'conv-mcp' }, + }, + { + event: 'tool_call', + data: { + id: toolCallId, + name: 'mcp_list_tools', + args: { server_label: 'mcp-integration-tools' }, + type: 'mcp_list_tools', + }, + }, + { + event: 'tool_result', + data: { + id: toolCallId, + status: 'success', + content: '{"server_label":"mcp-integration-tools","tools":[]}', + }, + }, + { + event: 'token', + data: { id: 0, token: 'Done.' }, + }, + ]); + + const mockApi = { + createMessage: jest.fn().mockResolvedValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(mcpStream), + }) + .mockResolvedValueOnce({ done: true, value: null }), + }), + }; + (useApi as jest.Mock).mockReturnValue(mockApi); + + const { result } = renderHook( + () => + useConversationMessages( + 'conv-mcp', + 'test-user', + 'gpt-4', + 'openai', + 'user.png', + ), + { wrapper }, + ); + + await act(async () => { + await result.current.handleInputPrompt('List MCP tools'); + }); + + await waitFor(() => { + const msgs = result.current.conversations['conv-mcp']; + const bot = msgs?.[1]; + expect(bot?.toolCalls).toHaveLength(1); + expect(bot?.toolCalls?.[0]).toEqual( + expect.objectContaining({ + id: toolCallId, + toolName: 'mcp_list_tools', + arguments: { server_label: 'mcp-integration-tools' }, + response: '{"server_label":"mcp-integration-tools","tools":[]}', + isLoading: false, + }), + ); + expect(bot?.content).toContain('Done.'); + }); + }); + + it('should complete legacy tool_result when response field is omitted', async () => { + const legacyStream = createSSEStream([ + { + event: 'start', + data: { conversation_id: 'conv-legacy-res' }, + }, + { + event: 'tool_call', + data: { + id: 1, + token: { tool_name: 'fetch-techdocs', arguments: { owner: 'a' } }, + }, + }, + { + event: 'tool_result', + data: { id: 1, token: { tool_name: 'fetch-techdocs' } }, + }, + ]); + + const mockApi = { + createMessage: jest.fn().mockResolvedValue({ + read: jest + .fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(legacyStream), + }) + .mockResolvedValueOnce({ done: true, value: null }), + }), + }; + (useApi as jest.Mock).mockReturnValue(mockApi); + + const { result } = renderHook( + () => + useConversationMessages( + 'conv-legacy-res', + 'test-user', + 'gpt-4', + 'openai', + 'user.png', + ), + { wrapper }, + ); + + await act(async () => { + await result.current.handleInputPrompt('techdocs'); + }); + + await waitFor(() => { + const msgs = result.current.conversations['conv-legacy-res']; + const bot = msgs?.[1]; + expect(bot?.toolCalls?.[0]).toEqual( + expect.objectContaining({ + toolName: 'fetch-techdocs', + response: '', + isLoading: false, + }), + ); + }); + }); + it('should resume streaming for the first conversation after switching back and complete', async () => { const onComplete = jest.fn(); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts index ca60832fa0..97bf0ea4d7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts @@ -42,6 +42,44 @@ import { } from '../utils/lightspeed-chatbox-utils'; import { useCreateConversationMessage } from './useCreateCoversationMessage'; +const toolCallIdKey = (id: string | number): string => { + return String(id); +}; + +const isMcpStyleToolCallPayload = ( + data: Record | undefined, +): boolean => { + return ( + !!data && + typeof data.name === 'string' && + data.name.trim().length > 0 && + data.id !== null + ); +}; + +/** Legacy tool_result uses data.token with at least tool_name and response. */ +const isLegacyToolResultToken = ( + token: unknown, +): token is { tool_name: string; response?: unknown } => { + return ( + !!token && + typeof token === 'object' && + !Array.isArray(token) && + typeof (token as { tool_name?: string }).tool_name === 'string' && + (token as { tool_name: string }).tool_name.length > 0 + ); +}; + +const legacyToolResultToString = (response: unknown): string => { + if (!response) return ''; + if (typeof response === 'string') return response; + try { + return JSON.stringify(response); + } catch { + return String(response); + } +}; + // Fetch all conversation messages export const useFetchConversationMessages = ( currentConversation: string, @@ -117,7 +155,7 @@ export const useConversationMessages = ( }); // Track pending tool calls during streaming - const pendingToolCalls = useRef<{ [id: number]: ToolCall }>({}); + const pendingToolCalls = useRef>({}); // Cache tool calls by conversation ID and message index to persist across refetches // Key format: `${conversationId}-${messageIndex}` @@ -298,19 +336,49 @@ export const useConversationMessages = ( // Handle tool_call event if (event === 'tool_call') { const toolCallData = data?.token; - if ( + const legacyObjectCall = typeof toolCallData === 'object' && - toolCallData?.tool_name - ) { - // Full tool call with arguments - track start time - const toolCall: ToolCall = { + toolCallData !== null && + !Array.isArray(toolCallData) && + (toolCallData as { tool_name?: string }).tool_name; + + const mcpStyle = isMcpStyleToolCallPayload(data); + const rawArgs = data?.args ?? data?.arguments; + const mcpArgs: Record = + rawArgs && + typeof rawArgs === 'object' && + !Array.isArray(rawArgs) + ? rawArgs + : {}; + + let toolCall: ToolCall | undefined; + // Prefer legacy token object when present (backward compatible) + if (legacyObjectCall && data.id !== null) { + toolCall = { id: data.id, - toolName: toolCallData.tool_name, - arguments: toolCallData.arguments || {}, + toolName: (toolCallData as { tool_name: string }).tool_name, + arguments: + (toolCallData as { arguments?: Record }) + .arguments || {}, startTime: Date.now(), isLoading: true, }; - pendingToolCalls.current[data.id] = toolCall; + } else if (mcpStyle) { + toolCall = { + id: data.id, + toolName: data.name.trim(), + description: + typeof data.type === 'string' && data.type !== data.name + ? data.type + : undefined, + arguments: mcpArgs, + startTime: Date.now(), + isLoading: true, + }; + } + + if (toolCall && data.id !== null) { + pendingToolCalls.current[toolCallIdKey(data.id)] = toolCall; // Update the bot message with the pending tool call setConversations(prevConversations => { @@ -358,10 +426,41 @@ export const useConversationMessages = ( // Handle tool_result event if (event === 'tool_result') { - const resultData = data?.token; - if (resultData?.tool_name) { - const toolId = data.id; - const pendingCall = pendingToolCalls.current[toolId]; + const tokenResult = data?.token; + const legacyResult = isLegacyToolResultToken(tokenResult); + + const mcpHasContent = + data?.id !== null && + data.content !== undefined && + !legacyResult; + + let responsePayload: string | undefined; + let matchToolName: string | undefined; + let toolIdKey: string | undefined; + + if (legacyResult) { + responsePayload = legacyToolResultToString( + tokenResult.response, + ); + matchToolName = tokenResult.tool_name; + toolIdKey = + data?.id !== null ? toolCallIdKey(data.id) : undefined; + } else if (mcpHasContent) { + toolIdKey = toolCallIdKey(data.id); + responsePayload = + typeof data.content === 'string' + ? data.content + : JSON.stringify(data.content); + if ( + typeof data.status === 'string' && + data.status !== 'success' + ) { + responsePayload = `[${data.status}] ${responsePayload}`; + } + } + + if (responsePayload !== undefined && toolIdKey !== undefined) { + const pendingCall = pendingToolCalls.current[toolIdKey]; const endTime = Date.now(); const executionTime = pendingCall ? (endTime - pendingCall.startTime) / 1000 @@ -380,13 +479,14 @@ export const useConversationMessages = ( // Find and update the matching tool call const updatedToolCalls = toolCalls.map(tc => { - if ( - tc.id === toolId || - tc.toolName === resultData.tool_name - ) { + const idMatches = + toolCallIdKey(tc.id) === toolIdKey || + (matchToolName !== undefined && + tc.toolName === matchToolName); + if (idMatches) { return { ...tc, - response: resultData.response, + response: responsePayload, endTime, executionTime, isLoading: false, @@ -419,13 +519,14 @@ export const useConversationMessages = ( if (aiMessage) { const toolCalls = aiMessage.toolCalls || []; const updatedToolCalls = toolCalls.map(tc => { - if ( - tc.id === toolId || - tc.toolName === resultData.tool_name - ) { + const idMatches = + toolCallIdKey(tc.id) === toolIdKey || + (matchToolName !== undefined && + tc.toolName === matchToolName); + if (idMatches) { return { ...tc, - response: resultData.response, + response: responsePayload, endTime, executionTime, isLoading: false, @@ -440,7 +541,7 @@ export const useConversationMessages = ( } // Clean up pending tool call - delete pendingToolCalls.current[toolId]; + delete pendingToolCalls.current[toolIdKey]; } } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts index d48426c217..2a0d7074ba 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts @@ -168,7 +168,7 @@ export type CaptureFeedback = { // Tool Calling Types export interface ToolCall { - id: number; + id: number | string; toolName: string; description?: string; arguments: Record; @@ -180,11 +180,17 @@ export interface ToolCall { } export interface ToolCallEvent { - id: number; - token: string | { tool_name: string; arguments: Record }; + id: number | string; + token?: string | { tool_name: string; arguments: Record }; + name?: string; + args?: Record; + arguments?: Record; + type?: string; } export interface ToolResultEvent { - id: number; - token: { tool_name: string; response: string }; + id: number | string; + token?: { tool_name: string; response: string }; + status?: string; + content?: string; }