[codex] fix responses lifecycle parity and codex session serialization (#278)

* fix responses lifecycle parity

* remove websocket test debug logging

* fix responses proxy stream typecheck

* update compact upstream response expectations

* add community and deepwiki badges

* update linuxdo banner link

* fix responses review follow-ups

* preserve whitespace in responses terminal deltas

* fix responses stream reconciliation edges

* fix responses lifecycle parity and codex session serialization

* fix review follow-up edge cases

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
Cita
2026-03-25 18:56:36 +08:00
committed by GitHub
parent 9ba4369a83
commit 639c387532
13 changed files with 777 additions and 67 deletions
@@ -437,6 +437,67 @@ describe('sanitizeResponsesBodyForProxy', () => {
},
]);
});
it('drops blank tool calls before proxying Responses requests while preserving follow-up tool outputs', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: [
{
type: 'function_call',
call_id: 'call_blank',
name: ' ',
arguments: '{"city":"Shanghai"}',
},
{
type: 'function_call_output',
call_id: 'call_blank',
output: '{"temp":22}',
},
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'continue normally',
},
],
},
{
type: 'function_call_output',
call_id: 'call_missing',
output: 'orphaned output',
},
],
},
'gpt-5',
false,
);
expect(result.input).toEqual([
{
type: 'function_call_output',
call_id: 'call_blank',
output: '{"temp":22}',
},
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'continue normally',
},
],
},
{
type: 'function_call_output',
call_id: 'call_missing',
output: 'orphaned output',
},
]);
});
});
describe('convertOpenAiBodyToResponsesBody', () => {
@@ -763,6 +824,45 @@ describe('convertOpenAiBodyToResponsesBody', () => {
]);
});
it('drops tool outputs that do not have a matching prior tool call when converting to OpenAI-compatible messages', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: [
{
type: 'function_call_output',
call_id: 'call_missing',
output: 'orphaned tool result',
},
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'continue normally',
},
],
},
],
},
'gpt-5',
false,
);
expect(result.messages).toEqual([
{
role: 'assistant',
content: [
{
type: 'text',
text: 'continue normally',
},
],
},
]);
});
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => {
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
@@ -840,6 +940,63 @@ describe('convertOpenAiBodyToResponsesBody', () => {
name: toolNames[0],
});
});
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
type: 'function',
function: {
name: ' ',
parameters: { type: 'object' },
},
},
{
type: 'function',
function: {
name: 'lookup_weather',
parameters: { type: 'object' },
},
},
],
},
'gpt-5',
false,
);
expect(result.tools).toEqual([
{
type: 'function',
name: 'lookup_weather',
parameters: { type: 'object' },
},
]);
});
it('drops all-invalid function tools instead of forwarding blank names into Responses bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
tools: [
{
type: 'function',
function: {
name: ' ',
parameters: { type: 'object' },
},
},
],
},
'gpt-5',
false,
);
expect(result.tools).toEqual([]);
});
});
describe('convertResponsesBodyToOpenAiBody', () => {
@@ -881,6 +1038,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
file: {
filename: 'paper.pdf',
file_data: Buffer.from('%PDF-demo').toString('base64'),
mime_type: 'application/pdf',
},
},
],
@@ -1461,6 +1619,31 @@ describe('convertResponsesBodyToOpenAiBody', () => {
});
});
it('drops blank native function tools and tool choices when converting to OpenAI-compatible fallback bodies', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
tools: [{
type: 'function',
name: ' ',
parameters: {
type: 'object',
},
}],
tool_choice: {
type: 'tool',
name: ' ',
},
},
'gpt-5',
false,
);
expect(result.tools).toEqual([]);
expect(result.tool_choice).toBeUndefined();
});
it('derives Responses reasoning config from chat-style reasoning request fields', () => {
const result = convertOpenAiBodyToResponsesBody(
{
@@ -352,7 +352,7 @@ function convertOpenAiToolsToResponses(
})
.filter((item): item is Record<string, unknown> => !!item);
return converted.length > 0 ? converted : rawTools;
return converted;
}
function convertOpenAiToolChoiceToResponses(
@@ -384,6 +384,49 @@ function normalizeResponsesBodyForCompatibility(
};
}
const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set([
'function_call',
'custom_tool_call',
]);
const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set([
'function_call_output',
'custom_tool_call_output',
]);
function stripOrphanedResponsesToolOutputs(input: unknown): unknown {
if (!Array.isArray(input)) return input;
const seenToolCallIds = new Set<string>();
const sanitized: unknown[] = [];
for (const item of input) {
if (!isRecord(item)) {
sanitized.push(item);
continue;
}
const type = asTrimmedString(item.type).toLowerCase();
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
const callId = asTrimmedString(item.call_id ?? item.id);
if (callId) seenToolCallIds.add(callId);
sanitized.push(item);
continue;
}
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
const callId = asTrimmedString(item.call_id ?? item.id);
if (!callId || !seenToolCallIds.has(callId)) continue;
sanitized.push(item);
continue;
}
sanitized.push(item);
}
return sanitized;
}
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
return normalizeResponsesMessageContentBlocks(role, content);
}
@@ -614,6 +657,9 @@ export function convertOpenAiBodyToResponsesBody(
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
if (Array.isArray(body.tools) && body.tools.length === 0) {
delete body.tool_choice;
}
return normalizeResponsesBodyForCompatibility(
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
@@ -723,28 +769,30 @@ function toOpenAiMessageContent(content: unknown): string | Array<string | Recor
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
if (!Array.isArray(rawTools)) return rawTools;
return rawTools.map((item) => {
if (!isRecord(item)) return item;
const type = asTrimmedString(item.type).toLowerCase();
return rawTools
.map((item) => {
if (!isRecord(item)) return item;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'custom' || type === 'image_generation') return item;
if (type !== 'function') return item;
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
if (type === 'custom' || type === 'image_generation') return item;
if (type !== 'function') return item;
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
const name = asTrimmedString(item.name);
if (!name) return item;
const name = asTrimmedString(item.name);
if (!name) return null;
const fn: Record<string, unknown> = { name };
const description = asTrimmedString(item.description);
if (description) fn.description = description;
if (item.parameters !== undefined) fn.parameters = item.parameters;
if (item.strict !== undefined) fn.strict = item.strict;
const fn: Record<string, unknown> = { name };
const description = asTrimmedString(item.description);
if (description) fn.description = description;
if (item.parameters !== undefined) fn.parameters = item.parameters;
if (item.strict !== undefined) fn.strict = item.strict;
return {
type: 'function',
function: fn,
};
});
return {
type: 'function',
function: fn,
};
})
.filter((item): item is Record<string, unknown> => !!item);
}
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
@@ -787,11 +835,13 @@ export function convertResponsesBodyToOpenAiBody(
stream: boolean,
options?: { defaultEncryptedReasoningInclude?: boolean },
): Record<string, unknown> {
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const normalizedBody = normalizeResponsesBodyForCompatibility(
normalizeResponsesRequestFieldParity(body, {
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
}),
);
const messages: Array<Record<string, unknown>> = [];
const input = normalizedBody.input;
const input = stripOrphanedResponsesToolOutputs(normalizedBody.input);
let functionCallIndex = 0;
let pendingToolCalls: OpenAiToolCall[] = [];
@@ -928,6 +978,9 @@ export function convertResponsesBodyToOpenAiBody(
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
if (Array.isArray(payload.tools) && payload.tools.length === 0) {
delete payload.tool_choice;
}
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
@@ -8,6 +8,16 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
const RESPONSES_TOOL_CALL_ITEM_TYPES = new Set([
'function_call',
'custom_tool_call',
]);
const RESPONSES_TOOL_OUTPUT_ITEM_TYPES = new Set([
'function_call_output',
'custom_tool_call_output',
]);
const ALLOWED_RESPONSES_INPUT_STATUSES = new Set([
'in_progress',
'completed',
@@ -135,7 +145,7 @@ function normalizeResponsesContentItem(
return fileBlock ? toResponsesInputFileBlock(fileBlock) : null;
}
if (type === 'function_call' || type === 'function_call_output') {
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
return item;
}
@@ -166,10 +176,71 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
};
}
function normalizeResponsesToolLifecycleItem(item: Record<string, unknown>): Record<string, unknown> | null {
const type = asTrimmedString(item.type).toLowerCase();
if (!RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) && !RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
return withNormalizedResponsesInputStatus(item);
}
const callId = asTrimmedString(item.call_id ?? item.id);
if (!callId) return null;
const normalized: Record<string, unknown> = {
...item,
call_id: callId,
};
if (typeof item.id === 'string') {
const id = asTrimmedString(item.id);
if (id) normalized.id = id;
else delete normalized.id;
}
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
if (Object.prototype.hasOwnProperty.call(item, 'name')) {
const name = asTrimmedString(item.name);
if (!name) return null;
normalized.name = name;
}
}
return withNormalizedResponsesInputStatus(normalized);
}
function sanitizeResponsesInputToolLifecycle(items: unknown[]): unknown[] {
const sanitized: unknown[] = [];
for (const item of items) {
if (!isRecord(item)) {
sanitized.push(item);
continue;
}
const type = asTrimmedString(item.type).toLowerCase();
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
const normalized = normalizeResponsesToolLifecycleItem(item);
if (!normalized) continue;
sanitized.push(normalized);
continue;
}
if (RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
const normalized = normalizeResponsesToolLifecycleItem(item);
if (!normalized) continue;
sanitized.push(normalized);
continue;
}
sanitized.push(item);
}
return sanitized;
}
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'function_call' || type === 'function_call_output') {
return withNormalizedResponsesInputStatus(item);
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
return normalizeResponsesToolLifecycleItem(item) ?? item;
}
const role = asTrimmedString(item.role).toLowerCase() || 'user';
@@ -211,7 +282,7 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
}
if (Array.isArray(input)) {
return input.flatMap((item) => {
const normalized = input.flatMap((item) => {
if (typeof item === 'string') {
const normalized = item.trim();
return normalized ? [toResponsesInputMessageFromText(normalized)] : [];
@@ -219,10 +290,11 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
if (!isRecord(item)) return [item];
return [normalizeResponsesMessageItem(item)];
});
return sanitizeResponsesInputToolLifecycle(normalized);
}
if (isRecord(input)) {
return [normalizeResponsesMessageItem(input)];
return sanitizeResponsesInputToolLifecycle([normalizeResponsesMessageItem(input)]);
}
return input;