diff --git a/ai/internal/anthropic/client.go b/ai/internal/anthropic/client.go index 26a656e..1353b85 100644 --- a/ai/internal/anthropic/client.go +++ b/ai/internal/anthropic/client.go @@ -135,11 +135,11 @@ type request struct { } type response struct { - ID string `json:"id"` - Type string `json:"type"` - Model string `json:"model"` - StopReason string `json:"stop_reason"` - Content []block `json:"content"` + ID string `json:"id"` + Type string `json:"type"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + Content json.RawMessage `json:"content"` // kept verbatim: thinking blocks (with their signature) must be replayed unchanged Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` @@ -275,8 +275,10 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena out.ID = fmt.Sprintf("msg_local_%d", c.seq) c.mu.Unlock() } + var blocks []block + _ = json.Unmarshal(r.Content, &blocks) var text strings.Builder - for _, bl := range r.Content { + for _, bl := range blocks { switch bl.Type { case "text": text.WriteString(bl.Text) @@ -296,7 +298,9 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena out.Usage.OutputTokens = r.Usage.OutputTokens out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens - // remember the conversation for the next turn (assistant content kept verbatim so tool_use ids line up) + // remember the conversation for the next turn: the assistant content is replayed as the raw JSON the API returned, + // so tool_use ids and thinking blocks (adaptive thinking is always on for Claude 5.5; a thinking block replayed + // without its text/signature is rejected with "thinking.thinking: Field required") stay intact full := append(append([]message{}, msgs...), message{Role: "assistant", Content: r.Content}) c.mu.Lock() c.conv[out.ID] = conversation{system: body.System, msgs: full} diff --git a/ai/internal/anthropic/client_test.go b/ai/internal/anthropic/client_test.go index 039ce9e..2b0f8ec 100644 --- a/ai/internal/anthropic/client_test.go +++ b/ai/internal/anthropic/client_test.go @@ -24,7 +24,7 @@ func TestTranslateTurns(t *testing.T) { bodies = append(bodies, b) switch len(bodies) { case 1: - w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`)) + w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"thinking","thinking":"let me probe","signature":"sig-abc"},{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`)) default: w.Write([]byte(`{"id":"msg_2","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_2","name":"submit_diagnosis","input":{"root_cause_label":"x","confidence":0.9}}],"usage":{"input_tokens":200,"output_tokens":30}}`)) } @@ -75,6 +75,10 @@ func TestTranslateTurns(t *testing.T) { if as["role"] != "assistant" || !strings.Contains(mustJSON(as["content"]), `"tool_use"`) { t.Fatalf("assistant turn not replayed: %v", as) } + // the thinking block must come back verbatim with its text and signature (Claude 5.5 rejects a stripped one) + if ac := mustJSON(as["content"]); !strings.Contains(ac, `"thinking":"let me probe"`) || !strings.Contains(ac, `"signature":"sig-abc"`) { + t.Fatalf("thinking block not replayed verbatim: %s", ac) + } last := msgs[2].(map[string]any)["content"].([]any) if tr := last[0].(map[string]any); tr["type"] != "tool_result" || tr["tool_use_id"] != "toolu_1" || tr["content"] != `{"alb":1}` { t.Fatalf("tool_result: %v", tr)