Skip to content

Fix Codex Claude translator pending tool-call edge cases #4076

Description

@cexll

Goal

Resolve the remaining Codex auto-continuation translator review blocker wu-review-remediation-6e183da7d5a2b00a for the Claude translator.

Rationale

The mission is blocked by three contract issues in internal/translator/codex/claude/codex_claude_response.go after the latest review of wu-review-remediation-67c6892edc1c3d36:

  1. A response.output_item.done keyed only by call_id can bind to LastPendingFunctionCallKey when multiple unnamed function calls are pending, closing the wrong block.
  2. response.output_item.added for an unnamed function call increments BlockIndex before any Claude block is emitted, so the first visible terminal block can start at index 1 instead of 0.
  3. Terminal response.output arguments are dropped for an already-open named tool call if no streamed argument delta/done arrived before terminal fallback.

Project policy requires write/maintain/admin permission before making a translator-only change. gh repo view --json viewerPermission -q .viewerPermission returned READ, so I cannot apply the fix directly.

Intended implementation

Keep this scoped to internal/translator/codex/claude/codex_claude_response.go and focused tests in internal/translator/codex/claude/codex_claude_response_test.go.

1. Track pending unnamed function calls by both output key and call_id

When deferring an unnamed function_call, store one pending object under output:<output_index> and, when present, call:<call_id>. Lookup should use exact key first, then call-id alias from the done item. Avoid falling back to LastPendingFunctionCallKey for response.output_item.done when the event has a call_id that does not match.

Sketch:

func recordPendingCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) {
    if params.PendingFunctionCalls == nil {
        params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{}
    }
    pending := &pendingCodexFunctionCall{CallID: codexFunctionCallID(itemResult)}
    key := codexFunctionCallKey(rootResult, itemResult)
    params.PendingFunctionCalls[key] = pending
    if pending.CallID != "" {
        params.PendingFunctionCalls["call:"+pending.CallID] = pending
    }
    params.LastPendingFunctionCallKey = key
}

func pendingCodexFunctionCallForDone(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) (*pendingCodexFunctionCall, []string) {
    keys := []string{codexFunctionCallKey(rootResult, itemResult)}
    if callID := codexFunctionCallID(itemResult); callID != "" {
        keys = append(keys, "call:"+callID)
    } else if rootResult.Get("output_index").Exists() {
        keys = append(keys, "output:"+rootResult.Get("output_index").Raw)
    } else if params.LastPendingFunctionCallKey != "" {
        keys = append(keys, params.LastPendingFunctionCallKey)
    }
    for _, key := range keys {
        if pending, ok := params.PendingFunctionCalls[key]; ok {
            return pending, keysForPendingFunctionCall(params, pending)
        }
    }
    return nil, nil
}

Delete every alias for the matched pending object after emission.

2. Do not reserve BlockIndex for invisible deferred calls

For unnamed response.output_item.added, do not increment params.BlockIndex. When the deferred call becomes visible on named response.output_item.done, emit at the current params.BlockIndex, stop that block, then increment once. This keeps visible Claude block indexes dense and ordered.

Sketch replacement in the deferred done path:

blockIndex := params.BlockIndex
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex)
...
output = appendCodexFunctionCallStop(output, blockIndex)
params.BlockIndex++

3. Hydrate already-open tool calls from terminal output when arguments never streamed

If response.completed / response.incomplete terminal output contains a function call matching the currently open FunctionCallBlockCallID and HasReceivedArgumentsDelta is false, emit the terminal arguments delta before finalizeCodexOpenContentBlocks closes the block, or teach appendCodexTerminalFunctionCall to hydrate the open block instead of returning early on the emission key.

Sketch before closing open blocks on terminal events:

func hydrateOpenCodexFunctionCallFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, responseData gjson.Result) []byte {
    if params == nil || !params.FunctionCallBlockOpen || params.HasReceivedArgumentsDelta {
        return output
    }
    responseData.Get("output").ForEach(func(_, item gjson.Result) bool {
        if item.Get("type").String() != "function_call" || codexFunctionCallID(item) != params.FunctionCallBlockCallID {
            return true
        }
        if args := item.Get("arguments").String(); args != "" {
            output = appendCodexFunctionCallArgumentDelta(output, args, params.FunctionCallBlockIndex)
            params.HasReceivedArgumentsDelta = true
        }
        return false
    })
    return output
}

Then call it before finalizeCodexOpenContentBlocks(params) for terminal stream events.

Required tests

Add focused stream tests for:

  • Two unnamed pending function calls; done for the earlier call keyed only by call_id emits at the correct block and does not consume the later pending slot.
  • Deferred unnamed function call that never becomes visible before terminal text/fallback does not shift the first visible block index from 0 to 1.
  • Named tool call opened by response.output_item.added, no streamed argument event, terminal response.output includes final arguments; Claude receives those arguments before content_block_stop.

Focused validation should include existing translator cases covering ghost stops, incomplete close, terminal hydration, mixed output_index/call_id close, and stop-reason mapping.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions