Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/clean-tomatoes-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
---

update how tool call info is retrived
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,44 @@
} from '../utils/lightspeed-chatbox-utils';
import { useCreateConversationMessage } from './useCreateCoversationMessage';

const toolCallIdKey = (id: string | number): string => {

Check warning on line 45 in workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

arrow function 'toolCallIdKey' is equivalent to `String`. Use `String` directly.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ0ld1xSb7rItWqi929A&open=AZ0ld1xSb7rItWqi929A&pullRequest=2607
return String(id);
};

const isMcpStyleToolCallPayload = (
data: Record<string, any> | undefined,
): boolean => {
return (
!!data &&
typeof data.name === 'string' &&
data.name.trim().length > 0 &&
data.id !== null
);
Comment on lines +49 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Undefined tool id accepted 🐞 Bug ✓ Correctness

Tool events only guard data.id !== null, so a missing id (undefined) is treated as valid and
stored under the key "undefined". This can collide multiple tool calls and break tool_result
correlation, leaving the wrong call completed or calls stuck loading.
Agent Prompt
### Issue description
Tool call/result parsing accepts `id: undefined` because it only checks `id !== null`, then keys `pendingToolCalls` by `String(id)` which becomes `"undefined"`.

### Issue Context
When backend events omit an id, multiple tool calls will collide in `pendingToolCalls.current['undefined']`, causing incorrect updates and stuck loading.

### Fix Focus Areas
- workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts[45-58]
- workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts[336-382]
- workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts[432-465]

### Recommended change
- Replace `data.id !== null` checks with `data.id != null` (covers null + undefined) or an explicit `(data.id !== null && data.id !== undefined)`.
- Consider additionally guarding against empty-string ids if backend might send them (`String(id).trim().length > 0`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

};

/** 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);

Check warning on line 79 in workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'response' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ0ld1xSb7rItWqi929C&open=AZ0ld1xSb7rItWqi929C&pullRequest=2607
}
};

// Fetch all conversation messages
export const useFetchConversationMessages = (
currentConversation: string,
Expand Down Expand Up @@ -117,7 +155,7 @@
});

// Track pending tool calls during streaming
const pendingToolCalls = useRef<{ [id: number]: ToolCall }>({});
const pendingToolCalls = useRef<Record<string, ToolCall>>({});

// Cache tool calls by conversation ID and message index to persist across refetches
// Key format: `${conversationId}-${messageIndex}`
Expand Down Expand Up @@ -298,19 +336,49 @@
// 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<string, any> =
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<string, any> })
.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 => {
Expand Down Expand Up @@ -358,10 +426,41 @@

// 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;

Check warning on line 447 in workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ0ld1xSb7rItWqi929D&open=AZ0ld1xSb7rItWqi929D&pullRequest=2607
} 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
Expand All @@ -380,13 +479,14 @@

// 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,
Expand Down Expand Up @@ -419,13 +519,14 @@
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,
Expand All @@ -440,7 +541,7 @@
}

// Clean up pending tool call
delete pendingToolCalls.current[toolId];
delete pendingToolCalls.current[toolIdKey];
}
}

Expand Down
16 changes: 11 additions & 5 deletions workspaces/lightspeed/plugins/lightspeed/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export type CaptureFeedback = {

// Tool Calling Types
export interface ToolCall {
id: number;
id: number | string;
toolName: string;
description?: string;
arguments: Record<string, any>;
Expand All @@ -180,11 +180,17 @@ export interface ToolCall {
}

export interface ToolCallEvent {
id: number;
token: string | { tool_name: string; arguments: Record<string, any> };
id: number | string;
token?: string | { tool_name: string; arguments: Record<string, any> };
name?: string;
args?: Record<string, any>;
arguments?: Record<string, any>;
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;
}
Loading