Skip to content
Merged
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
49 changes: 48 additions & 1 deletion app/debug/debug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions e2e/account-status.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
72 changes: 66 additions & 6 deletions lib/server/proxy/codebuddy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,11 +1220,23 @@ const mapResponsesPayloadToChat = (
const toolCalls = output.flatMap((item) => {
if (!item || typeof item !== 'object') return [];
const value = item as Record<string, unknown>;
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()),
Expand Down Expand Up @@ -1326,6 +1338,8 @@ const mapResponsesStreamToChat = (
let pendingStopText = '';
const toolIndexes = new Map<string, number>();
const toolCallIds = new Map<string, string>();
const customToolCallIds = new Set<string>();
const closedCustomToolCallIds = new Set<string>();
let nextToolIndex = 0;
let stoppedLocally = false;

Expand Down Expand Up @@ -1404,6 +1418,25 @@ const mapResponsesStreamToChat = (
}
};

const emitCustomToolCallClosures = (
controller: ReadableStreamDefaultController<Uint8Array>,
): 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<Uint8Array>({
async pull(controller) {
if (!reader) {
Expand Down Expand Up @@ -1433,6 +1466,7 @@ const mapResponsesStreamToChat = (
);
pendingStopText = '';
}
emitCustomToolCallClosures(controller);
if (!emittedFinish) {
controller.enqueue(
encodeChunk({
Expand Down Expand Up @@ -1579,22 +1613,38 @@ 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({
delta: {
tool_calls: [
{
function: {
arguments: String(item.arguments ?? ''),
arguments: initialArguments,
name: String(item.name ?? 'function'),
},
id: callId,
Expand All @@ -1609,20 +1659,28 @@ 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,
);
const index = getToolIndex(itemId);
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,
},
Expand All @@ -1644,6 +1702,7 @@ const mapResponsesStreamToChat = (
);
pendingStopText = '';
}
emitCustomToolCallClosures(controller);
if (!emittedFinish) {
controller.enqueue(
encodeChunk({
Expand Down Expand Up @@ -1675,6 +1734,7 @@ const mapResponsesStreamToChat = (
);
pendingStopText = '';
}
emitCustomToolCallClosures(controller);
const incompleteReason =
event.response && typeof event.response === 'object'
? (
Expand Down
75 changes: 75 additions & 0 deletions tests/admin/lobehub-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DebugProvider
value={{
autoRefreshOptions: [],
debug: {
autoRefreshSeconds: 0,
detailLoadedIds: { tool: true },
detailLoadingIds: {},
enabled: true,
items: [
{
credentialFilename: null,
createdAt: '2026-07-18T00:00:00.000Z',
elapsedMs: null,
error: null,
id: 'tool',
requestBody: {},
requestKey: null,
route: '/v1/chat/completions',
transformedResponse: null,
upstreamRequest: null,
upstreamResponse: {
body: JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
arguments: '{"query":"docs"}',
name: 'search_docs',
},
id: 'call_docs',
type: 'function',
},
],
},
},
],
}),
status: 200,
},
},
],
loading: false,
maxEntries: 100,
saving: false,
},
onAutoRefreshSecondsChange: vi.fn(),
onClear: vi.fn(),
onCopy: vi.fn(),
onEnabledChange: vi.fn(),
onMaxEntriesChange: vi.fn(),
onRefresh: vi.fn(),
onSave: vi.fn(),
}}
>
<Debug />
</DebugProvider>,
);

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);
});
Loading
Loading