diff --git a/app/debug/debug.tsx b/app/debug/debug.tsx index e2d11f8..4eca9e7 100644 --- a/app/debug/debug.tsx +++ b/app/debug/debug.tsx @@ -250,6 +250,50 @@ const getStreamingEventText = (event: unknown): string | null => { : null; }; +const getToolCallText = (value: unknown): string | null => { + if (Array.isArray(value)) { + const calls = value + .map(getToolCallText) + .filter((item): item is string => Boolean(item)); + return calls.length ? calls.join('\n') : null; + } + + if (!isRecord(value)) return null; + + const type = typeof value.type === 'string' ? value.type : ''; + const name = isRecord(value.function) ? value.function.name : value.name; + const argumentsValue = isRecord(value.function) + ? value.function.arguments + : (value.arguments ?? value.input); + + if ( + (type === 'function' || + type === 'function_call' || + type === 'mcp_call' || + type === 'custom_tool_call') && + (typeof name === 'string' || argumentsValue !== undefined) + ) { + const label = typeof name === 'string' ? name : 'Unnamed tool'; + return argumentsValue === undefined + ? `Tool call: ${label}` + : `Tool call: ${label} ${String(argumentsValue)}`; + } + + for (const key of [ + 'tool_calls', + 'output', + 'message', + 'item', + 'response', + 'delta', + ]) { + const text = getToolCallText(value[key]); + if (text) return text; + } + + return null; +}; + const getToolName = (tool: JsonRecord): string => { const functionValue = isRecord(tool.function) ? tool.function : tool; return String(functionValue.name ?? tool.name ?? 'Unnamed tool'); @@ -708,7 +752,10 @@ const StructuredResponse = ({ .filter((item): item is string => Boolean(item)) .join('') || getText(responseRecord?.choices) || - getText(responseRecord?.content); + getText(responseRecord?.content) || + getToolCallText(eventPayloads) || + getToolCallText(responseRecord?.choices) || + getToolCallText(responseRecord?.output); const usage = isRecord(responseRecord?.usage) ? responseRecord.usage : null; const visibleContent = content && !showFullContent diff --git a/e2e/account-status.spec.ts b/e2e/account-status.spec.ts index 60bfeda..dfe3f24 100644 --- a/e2e/account-status.spec.ts +++ b/e2e/account-status.spec.ts @@ -73,6 +73,18 @@ test.describe('Account Status tab', () => { await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'http://127.0.0.1:8001', }); + await page.addInitScript(() => { + let clipboardText = ''; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: async () => clipboardText, + writeText: async (value: string) => { + clipboardText = value; + }, + }, + }); + }); const filename = `account-status-copy-${process.pid}.json`; const createResponse = await page.request.post('/admin-api/credentials', { data: { diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 2b6d6ac..7491f93 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -1220,11 +1220,23 @@ const mapResponsesPayloadToChat = ( const toolCalls = output.flatMap((item) => { if (!item || typeof item !== 'object') return []; const value = item as Record; - if (value.type !== 'function_call') return []; + if ( + value.type !== 'function_call' && + value.type !== 'mcp_call' && + value.type !== 'custom_tool_call' + ) { + return []; + } + const isCustomToolCall = value.type === 'custom_tool_call'; + const customArguments = JSON.stringify({ + input: String(value.input ?? value.arguments ?? ''), + }); return [ { function: { - arguments: String(value.arguments ?? ''), + arguments: String( + isCustomToolCall ? customArguments : (value.arguments ?? ''), + ), name: String(value.name ?? 'function'), }, id: String(value.call_id ?? value.id ?? crypto.randomUUID()), @@ -1326,6 +1338,8 @@ const mapResponsesStreamToChat = ( let pendingStopText = ''; const toolIndexes = new Map(); const toolCallIds = new Map(); + const customToolCallIds = new Set(); + const closedCustomToolCallIds = new Set(); let nextToolIndex = 0; let stoppedLocally = false; @@ -1404,6 +1418,25 @@ const mapResponsesStreamToChat = ( } }; + const emitCustomToolCallClosures = ( + controller: ReadableStreamDefaultController, + ): void => { + customToolCallIds.forEach((itemId) => { + if (closedCustomToolCallIds.has(itemId)) return; + const index = getToolIndex(itemId); + const callId = toolCallIds.get(itemId) ?? `call_${index + 1}`; + controller.enqueue( + encodeChunk({ + delta: { + tool_calls: [{ function: { arguments: '"}' }, id: callId, index }], + }, + index: 0, + }), + ); + closedCustomToolCallIds.add(itemId); + }); + }; + const stream = new ReadableStream({ async pull(controller) { if (!reader) { @@ -1433,6 +1466,7 @@ const mapResponsesStreamToChat = ( ); pendingStopText = ''; } + emitCustomToolCallClosures(controller); if (!emittedFinish) { controller.enqueue( encodeChunk({ @@ -1579,14 +1613,30 @@ const mapResponsesStreamToChat = ( arguments?: unknown; call_id?: unknown; id?: unknown; + input?: unknown; name?: unknown; type?: unknown; }; - if (item.type !== 'function_call') continue; + if ( + item.type !== 'function_call' && + item.type !== 'mcp_call' && + item.type !== 'custom_tool_call' + ) { + continue; + } + const isCustomToolCall = item.type === 'custom_tool_call'; + const initialArguments = isCustomToolCall + ? `{"input":"${JSON.stringify( + String(item.input ?? item.arguments ?? ''), + ).slice(1, -1)}` + : String(item.arguments ?? ''); const itemId = String(item.id ?? item.call_id ?? nextToolIndex); const index = getToolIndex(itemId); const callId = String(item.call_id ?? item.id ?? itemId); toolCallIds.set(itemId, callId); + if (isCustomToolCall) { + customToolCallIds.add(itemId); + } hasToolCalls = true; controller.enqueue( encodeChunk({ @@ -1594,7 +1644,7 @@ const mapResponsesStreamToChat = ( tool_calls: [ { function: { - arguments: String(item.arguments ?? ''), + arguments: initialArguments, name: String(item.name ?? 'function'), }, id: callId, @@ -1609,7 +1659,11 @@ const mapResponsesStreamToChat = ( emitted = true; continue; } - if (event.type === 'response.function_call_arguments.delta') { + if ( + event.type === 'response.function_call_arguments.delta' || + event.type === 'response.mcp_call_arguments.delta' || + event.type === 'response.custom_tool_call_input.delta' + ) { const itemId = String( event.item_id ?? event.output_index ?? nextToolIndex, ); @@ -1617,12 +1671,16 @@ const mapResponsesStreamToChat = ( const callId = toolCallIds.get(itemId) ?? `call_${index + 1}`; toolCallIds.set(itemId, callId); hasToolCalls = true; + const argumentDelta = + event.type === 'response.custom_tool_call_input.delta' + ? JSON.stringify(String(event.delta ?? '')).slice(1, -1) + : String(event.delta ?? ''); controller.enqueue( encodeChunk({ delta: { tool_calls: [ { - function: { arguments: String(event.delta ?? '') }, + function: { arguments: argumentDelta }, id: callId, index, }, @@ -1644,6 +1702,7 @@ const mapResponsesStreamToChat = ( ); pendingStopText = ''; } + emitCustomToolCallClosures(controller); if (!emittedFinish) { controller.enqueue( encodeChunk({ @@ -1675,6 +1734,7 @@ const mapResponsesStreamToChat = ( ); pendingStopText = ''; } + emitCustomToolCallClosures(controller); const incompleteReason = event.response && typeof event.response === 'object' ? ( diff --git a/tests/admin/lobehub-components.test.tsx b/tests/admin/lobehub-components.test.tsx index 41a2f65..1da7983 100644 --- a/tests/admin/lobehub-components.test.tsx +++ b/tests/admin/lobehub-components.test.tsx @@ -355,4 +355,79 @@ describe('debug view', () => { expect(document.body).toHaveTextContent('Hello world'); }); }, 60_000); + + it('shows tool calls when a response has no text content', async () => { + renderWithMessages( + + + , + ); + + fireEvent.click( + screen.getByRole('button', { name: /\/v1\/chat\/completions/ }), + ); + + await waitFor(() => { + expect(document.body).toHaveTextContent('Tool call: search_docs'); + expect(document.body).toHaveTextContent('{"query":"docs"}'); + }); + expect(screen.getAllByText('No aggregate response content')).toHaveLength( + 1, + ); + }, 60_000); }); diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index f519229..414d696 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -1506,6 +1506,171 @@ describe('server units', () => { }); }); + it('maps Responses mcp and custom tool calls to Chat tool calls', async () => { + const context = createProxyContextFromCredential({ + data: { + bearer_token: 'responses-tool-types-token', + upstream_protocol: 'responses', + user_id: 'responses-tool-types@example.com', + }, + filePath: '/tmp/responses-tool-types.json', + filename: 'responses-tool-types.json', + }); + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + makeJsonResponse({ + id: 'resp_mcp', + output: [ + { + type: 'mcp_call', + call_id: 'call_mcp', + name: 'search_docs', + arguments: '{"query":"docs"}', + }, + ], + status: 'completed', + }), + ) + .mockResolvedValueOnce( + makeJsonResponse({ + id: 'resp_custom', + output: [ + { + type: 'custom_tool_call', + call_id: 'call_custom', + name: 'shell', + input: 'ls -la', + }, + ], + status: 'completed', + }), + ); + + const makeRequest = async (): Promise> => { + const response = await proxyChatCompletions( + makeNextRequest('http://localhost/v1/chat/completions', { + method: 'POST', + }), + { messages: [{ role: 'user', content: 'run tool' }], model: 'hy3' }, + context, + ); + return (await response.json()) as Record; + }; + + const mcpPayload = await makeRequest(); + const customPayload = await makeRequest(); + expect(mcpPayload.choices).toMatchObject([ + { + finish_reason: 'tool_calls', + message: { + tool_calls: [ + { + id: 'call_mcp', + function: { + arguments: '{"query":"docs"}', + name: 'search_docs', + }, + }, + ], + }, + }, + ]); + expect(customPayload.choices).toMatchObject([ + { + finish_reason: 'tool_calls', + message: { + tool_calls: [ + { + id: 'call_custom', + function: { arguments: '{"input":"ls -la"}', name: 'shell' }, + }, + ], + }, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('maps streamed Responses mcp and custom tool calls to Chat tool calls', async () => { + const context = createProxyContextFromCredential({ + data: { + bearer_token: 'responses-stream-tool-types-token', + upstream_protocol: 'responses', + user_id: 'responses-stream-tool-types@example.com', + }, + filePath: '/tmp/responses-stream-tool-types.json', + filename: 'responses-stream-tool-types.json', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + [ + 'data: {"type":"response.output_item.added","item":{"type":"message"}}', + 'data: {"type":"response.output_item.added","item":{"type":"mcp_call","id":"fc_mcp","call_id":"call_mcp","name":"search_docs","arguments":"{}"}}', + 'data: {"type":"response.mcp_call_arguments.delta","item_id":"fc_mcp","delta":"{\\"query\\":\\"docs\\"}"}', + 'data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","id":"fc_custom","call_id":"call_custom","name":"shell","arguments":"ls"}}', + 'data: {"type":"response.custom_tool_call_input.delta","item_id":"fc_custom","delta":" -la"}', + 'data: {"type":"response.completed"}', + 'data: {"type":"response.completed"}', + ].join('\n\n') + '\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + ); + + const response = await proxyChatCompletions( + makeNextRequest('http://localhost/v1/chat/completions', { + method: 'POST', + }), + { + messages: [{ role: 'user', content: 'run tools' }], + model: 'hy3', + stream: true, + }, + context, + ); + const text = await response.text(); + expect(text).toContain('"id":"call_mcp"'); + expect(text).toContain('"id":"call_custom"'); + expect(text).toContain('\\"input\\":\\"ls'); + expect(text).toContain(' -la'); + expect(text).toContain('"finish_reason":"tool_calls"'); + }); + + it('handles empty and partial streamed custom tool inputs at EOF', async () => { + const context = createProxyContextFromCredential({ + data: { + bearer_token: 'responses-stream-custom-eof-token', + upstream_protocol: 'responses', + user_id: 'responses-stream-custom-eof@example.com', + }, + filePath: '/tmp/responses-stream-custom-eof.json', + filename: 'responses-stream-custom-eof.json', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + 'data: {"type":"response.output_item.added","item":{"type":"custom_tool_call","id":"fc_empty","call_id":"call_empty","name":"shell"}}\n\n' + + 'data: {"type":"response.custom_tool_call_input.delta","item_id":"fc_empty"}\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }, + ), + ); + + const response = await proxyChatCompletions( + makeNextRequest('http://localhost/v1/chat/completions', { + method: 'POST', + }), + { + messages: [{ role: 'user', content: 'run tool' }], + model: 'hy3', + stream: true, + }, + context, + ); + const text = await response.text(); + expect(text).toContain('"id":"call_empty"'); + expect(text).toContain('\\"input\\":\\"'); + expect(text).toContain('\\"}'); + }); + it('covers Responses upstream compatibility input variants', async () => { const context = createProxyContextFromCredential({ data: {