From e811e5b77c5d70712fb3265c503bfaf788ef3184 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 4 Sep 2026 16:33:24 +0800 Subject: [PATCH 1/4] fix: preserve non-function responses tool calls --- lib/server/proxy/codebuddy.ts | 63 +++++++++++++++-- tests/server/units.test.ts | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 2b6d6ac..9dd5835 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,7 @@ const mapResponsesStreamToChat = ( let pendingStopText = ''; const toolIndexes = new Map(); const toolCallIds = new Map(); + const customToolCallIds = new Set(); let nextToolIndex = 0; let stoppedLocally = false; @@ -1579,14 +1592,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 = String( + isCustomToolCall + ? `{"input":${JSON.stringify(String(item.input ?? item.arguments ?? ''))}` + : (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 +1623,7 @@ const mapResponsesStreamToChat = ( tool_calls: [ { function: { - arguments: String(item.arguments ?? ''), + arguments: initialArguments, name: String(item.name ?? 'function'), }, id: callId, @@ -1609,7 +1638,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 +1650,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, }, @@ -1645,6 +1682,20 @@ const mapResponsesStreamToChat = ( pendingStopText = ''; } if (!emittedFinish) { + customToolCallIds.forEach((itemId) => { + 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, + }), + ); + }); controller.enqueue( encodeChunk({ delta: {}, diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index f519229..edd3e54 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -1506,6 +1506,134 @@ 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":"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","input":"ls"}}', + 'data: {"type":"response.custom_tool_call_input.delta","item_id":"fc_custom","delta":" -la"}', + '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('covers Responses upstream compatibility input variants', async () => { const context = createProxyContextFromCredential({ data: { From ecac6f9b30921b7401a4f6a433baaccbc770b25f Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 4 Sep 2026 16:47:35 +0800 Subject: [PATCH 2/4] fix: show tool calls in debug responses --- app/debug/debug.tsx | 42 +++++++++++++- lib/server/proxy/codebuddy.ts | 47 +++++++++------- tests/admin/lobehub-components.test.tsx | 75 +++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 20 deletions(-) diff --git a/app/debug/debug.tsx b/app/debug/debug.tsx index e2d11f8..01d31ae 100644 --- a/app/debug/debug.tsx +++ b/app/debug/debug.tsx @@ -250,6 +250,43 @@ 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']) { + 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 +745,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/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 9dd5835..26c1a11 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -1339,6 +1339,7 @@ const mapResponsesStreamToChat = ( const toolIndexes = new Map(); const toolCallIds = new Map(); const customToolCallIds = new Set(); + const closedCustomToolCallIds = new Set(); let nextToolIndex = 0; let stoppedLocally = false; @@ -1417,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) { @@ -1446,6 +1466,7 @@ const mapResponsesStreamToChat = ( ); pendingStopText = ''; } + emitCustomToolCallClosures(controller); if (!emittedFinish) { controller.enqueue( encodeChunk({ @@ -1604,11 +1625,11 @@ const mapResponsesStreamToChat = ( continue; } const isCustomToolCall = item.type === 'custom_tool_call'; - const initialArguments = String( - isCustomToolCall - ? `{"input":${JSON.stringify(String(item.input ?? item.arguments ?? ''))}` - : (item.arguments ?? ''), - ); + 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); @@ -1682,20 +1703,7 @@ const mapResponsesStreamToChat = ( pendingStopText = ''; } if (!emittedFinish) { - customToolCallIds.forEach((itemId) => { - 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, - }), - ); - }); + emitCustomToolCallClosures(controller); controller.enqueue( encodeChunk({ delta: {}, @@ -1726,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); }); From a5e108ff17a9010764a005f1905c03f8dbef962b Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 4 Sep 2026 17:00:34 +0800 Subject: [PATCH 3/4] fix: address tool stream review feedback --- app/debug/debug.tsx | 9 +++++++- lib/server/proxy/codebuddy.ts | 2 +- tests/server/units.test.ts | 39 ++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/debug/debug.tsx b/app/debug/debug.tsx index 01d31ae..4eca9e7 100644 --- a/app/debug/debug.tsx +++ b/app/debug/debug.tsx @@ -279,7 +279,14 @@ const getToolCallText = (value: unknown): string | null => { : `Tool call: ${label} ${String(argumentsValue)}`; } - for (const key of ['tool_calls', 'output', 'message']) { + for (const key of [ + 'tool_calls', + 'output', + 'message', + 'item', + 'response', + 'delta', + ]) { const text = getToolCallText(value[key]); if (text) return text; } diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 26c1a11..7491f93 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -1702,8 +1702,8 @@ const mapResponsesStreamToChat = ( ); pendingStopText = ''; } + emitCustomToolCallClosures(controller); if (!emittedFinish) { - emitCustomToolCallClosures(controller); controller.enqueue( encodeChunk({ delta: {}, diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts index edd3e54..414d696 100644 --- a/tests/server/units.test.ts +++ b/tests/server/units.test.ts @@ -1605,11 +1605,13 @@ describe('server units', () => { 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","input":"ls"}}', + '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' } }, ), @@ -1634,6 +1636,41 @@ describe('server units', () => { 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: { From 8dabf1a18115fd0ab4903a620d204e6342f5b63a Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Fri, 4 Sep 2026 17:07:34 +0800 Subject: [PATCH 4/4] test: stabilize account status clipboard e2e --- e2e/account-status.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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: {