fix(usage): keep streamed call args in responses completion

The response.completed fallback rebuilt every call item with responsesCallInput(), which returns '{}' for a function_call lacking arguments. Since '{}' is truthy, ensureToolCall overwrote arguments already collected from streamed delta events. Guard the completed branch with responsesCallHasInput (matching the output_item.done branch) so empty/default inputs no longer clobber streamed args, and align its dedupe key with the streaming phase to avoid duplicate tool-call rendering when an item has no id. Drop the now-dead '工具调用' fallbacks since responsesCallName never returns empty.
This commit is contained in:
stabey
2026-06-04 13:30:20 +08:00
committed by stabey
parent 2de2a792f6
commit 9f19ca5754
2 changed files with 52 additions and 10 deletions
@@ -243,6 +243,45 @@ describe('Conversation stream compatibility', () => {
}) })
}) })
it('keeps streamed function_call arguments when response.completed omits them', () => {
const requestBody = {
model: 'gpt-5.5',
stream: true,
input: 'What is the weather?',
}
const rawSse = [
'event: response.created',
`data: ${JSON.stringify({ type: 'response.created', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'in_progress' } })}`,
'',
'event: response.output_item.added',
`data: ${JSON.stringify({ type: 'response.output_item.added', output_index: 0, item: { id: 'fc_1', type: 'function_call', status: 'in_progress', call_id: 'call_1', name: 'get_weather', arguments: '' } })}`,
'',
'event: response.function_call_arguments.delta',
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '{"city":' })}`,
'',
'event: response.function_call_arguments.delta',
`data: ${JSON.stringify({ type: 'response.function_call_arguments.delta', output_index: 0, item_id: 'fc_1', delta: '"SF"}' })}`,
'',
// 最终项故意不带 arguments:解析器不应用 '{}' 冲掉已收集的增量参数
'event: response.completed',
`data: ${JSON.stringify({ type: 'response.completed', response: { id: 'resp_fc_1', object: 'response', model: 'gpt-5.5', status: 'completed', output: [{ id: 'fc_1', type: 'function_call', status: 'completed', call_id: 'call_1', name: 'get_weather' }] } })}`,
'',
'data: [DONE]',
'',
].join('\n')
const parsed = parseResponse(rawSse, requestBody, 'openai:responses')
// 命中同一 key,不重复渲染
expect(parsed.messages).toHaveLength(1)
expect(parsed.messages[0]?.content).toHaveLength(1)
expect(parsed.messages[0]?.content[0]).toMatchObject({
type: 'tool_use',
toolName: 'get_weather',
toolId: 'call_1',
input: '{"city":"SF"}',
})
})
it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => { it('renders HTML-entity encoded OpenAI tool arguments as formatted JSON', () => {
const requestBody = { const requestBody = {
model: 'gpt-5.4', model: 'gpt-5.4',
@@ -698,8 +698,9 @@ export class OpenAIParser implements ApiFormatParser {
// 从 output 中提取文本和工具调用(备用方案) // 从 output 中提取文本和工具调用(备用方案)
if (Array.isArray(response?.output)) { if (Array.isArray(response?.output)) {
for (const rawItem of response.output as unknown[]) { const output = response.output as unknown[]
const item = rawItem as RawObject for (let index = 0; index < output.length; index++) {
const item = output[index] as RawObject
if (textParts.length === 0 && item?.type === 'message' && Array.isArray(item?.content)) { if (textParts.length === 0 && item?.type === 'message' && Array.isArray(item?.content)) {
for (const rawContent of item.content as unknown[]) { for (const rawContent of item.content as unknown[]) {
const content = rawContent as RawObject const content = rawContent as RawObject
@@ -710,12 +711,14 @@ export class OpenAIParser implements ApiFormatParser {
} else if (this.isResponsesCallItemType(item.type)) { } else if (this.isResponsesCallItemType(item.type)) {
const itemId = typeof item.id === 'string' ? item.id : '' const itemId = typeof item.id === 'string' ? item.id : ''
const toolId = this.responsesCallId(item) const toolId = this.responsesCallId(item)
ensureToolCall( // 与流式阶段使用同一套 key 命中同一条工具调用,避免重复渲染
itemId || toolId, const key = itemId || toolId || outputIndexToToolKey.get(index) || String(index)
toolId, // 仅在最终项确实带有输入时才覆盖,避免用 '{}' 等默认值
this.responsesCallName(item), // 冲掉已通过增量事件收集到的参数
this.responsesCallInput(item) const input = this.responsesCallHasInput(item)
) ? this.responsesCallInput(item)
: ''
ensureToolCall(key, toolId, this.responsesCallName(item), input)
} }
} }
} }
@@ -1007,7 +1010,7 @@ export class OpenAIParser implements ApiFormatParser {
// Responses API call item -> 工具调用 // Responses API call item -> 工具调用
if (this.isResponsesCallItemType(itemType)) { if (this.isResponsesCallItemType(itemType)) {
const toolName = this.responsesCallName(item) || '工具调用' const toolName = this.responsesCallName(item)
const args = this.formatJson(this.responsesCallInput(item)) const args = this.formatJson(this.responsesCallInput(item))
return createMessageBlock('assistant', [ return createMessageBlock('assistant', [
createToolUseRenderBlock(toolName, args, this.responsesCallId(item)), createToolUseRenderBlock(toolName, args, this.responsesCallId(item)),
@@ -1136,7 +1139,7 @@ export class OpenAIParser implements ApiFormatParser {
} else if (this.isResponsesCallItemType(item.type)) { } else if (this.isResponsesCallItemType(item.type)) {
blocks.push(createMessageBlock('assistant', [ blocks.push(createMessageBlock('assistant', [
createToolUseRenderBlock( createToolUseRenderBlock(
this.responsesCallName(item) || '工具调用', this.responsesCallName(item),
this.formatJson(this.responsesCallInput(item)), this.formatJson(this.responsesCallInput(item)),
this.responsesCallId(item) this.responsesCallId(item)
), ),