Compare commits

...
Author SHA1 Message Date
oniisanlabsandClaude Fable 5.1 58ebf5a6de fix(codex): forward tool input that only arrives whole on an output item
The Codex Responses handler kept arguments that arrived only on
response.output_item.done (and custom tool input that arrived on
response.output_item.added) in the builder for history replay, but never
emitted them as tool-call deltas, so the runtime executed the call with an
empty argument string ("path is required").

Route every builder write through one forwardInput helper that also emits
the delta, so the builder holds exactly what the runtime has received. The
added handler forwards whole custom input, the delta handler forwards each
fragment, and the done handler forwards the final input only when nothing
reached the runtime yet, so streamed fragments are never duplicated.

Handler-level coverage spans function and custom calls, with and without
the added item, streamed or final-only, plus custom input on the added
item; a second test drives the same shapes through StreamCompletion and
zeroruntime.CollectStream. Against the unfixed handler the final-only and
input-on-added cases report an empty argument string at tool-call-end.

Closes #1194

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-11-09 14:57:36 -08:00
2 changed files with 177 additions and 19 deletions

No files matched your search

+38 -19
View File
@@ -212,15 +212,34 @@ type errorPayload struct {
// response.output_item.added, the argument deltas, and the final
// response.output_item.done.
type toolCallBuilder struct {
ID string
CallID string
Name string
ID string
CallID string
Name string
// Arguments holds exactly the input already forwarded to the runtime as
// tool-call deltas: every write goes through forwardInput, so an empty
// builder means the runtime has received nothing for this call yet. The
// done handler relies on that to forward input that only arrived whole
// (on the added or done item) without duplicating streamed fragments.
Arguments strings.Builder
Freeform bool
started bool
ended bool
}
// forwardInput appends fragment to the builder and emits the matching
// tool-call delta, keeping the builder and the runtime in step.
func (builder *toolCallBuilder) forwardInput(ctx context.Context, events chan<- zeroruntime.StreamEvent, fragment string) {
if fragment == "" {
return
}
builder.Arguments.WriteString(fragment)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: builder.ID,
ArgumentsFragment: fragment,
})
}
// responsesState tracks in-flight tool calls and whether the terminal
// event has been emitted. It is the Responses-API equivalent of the
// chat-completions toolState in provider.go.
@@ -726,9 +745,6 @@ func (p *CodexProvider) handleOutputItemAdded(
builder.Name = event.Item.Name
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Input)
}
state.emitted = true
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
@@ -740,6 +756,11 @@ func (p *CodexProvider) handleOutputItemAdded(
})
builder.started = true
}
// A custom tool call may arrive with its whole input on the added item
// instead of input deltas; the runtime needs it as a delta either way.
if builder.Freeform && builder.Arguments.Len() == 0 {
builder.forwardInput(ctx, events, event.Item.Input)
}
}
}
@@ -773,13 +794,8 @@ func (p *CodexProvider) handleToolInputDelta(
if event.CallID != "" && builder.CallID == "" {
builder.CallID = event.CallID
}
builder.Arguments.WriteString(event.Delta)
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: key,
ArgumentsFragment: event.Delta,
})
builder.forwardInput(ctx, events, event.Delta)
}
// handleOutputItemDone finalizes a tool call when the item type is
@@ -813,13 +829,6 @@ func (p *CodexProvider) handleOutputItemDone(
builder.CallID = event.Item.CallID
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
if finalInput != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(finalInput)
}
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
@@ -830,6 +839,16 @@ func (p *CodexProvider) handleOutputItemDone(
})
builder.started = true
}
// The done item repeats the complete input. When nothing reached the
// runtime through deltas (or the added item), this is the only copy, so
// forward it before closing the call; otherwise the streamed copy stands.
if builder.Arguments.Len() == 0 {
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
builder.forwardInput(ctx, events, finalInput)
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallEnd,
ToolCallID: key,
+139
View File
@@ -903,6 +903,145 @@ func TestCodexProviderParsesResponsesToolCalls(t *testing.T) {
}
}
// The runtime only sees tool input through tool-call deltas. The Codex
// backend may deliver that input incrementally, whole on the done item, or
// (for custom tool calls) whole on the added item; whichever way it arrives,
// the deltas before tool-call-end must carry it exactly once, and the replayed
// history item must hold the same input.
func TestCodexOutputItemDoneForwardsUnstreamedArguments(t *testing.T) {
t.Parallel()
const (
functionArguments = `{"path":"README.md"}`
customInput = "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
)
cases := []struct {
name string
freeform bool
added bool // response.output_item.added precedes the input
addedInput bool // the added item already carries the whole input
streamed bool // input arrives through *_delta events
}{
{name: "function/added/streamed", added: true, streamed: true},
{name: "function/added/final-only", added: true},
{name: "function/no-added/streamed", streamed: true},
{name: "function/no-added/final-only"},
{name: "custom/added/streamed", freeform: true, added: true, streamed: true},
{name: "custom/added/final-only", freeform: true, added: true},
{name: "custom/no-added/streamed", freeform: true, streamed: true},
{name: "custom/no-added/final-only", freeform: true},
{name: "custom/input-on-added", freeform: true, added: true, addedInput: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 10)
provider := &CodexProvider{}
item := &itemPayload{Type: "function_call", ID: "item-1", CallID: "call-1", Name: "read_file", Arguments: functionArguments}
want := functionArguments
if tc.freeform {
item.Type = "custom_tool_call"
item.Name = "apply_patch"
item.Arguments = ""
item.Input = customInput
want = customInput
}
ctx := context.Background()
if tc.added {
initial := *item
if !tc.addedInput {
initial.Arguments = ""
initial.Input = ""
}
provider.handleOutputItemAdded(ctx, &responsesEvent{Item: &initial}, state, events)
}
if tc.streamed {
provider.handleToolInputDelta(ctx, &responsesEvent{ItemID: item.ID, Delta: want}, state, events, tc.freeform)
}
provider.handleOutputItemDone(ctx, &responsesEvent{Item: item}, state, events)
close(events)
var arguments strings.Builder
var starts, ends int
for event := range events {
switch event.Type {
case zeroruntime.StreamEventToolCallStart:
starts++
case zeroruntime.StreamEventToolCallDelta:
arguments.WriteString(event.ArgumentsFragment)
case zeroruntime.StreamEventToolCallEnd:
ends++
if arguments.String() != want {
t.Errorf("arguments at tool-call-end = %q, want %q", arguments.String(), want)
}
}
if event.ToolCallID != item.ID {
t.Errorf("tool call ID = %q, want %q", event.ToolCallID, item.ID)
}
}
if starts != 1 || ends != 1 {
t.Errorf("starts=%d ends=%d, want 1 each", starts, ends)
}
got := state.outputInputItems()
if len(got) != 1 {
t.Fatalf("output items = %v, want one tool call", got)
}
replayed := got[0].Arguments
if tc.freeform {
replayed = got[0].Input
}
if replayed != want {
t.Errorf("replayed history input = %q, want %q", replayed, want)
}
})
}
}
// The same cases through the runtime's collector, which is what the agent
// loop consumes: a function call whose arguments exist only on the done item
// and a custom tool call whose input exists only on the added item both reach
// the runtime with their input intact.
func TestCodexStreamCollectsToolInputDeliveredWhole(t *testing.T) {
t.Parallel()
var rec codexRequest
srv := newCodexResponsesServer(t, &rec,
`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"read_file","arguments":"{\"path\":\"README.md\"}"}}`,
`{"type":"response.output_item.added","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
`{"type":"response.output_item.done","output_index":1,"item":{"type":"custom_tool_call","id":"ct-1","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch\n"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":3,"output_tokens":2}}}`,
)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "read the readme"}},
Tools: []zeroruntime.ToolDefinition{
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
{Name: "apply_patch", Type: zeroruntime.ToolDefinitionFreeform},
},
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
collected := zeroruntime.CollectStream(context.Background(), stream)
if collected.Error != "" {
t.Fatalf("unexpected stream error: %q", collected.Error)
}
if len(collected.ToolCalls) != 2 {
t.Fatalf("tool calls = %+v, want read_file and apply_patch", collected.ToolCalls)
}
if call := collected.ToolCalls[0]; call.Name != "read_file" || call.Arguments != `{"path":"README.md"}` || call.Freeform {
t.Errorf("function call = %+v, want read_file with the done item's arguments", call)
}
if call := collected.ToolCalls[1]; call.Name != "apply_patch" || call.Arguments != "*** Begin Patch\n*** End Patch\n" || !call.Freeform {
t.Errorf("custom call = %+v, want apply_patch with the added item's input", call)
}
}
func TestCodexProviderSendsAssistantToolCallsAsInputItems(t *testing.T) {
t.Parallel()
// When the runtime replays a prior assistant turn that issued a tool