diff --git a/src/bridge.ts b/src/bridge.ts index dbd6b7d06f..b04bbb25ec 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -627,6 +627,7 @@ export function bridgeToResponsesSSE( const argsStr = coerceIntegerToolArguments( currentToolCall.args || "{}", options?.toolParameterSchemas?.get(currentToolCall.name), + currentToolCall.namespace === undefined ? currentToolCall.name : undefined, ); // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use). if (!currentToolCall.freeform && !currentToolCall.toolSearch) { @@ -1659,6 +1660,7 @@ function buildResponseJSONWithBudget( const coercedArgs = coerceIntegerToolArguments( currentToolCallArgs, options?.toolParameterSchemas?.get(currentToolCallName), + ns === undefined ? realName : undefined, ); // Freeform tools serialize as custom_tool_call without extra_content; remember the // signature server-side regardless so the replayed call can be re-signed (#1735). diff --git a/src/lib/tool-argument-integers.ts b/src/lib/tool-argument-integers.ts index 6724fa002c..7b3204d40c 100644 --- a/src/lib/tool-argument-integers.ts +++ b/src/lib/tool-argument-integers.ts @@ -77,6 +77,13 @@ function declaresString(schema: SchemaNode): boolean { // against it. It gets its own change when it gets its own reproduction. const U64_NUMBER_FIELDS = new Set(["timeout_ms"]); +// Issue #2443: Codex Desktop's bare `wait` tool has the same schema/runtime split +// for `yield-time_ms` and `max_tokens`. Scope these names to that bare tool so a +// third-party or namespaced tool can still use fractional values legitimately. +const U64_NUMBER_FIELDS_BY_TOOL = new Map>([ + ["wait", new Set(["yield-time_ms", "max_tokens"])], +]); + /** True when the node accepts a JSON number (`integer` or `number`), so a numeric * value is already schema-valid and must not be rewritten into a string. */ function declaresNumeric(schema: SchemaNode): boolean { @@ -141,6 +148,7 @@ function coerceValue( schema: SchemaNode | undefined, root: SchemaNode, depth: number, + toolName?: string, propertyName?: string, ): CoerceResult { // A hostile or deeply nested schema must not blow the stack. @@ -153,8 +161,12 @@ function coerceValue( // #2316: a known Codex-native u64 field counts as integer-declared even when the // advertised schema says `number`, but only when the field really is numeric — // an allowlisted name over a string-typed field is a disagreement, not a repair. - const nativeU64Declared = propertyName !== undefined - && U64_NUMBER_FIELDS.has(propertyName) + const nativeU64Field = propertyName !== undefined + && ( + U64_NUMBER_FIELDS.has(propertyName) + || U64_NUMBER_FIELDS_BY_TOOL.get(toolName ?? "")?.has(propertyName) === true + ); + const nativeU64Declared = nativeU64Field && (declaresNumeric(resolved) || branches.some(declaresNumeric)); const integerDeclared = declaresInteger(resolved) || branches.some(declaresInteger) @@ -182,7 +194,7 @@ function coerceValue( const next = value.map(entry => { // Array items have no property name of their own; passing the array's key would // let `timeout_ms: [1.5]` inherit the allowlist. Items are judged by schema only. - const result = coerceValue(entry, itemSchema, root, depth + 1); + const result = coerceValue(entry, itemSchema, root, depth + 1, toolName); if (result.changed) changed = true; return result.value; }); @@ -198,7 +210,7 @@ function coerceValue( const next: Record = {}; for (const [key, entry] of Object.entries(object)) { const childSchema = asSchema(properties?.[key]) ?? additional; - const result = coerceValue(entry, childSchema, root, depth + 1, key); + const result = coerceValue(entry, childSchema, root, depth + 1, toolName, key); if (result.changed) changed = true; next[key] = result.value; } @@ -216,6 +228,7 @@ function coerceValue( export function coerceIntegerToolArguments( args: string, parameters: Record | undefined, + toolName?: string, ): string { if (!parameters || !args) return args; // Cheap reject: a payload with no digit cannot need either repair (integral-float @@ -230,7 +243,7 @@ export function coerceIntegerToolArguments( return args; } const root = parameters as SchemaNode; - const result = coerceValue(parsed, root, root, 0); + const result = coerceValue(parsed, root, root, 0, toolName); if (!result.changed) return args; return JSON.stringify(result.value); } diff --git a/tests/tool-argument-integers.test.ts b/tests/tool-argument-integers.test.ts index b09c782dc1..9ed89da444 100644 --- a/tests/tool-argument-integers.test.ts +++ b/tests/tool-argument-integers.test.ts @@ -325,14 +325,15 @@ describe("native u64 fields advertised as number (#2316)", () => { .toBe('{"timeout_ms":"120000"}'); }); - test("a field NOT on the allowlist keeps its float even when integral", () => { - // Deliberate scope proof: only names with a captured u64 rejection are repaired. + test("Cursor's sibling field stays unchanged even with wait identity", () => { + // Cursor uses yield_time_ms (underscore), not wait's yield-time_ms (hyphen). + // Keep this explicit scope proof from #2316: field names are never broadened globally. const others = { type: "object", properties: { yield_time_ms: { type: "number" }, priority: { type: "number" } }, }; const raw = '{"yield_time_ms":60000.0,"priority":2.0}'; - expect(coerceIntegerToolArguments(raw, others)).toBe(raw); + expect(coerceIntegerToolArguments(raw, others, "wait")).toBe(raw); }); test("the namespaced wait_agent call is repaired through the real bridge", async () => { @@ -363,3 +364,77 @@ describe("native u64 fields advertised as number (#2316)", () => { }); }); +const CODEX_DESKTOP_WAIT_SCHEMA = { + type: "object", + properties: { + "yield-time_ms": { type: "number" }, + max_tokens: { type: "number" }, + }, +}; + +const WAIT_SCOPE_SCHEMAS = new Map>([ + ["wait", CODEX_DESKTOP_WAIT_SCHEMA], + ["other_tool", CODEX_DESKTOP_WAIT_SCHEMA], + ["cursor_wait", CODEX_DESKTOP_WAIT_SCHEMA], +]); + +const WAIT_SCOPE_NAMESPACE_MAP = new Map([ + ["cursor_wait", { namespace: "cursor", name: "wait" }], +]); + +const WAIT_SCOPE_EVENTS: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_wait", name: "wait" }, + { type: "tool_call_delta", arguments: '{"yield-time_ms":120000.0,"max_tokens":8000.0}' }, + { type: "tool_call_end", id: "call_wait" }, + { type: "tool_call_start", id: "call_fractional", name: "wait" }, + { type: "tool_call_delta", arguments: '{"yield-time_ms":1.5,"max_tokens":1.5}' }, + { type: "tool_call_end", id: "call_fractional" }, + { type: "tool_call_start", id: "call_other", name: "other_tool" }, + { type: "tool_call_delta", arguments: '{"yield-time_ms":120000.0,"max_tokens":8000.0}' }, + { type: "tool_call_end", id: "call_other" }, + { type: "tool_call_start", id: "call_namespaced", name: "cursor_wait" }, + { type: "tool_call_delta", arguments: '{"yield-time_ms":120000.0,"max_tokens":8000.0}' }, + { type: "tool_call_end", id: "call_namespaced" }, + { type: "done" }, +]; + +describe("Codex Desktop wait native integers (#2443)", () => { + const expectedCalls = [ + { name: "wait", namespace: undefined, arguments: '{"yield-time_ms":120000,"max_tokens":8000}' }, + { name: "wait", namespace: undefined, arguments: '{"yield-time_ms":1.5,"max_tokens":1.5}' }, + { name: "other_tool", namespace: undefined, arguments: '{"yield-time_ms":120000.0,"max_tokens":8000.0}' }, + { name: "wait", namespace: "cursor", arguments: '{"yield-time_ms":120000.0,"max_tokens":8000.0}' }, + ]; + + test("streaming bridge scopes the repair to the bare wait tool", async () => { + const frames = await collectSse(bridgeToResponsesSSE( + replay(WAIT_SCOPE_EVENTS), + "grok-4.6", + WAIT_SCOPE_NAMESPACE_MAP, + undefined, + undefined, + undefined, + 2_000, + { toolParameterSchemas: WAIT_SCOPE_SCHEMAS }, + )); + const calls = frames + .filter(frame => frame.event === "response.output_item.done") + .map(frame => frame.data.item as Record) + .filter(item => item.type === "function_call") + .map(item => ({ name: item.name, namespace: item.namespace, arguments: item.arguments })); + + expect(calls).toEqual(expectedCalls); + }); + + test("non-streaming bridge scopes the repair to the bare wait tool", () => { + const body = buildResponseJSON(WAIT_SCOPE_EVENTS, "grok-4.6", { + toolNsMap: WAIT_SCOPE_NAMESPACE_MAP, + toolParameterSchemas: WAIT_SCOPE_SCHEMAS, + }) as Record; + const calls = (body.output as Record[]) + .filter(item => item.type === "function_call") + .map(item => ({ name: item.name, namespace: item.namespace, arguments: item.arguments })); + + expect(calls).toEqual(expectedCalls); + }); +});