Skip to content

BYOK Responses streaming drops apply_patch input before execution #4327

Description

@lonegunmanb

Describe the bug

When Copilot CLI runs a streamed BYOK session using an OpenAI-compatible provider with wireApi: "responses", the model can emit a complete raw input for the built-in apply_patch tool, but the CLI invokes apply_patch with an empty argument string.

The SDK event stream contains all assistant.tool_call_delta fragments needed to reconstruct a valid patch. The corresponding tool.execution_start event then reports arguments: "", and execution fails with:

apply_patch requires a non-empty string input (the patch content).

This was reproduced with both Copilot CLI 1.0.74-0 and 1.0.78-2. The standalone reproducer below uses only the official Go SDK and the Copilot CLI; it does not depend on an application framework.

Affected version

GitHub Copilot CLI 1.0.78-2
github.com/github/copilot-sdk/go v1.0.8
go version go1.26.5 windows/amd64
Windows 11 Pro 10.0.26200, amd64

Also reproduced on Copilot CLI 1.0.74-0.

Steps to reproduce the behavior

  1. Install and authenticate Copilot CLI.

  2. Create an empty directory and initialize the reproducer:

    go mod init example.com/copilot-apply-patch-repro
    go get github.com/github/copilot-sdk/go@v1.0.8
  3. Save the following as main.go:

    Standalone Go reproducer
    package main
    
    import (
        "context"
        "encoding/json"
        "fmt"
        "log"
        "os"
        "path/filepath"
        "strings"
        "time"
    
        copilot "github.com/github/copilot-sdk/go"
    )
    
    type evidence struct {
        deltaCount int
        delta      strings.Builder
        arguments  any
        callID     string
        success    bool
        toolError  string
    }
    
    func main() {
        baseURL := os.Getenv("COPILOT_REPRO_BASE_URL")
        apiKey := os.Getenv("COPILOT_REPRO_API_KEY")
        model := os.Getenv("COPILOT_REPRO_MODEL")
        if baseURL == "" || apiKey == "" || model == "" {
            log.Fatal("COPILOT_REPRO_BASE_URL, COPILOT_REPRO_API_KEY, and COPILOT_REPRO_MODEL are required")
        }
    
        wd, err := os.Getwd()
        if err != nil {
            log.Fatal(err)
        }
        target := filepath.Join(wd, "target.txt")
        if err := os.WriteFile(target, []byte("ORIGINAL_STREAMING_PROBE\n"), 0o600); err != nil {
            log.Fatal(err)
        }
    
        ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
        defer cancel()
    
        client := copilot.NewClient(nil)
        if err := client.Start(ctx); err != nil {
            log.Fatal(err)
        }
        defer client.Stop()
    
        session, err := client.CreateSession(ctx, &copilot.SessionConfig{
            Model:               model,
            ReasoningEffort:     "low",
            Streaming:           copilot.Bool(true),
            WorkingDirectory:    wd,
            AvailableTools:      []string{"apply_patch"},
            OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
            Provider: &copilot.ProviderConfig{
                Type: "openai", WireAPI: "responses", BaseURL: baseURL, APIKey: apiKey,
            },
            SkipCustomInstructions: copilot.Bool(true),
            EnableConfigDiscovery:  copilot.Bool(false),
            EnableSkills:           copilot.Bool(false),
        })
        if err != nil {
            log.Fatal(err)
        }
        defer session.Disconnect()
    
        var got evidence
        session.On(func(event copilot.SessionEvent) {
            switch data := event.Data.(type) {
            case *copilot.AssistantToolCallDeltaData:
                if data.ToolName != nil && *data.ToolName == "apply_patch" {
                    got.deltaCount++
                    got.delta.WriteString(data.InputDelta)
                }
            case *copilot.ToolExecutionStartData:
                if data.ToolName == "apply_patch" {
                    got.callID = data.ToolCallID
                    got.arguments = data.Arguments
                }
            case *copilot.ToolExecutionCompleteData:
                if data.ToolCallID == got.callID {
                    got.success = data.Success
                    if data.Error != nil {
                        got.toolError = data.Error.Message
                    }
                }
            }
        })
    
        prompt := fmt.Sprintf(`Call the built-in apply_patch tool exactly once. Do not call any other tool.
    Replace the complete line ORIGINAL_STREAMING_PROBE with PATCHED_STREAMING_PROBE in this existing file:
    %s
    Do not retry if apply_patch fails.`, filepath.ToSlash(target))
    
        if _, err := session.SendPromptAndWait(ctx, prompt); err != nil {
            log.Print(err)
        }
        args, _ := json.Marshal(got.arguments)
        content, _ := os.ReadFile(target)
        fmt.Printf("delta_count=%d\ndelta=%q\narguments=%s\nsuccess=%t\nerror=%q\ntarget=%q\n",
            got.deltaCount, got.delta.String(), args, got.success, got.toolError, content)
    }
  4. Configure any OpenAI-compatible Responses provider and run:

    $env:COPILOT_REPRO_BASE_URL = "https://your-openai-compatible-endpoint"
    $env:COPILOT_REPRO_API_KEY = "..."
    $env:COPILOT_REPRO_MODEL = "your-model"
    go run .
  5. Observe that the reconstructed delta is a valid patch while execution arguments are empty.

Actual sanitized output from 1.0.78-2:

delta_count=55
delta_length=174
delta_sha256=93917537028d251c59aedc8c2791697bf689214973906e641dfec977e3f1a15e
delta_input_json="*** Begin Patch\n*** Update File: D:\\project\\r42\\.r42\\diagnostics\\copilot-sdk-apply-patch-repro\\target.txt\n@@\n-ORIGINAL_STREAMING_PROBE\n+PATCHED_STREAMING_PROBE\n*** End Patch\n"
execution_arguments_json=""
tool_success=false
tool_error="apply_patch requires a non-empty string input (the patch content)."
target_content="ORIGINAL_STREAMING_PROBE\n"

The relevant event transition is:

assistant.tool_call_delta: 55 fragments -> complete 174-byte patch
tool.execution_start:       {"toolName":"apply_patch","arguments":""}
tool.execution_complete:    success=false, non-empty-string error

Expected behavior

The accumulated raw custom-tool input should be passed to the built-in apply_patch invocation. tool.execution_start.arguments should contain the patch, execution should succeed, and target.txt should contain PATCHED_STREAMING_PROBE.

Additional context

Control result

On the same machine and the same CLI version, a logged-in GitHub-hosted gpt-5.4 session succeeds with the same prompt, streaming enabled, and only apply_patch available:

copilot --model gpt-5.4 --stream on --available-tools=apply_patch --allow-all `
  --no-custom-instructions --no-experimental -p "<same prompt>"

The command exits 0, reports one line added and one removed, and the file contains PATCHED_STREAMING_PROBE.

Analysis

The provider/model output is not empty: the CLI has already decoded and emitted the complete raw custom-tool input through assistant.tool_call_delta. The file path and write permission are also valid, as shown by the GitHub-hosted control run. The value becomes empty between the emitted deltas and the built-in tool execution event.

This suggests a fidelity issue in the BYOK OpenAI Responses streaming path when converting a streamed custom tool call into the built-in apply_patch invocation. I cannot determine from the public artifacts whether the fix belongs in the Responses adapter or the runtime tool dispatcher, but the event boundary above should provide a narrow regression test.

I am filing a corresponding SDK issue because this is directly observable through the public Go SDK event contract. I will cross-link it here once created.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:modelsModel selection, availability, switching, rate limits, and model-specific behaviorarea:toolsBuilt-in tools: file editing, shell, search, LSP, git, and tool call behavior

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions