From 49b9641d11a0dbc4200aa26dfec8d2898e927d4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:50:35 +0000 Subject: [PATCH 1/3] aisdk: fix OpenAI streamed tool calls and Anthropic no-arg tools Fixes bug-scan P0 #7 and #8 from specs/bug-scan.md. - #7: The OpenAI stream emits ToolCallDeltaEvents for a function call and then a final ToolCallEvent with the same ID. ResponseBuilder rejected that final event as a duplicate, so builder.StreamToResponse failed on every OpenAI stream containing a function call. The builder now finalizes the existing block (taking the complete args from the final event) and only errors when two complete ToolCallEvents share an ID. - #8: encodeInputSchema dereferenced a nil schema, so a FunctionTool with no InputSchema panicked when sent to Anthropic. A nil schema is now encoded as an empty object schema. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bqy92pq662Fgv5A4GoaUD2 --- aisdk/ai/builder/builder.go | 28 +++++++++-- aisdk/ai/builder/builder_test.go | 48 +++++++++++++++++++ .../anthropic/codec/encode_tools_test.go | 16 +++++++ .../ai/provider/anthropic/codec/jsonschema.go | 9 ++++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/aisdk/ai/builder/builder.go b/aisdk/ai/builder/builder.go index 5450b2bb..ea8be0bb 100644 --- a/aisdk/ai/builder/builder.go +++ b/aisdk/ai/builder/builder.go @@ -26,6 +26,8 @@ type ResponseBuilder struct { resp api.Response // Map of tool call ID to index in Content array (for parallel tool calls) toolCallIndices map[string]int + // Set of tool call IDs that have received a complete ToolCallEvent. + completedToolCalls map[string]bool // Error encountered during event processing err error @@ -46,9 +48,10 @@ func NewResponseBuilder() *ResponseBuilder { Warnings: []api.CallWarning{}, FinishReason: api.FinishReason(""), }, - toolCallIndices: make(map[string]int), - currentState: noState, - err: nil, + toolCallIndices: make(map[string]int), + completedToolCalls: make(map[string]bool), + currentState: noState, + err: nil, } } @@ -155,11 +158,28 @@ func (b *ResponseBuilder) addReasoningSignature(e *api.ReasoningSignatureEvent) // addToolCall adds a tool call event to the response. func (b *ResponseBuilder) addToolCall(e *api.ToolCallEvent) error { // Check for duplicate tool call ID - if _, exists := b.toolCallIndices[e.ToolCallID]; exists { + if b.completedToolCalls[e.ToolCallID] { return fmt.Errorf("duplicate tool call ID: %s", e.ToolCallID) } b.currentState = toolCallState + b.completedToolCalls[e.ToolCallID] = true + + // Providers such as OpenAI stream a tool call as a series of + // ToolCallDeltaEvents followed by a final ToolCallEvent with the same ID + // and the complete arguments. In that case, finalize the existing block + // instead of treating the final event as a duplicate. + if idx, exists := b.toolCallIndices[e.ToolCallID]; exists { + if toolCall, ok := b.resp.Content[idx].(*api.ToolCallBlock); ok { + if e.ToolName != "" { + toolCall.ToolName = e.ToolName + } + if len(e.Args) > 0 { + toolCall.Args = slices.Clone(e.Args) + } + } + return nil + } // Create new tool call block toolCall := &api.ToolCallBlock{ diff --git a/aisdk/ai/builder/builder_test.go b/aisdk/ai/builder/builder_test.go index cc322242..d0d7bd95 100644 --- a/aisdk/ai/builder/builder_test.go +++ b/aisdk/ai/builder/builder_test.go @@ -257,6 +257,43 @@ func TestResponseBuilder(t *testing.T) { }, }, }, + { + // Mirrors the OpenAI Responses stream: output_item.added and + // function_call_arguments.delta produce deltas, then + // output_item.done produces a final ToolCallEvent with the same ID. + name: "tool call deltas followed by final tool call", + events: []api.StreamEvent{ + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(``), + }, + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`{"location":`), + }, + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`"Paris"}`), + }, + &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris"}`), + }, + }, + expected: &api.Response{ + Content: []api.ContentBlock{ + &api.ToolCallBlock{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris"}`), + }, + }, + }, + }, { name: "multiple function calls", events: []api.StreamEvent{ @@ -477,3 +514,14 @@ func TestResponseBuilder_ValueTypes(t *testing.T) { }) } } + +func TestResponseBuilder_DuplicateToolCall(t *testing.T) { + b := NewResponseBuilder() + ev := &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{}`), + } + assert.NoError(t, b.AddEvent(ev)) + assert.ErrorContains(t, b.AddEvent(ev), "duplicate tool call ID") +} diff --git a/aisdk/ai/provider/anthropic/codec/encode_tools_test.go b/aisdk/ai/provider/anthropic/codec/encode_tools_test.go index 7c624475..d438eb63 100644 --- a/aisdk/ai/provider/anthropic/codec/encode_tools_test.go +++ b/aisdk/ai/provider/anthropic/codec/encode_tools_test.go @@ -18,6 +18,22 @@ func TestEncodeFunctionTool(t *testing.T) { wantErr bool want string // Expected JSON output }{ + { + name: "function tool without input schema", + input: api.FunctionTool{ + Name: "now", + Description: "Returns the current time", + }, + want: `{ + "type": "tool", + "name": "now", + "description": "Returns the current time", + "input_schema": { + "type": "object", + "properties": {} + } + }`, + }, { name: "simple function tool", input: api.FunctionTool{ diff --git a/aisdk/ai/provider/anthropic/codec/jsonschema.go b/aisdk/ai/provider/anthropic/codec/jsonschema.go index 92a06fd5..f40dfb5e 100644 --- a/aisdk/ai/provider/anthropic/codec/jsonschema.go +++ b/aisdk/ai/provider/anthropic/codec/jsonschema.go @@ -62,6 +62,15 @@ func normalizeSchemaMap(schemaMap map[string]interface{}) { // encodeInputSchema converts the JSON schema definition to Anthropic's schema format func encodeInputSchema(schema *jsonschema.Schema) (anthropic.BetaToolInputSchemaParam, error) { + // InputSchema is optional on FunctionTool. A tool without one takes no + // arguments, which Anthropic expresses as an object with no properties. + if schema == nil { + return anthropic.BetaToolInputSchemaParam{ + Type: "object", + Properties: map[string]any{}, + }, nil + } + // Verify the schema type is "object" // TODO: When the Anthropic SDK supports other type values or union types, // we should update this to pass through the original type information. From dd2b7ce5939fff8fa2c1c1adbaebb227569a1553 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:51:16 +0000 Subject: [PATCH 2/3] specs/bug-scan: mark P0 #7, #8 as fixed Adds the status as its own line under the matching "Suggested fix order" item so it doesn't conflict with the other P0 fix PRs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bqy92pq662Fgv5A4GoaUD2 --- specs/bug-scan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/bug-scan.md b/specs/bug-scan.md index 284e300a..fe444435 100644 --- a/specs/bug-scan.md +++ b/specs/bug-scan.md @@ -94,5 +94,6 @@ This is a static review of the code. Every finding was re-read in the source, an 1. **envsec secrets hygiene (#1–3, #6):** add `$` to the name regex, write secret and credential files `0600`, and fix the `--format` default. Each is a one-line fix with high payoff. 2. **Auth panics (#4, #5):** use a comma-ok assertion for `id_token` and nil-check `IDClaims()` everywhere. Together these unblock token refresh for every Jetify CLI. 3. **aisdk OpenAI/Anthropic correctness (#7–11):** these break basic tool calling, no-arg tools, and multi-turn reasoning. + - ✅ **Fixed** #7, #8 in [jetify-com/opensource#616](https://github.com/jetify-com/opensource/pull/616). #9–11 are still open. 4. **`pkg/osdir` and `runx` (#12–18):** small, contained fixes, each of which should get a regression test. 5. **SSE hardening (#21, #23–25)**, then the remaining P2 and P3 items. From 280548ec2669c6e8a53807498dfdace4f09498ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:54:23 +0000 Subject: [PATCH 3/3] aisdk/builder: test that final ToolCallEvent replaces delta args in place Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bqy92pq662Fgv5A4GoaUD2 --- aisdk/ai/builder/builder_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/aisdk/ai/builder/builder_test.go b/aisdk/ai/builder/builder_test.go index d0d7bd95..50af3096 100644 --- a/aisdk/ai/builder/builder_test.go +++ b/aisdk/ai/builder/builder_test.go @@ -525,3 +525,35 @@ func TestResponseBuilder_DuplicateToolCall(t *testing.T) { assert.NoError(t, b.AddEvent(ev)) assert.ErrorContains(t, b.AddEvent(ev), "duplicate tool call ID") } + +// The final ToolCallEvent is authoritative: it must update the block built +// from deltas in place (not append a second block), and its complete args +// must replace whatever the deltas accumulated. +func TestResponseBuilder_FinalToolCallReplacesDeltaArgs(t *testing.T) { + b := NewResponseBuilder() + events := []api.StreamEvent{ + &api.ToolCallDeltaEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + ArgsDelta: json.RawMessage(`{"location":"Par`), + }, + &api.ToolCallEvent{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris","unit":"C"}`), + }, + } + for _, ev := range events { + assert.NoError(t, b.AddEvent(ev)) + } + + resp, err := b.Build() + assert.NoError(t, err) + assert.Equal(t, []api.ContentBlock{ + &api.ToolCallBlock{ + ToolCallID: "call_1", + ToolName: "get_weather", + Args: json.RawMessage(`{"location":"Paris","unit":"C"}`), + }, + }, resp.Content) +}