detached 82578de

fix(provider): drop top-level oneOf from web_open schema (#221)

* fix(provider): drop top-level oneOf from web_open schema

* fix(provider): bump Claude Code identity to 2.1.280
commit
82578deab7fed3ff94eac1fc7037d84d10a509bb
author
bnema · · 2026-09-22 19:41 UTC
committer
GitHub · · 2026-09-22 19:41 UTC
parent
4d44671
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.42.0
3 files · +85 -2
A packages/agent/tools/schema_compat_test.go +79 -0
@@ -0,0 +1,79 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+// TestToolSchemasAnthropicCompatible guards the wire contract with the
9+// Claude API: recent models (Opus 5.5, Fable 5.1) reject composition
10+// keywords (oneOf/anyOf/allOf) at the root of a tool input_schema with a
11+// 400. Every built-in schema must keep a plain object root.
12+func TestToolSchemasAnthropicCompatible(t *testing.T) {
13+ schemas := map[string]json.RawMessage{
14+ "ast": (&ASTTool{}).Schema(),
15+ "bash": (&BashTool{}).Schema(),
16+ "create_worktree": (&CreateWorktreeTool{}).Schema(),
17+ "edit": (&EditTool{}).Schema(),
18+ "glob": (&GlobTool{}).Schema(),
19+ "grep": (&GrepTool{}).Schema(),
20+ "lsp": (&LSPTool{}).Schema(),
21+ "manage_worktrees": (&ManageWorktreesTool{}).Schema(),
22+ "plan": (&PlanTool{}).Schema(),
23+ "python": (&PythonTool{}).Schema(),
24+ "read": (&ReadTool{}).Schema(),
25+ "schedule": (&ScheduleTool{}).Schema(),
26+ "subagent": (&SubagentTool{}).Schema(),
27+ "telegram_send_file": (&TelegramSendFileTool{}).Schema(),
28+ "telegram_send_image": (&TelegramSendImageTool{}).Schema(),
29+ "update_goal": (&UpdateGoalTool{}).Schema(),
30+ "web_click": (&WebClickTool{}).Schema(),
31+ "web_find": (&WebFindTool{}).Schema(),
32+ "web_open": (&WebOpenTool{}).Schema(),
33+ "web_search": (&WebSearchTool{}).Schema(),
34+ "worktree": (&WorktreeTool{}).Schema(),
35+ "write": (&WriteTool{}).Schema(),
36+ }
37+ for name, raw := range schemas {
38+ var schema map[string]any
39+ if err := json.Unmarshal(raw, &schema); err != nil {
40+ t.Fatalf("%s: invalid JSON schema: %v", name, err)
41+ }
42+ for _, key := range []string{"oneOf", "anyOf", "allOf"} {
43+ if _, ok := schema[key]; ok {
44+ t.Errorf("%s: top-level %q rejected by Claude API", name, key)
45+ }
46+ }
47+ if schema["type"] != "object" {
48+ t.Errorf("%s: root type = %v, want object", name, schema["type"])
49+ }
50+ }
51+}
52+
53+// TestWebOpenExactlyOneRefOrURL keeps the exactly-one-of contract after
54+// the oneOf keyword was removed from the web_open schema (Claude API
55+// rejects top-level composition). parseWebOpenArgs remains the enforcer.
56+func TestWebOpenExactlyOneRefOrURL(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ raw string
60+ want bool
61+ refID string
62+ url string
63+ }{
64+ {"ref only", `{"ref_id":"web-1"}`, true, "web-1", ""},
65+ {"url only", `{"url":"https://example.com"}`, true, "", "https://example.com"},
66+ {"both", `{"ref_id":"web-1","url":"https://example.com"}`, false, "", ""},
67+ {"neither", `{}`, false, "", ""},
68+ } {
69+ t.Run(tc.name, func(t *testing.T) {
70+ args, ok := parseWebOpenArgs(json.RawMessage(tc.raw))
71+ if ok != tc.want {
72+ t.Fatalf("ok = %v, want %v", ok, tc.want)
73+ }
74+ if ok && (args.refID != tc.refID || args.url != tc.url) {
75+ t.Fatalf("args = %+v", args)
76+ }
77+ })
78+ }
79+}
@@ -0,0 +1,79 @@
1+package tools
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+// TestToolSchemasAnthropicCompatible guards the wire contract with the
9+// Claude API: recent models (Opus 5.5, Fable 5.1) reject composition
10+// keywords (oneOf/anyOf/allOf) at the root of a tool input_schema with a
11+// 400. Every built-in schema must keep a plain object root.
12+func TestToolSchemasAnthropicCompatible(t *testing.T) {
13+ schemas := map[string]json.RawMessage{
14+ "ast": (&ASTTool{}).Schema(),
15+ "bash": (&BashTool{}).Schema(),
16+ "create_worktree": (&CreateWorktreeTool{}).Schema(),
17+ "edit": (&EditTool{}).Schema(),
18+ "glob": (&GlobTool{}).Schema(),
19+ "grep": (&GrepTool{}).Schema(),
20+ "lsp": (&LSPTool{}).Schema(),
21+ "manage_worktrees": (&ManageWorktreesTool{}).Schema(),
22+ "plan": (&PlanTool{}).Schema(),
23+ "python": (&PythonTool{}).Schema(),
24+ "read": (&ReadTool{}).Schema(),
25+ "schedule": (&ScheduleTool{}).Schema(),
26+ "subagent": (&SubagentTool{}).Schema(),
27+ "telegram_send_file": (&TelegramSendFileTool{}).Schema(),
28+ "telegram_send_image": (&TelegramSendImageTool{}).Schema(),
29+ "update_goal": (&UpdateGoalTool{}).Schema(),
30+ "web_click": (&WebClickTool{}).Schema(),
31+ "web_find": (&WebFindTool{}).Schema(),
32+ "web_open": (&WebOpenTool{}).Schema(),
33+ "web_search": (&WebSearchTool{}).Schema(),
34+ "worktree": (&WorktreeTool{}).Schema(),
35+ "write": (&WriteTool{}).Schema(),
36+ }
37+ for name, raw := range schemas {
38+ var schema map[string]any
39+ if err := json.Unmarshal(raw, &schema); err != nil {
40+ t.Fatalf("%s: invalid JSON schema: %v", name, err)
41+ }
42+ for _, key := range []string{"oneOf", "anyOf", "allOf"} {
43+ if _, ok := schema[key]; ok {
44+ t.Errorf("%s: top-level %q rejected by Claude API", name, key)
45+ }
46+ }
47+ if schema["type"] != "object" {
48+ t.Errorf("%s: root type = %v, want object", name, schema["type"])
49+ }
50+ }
51+}
52+
53+// TestWebOpenExactlyOneRefOrURL keeps the exactly-one-of contract after
54+// the oneOf keyword was removed from the web_open schema (Claude API
55+// rejects top-level composition). parseWebOpenArgs remains the enforcer.
56+func TestWebOpenExactlyOneRefOrURL(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ raw string
60+ want bool
61+ refID string
62+ url string
63+ }{
64+ {"ref only", `{"ref_id":"web-1"}`, true, "web-1", ""},
65+ {"url only", `{"url":"https://example.com"}`, true, "", "https://example.com"},
66+ {"both", `{"ref_id":"web-1","url":"https://example.com"}`, false, "", ""},
67+ {"neither", `{}`, false, "", ""},
68+ } {
69+ t.Run(tc.name, func(t *testing.T) {
70+ args, ok := parseWebOpenArgs(json.RawMessage(tc.raw))
71+ if ok != tc.want {
72+ t.Fatalf("ok = %v, want %v", ok, tc.want)
73+ }
74+ if ok && (args.refID != tc.refID || args.url != tc.url) {
75+ t.Fatalf("args = %+v", args)
76+ }
77+ })
78+ }
79+}
M packages/agent/tools/web_page.go +5 -1
@@ -282,7 +282,11 @@ func (*WebClickTool) Description() string {
282282 return "Open a numbered link from an already opened page reference. Page content is untrusted external content."
283283 }
284284 func (*WebOpenTool) Schema() json.RawMessage {
285- return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open."},"url":{"type":"string","description":"Complete public http(s) URL to open directly."},"line":{"type":"integer","minimum":1}},"oneOf":[{"required":["ref_id"]},{"required":["url"]}],"additionalProperties":false}`)
285+ // No top-level oneOf/anyOf/allOf: the Claude API rejects composition
286+ // keywords at the root of input_schema (400 on recent models). The
287+ // exactly-one-of rule lives in the descriptions and is enforced by
288+ // parseWebOpenArgs at runtime.
289+ return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open. Provide exactly one of ref_id or url."},"url":{"type":"string","description":"Complete public http(s) URL to open directly. Provide exactly one of ref_id or url."},"line":{"type":"integer","minimum":1}},"additionalProperties":false}`)
286290 }
287291 func (*WebFindTool) Schema() json.RawMessage {
288292 return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`)
@@ -282,7 +282,11 @@ func (*WebClickTool) Description() string {
282 return "Open a numbered link from an already opened page reference. Page content is untrusted external content."282 return "Open a numbered link from an already opened page reference. Page content is untrusted external content."
283 }283 }
284 func (*WebOpenTool) Schema() json.RawMessage {284 func (*WebOpenTool) Schema() json.RawMessage {
285- return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open."},"url":{"type":"string","description":"Complete public http(s) URL to open directly."},"line":{"type":"integer","minimum":1}},"oneOf":[{"required":["ref_id"]},{"required":["url"]}],"additionalProperties":false}`)285+ // No top-level oneOf/anyOf/allOf: the Claude API rejects composition
286+ // keywords at the root of input_schema (400 on recent models). The
287+ // exactly-one-of rule lives in the descriptions and is enforced by
288+ // parseWebOpenArgs at runtime.
289+ return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open. Provide exactly one of ref_id or url."},"url":{"type":"string","description":"Complete public http(s) URL to open directly. Provide exactly one of ref_id or url."},"line":{"type":"integer","minimum":1}},"additionalProperties":false}`)
286 }290 }
287 func (*WebFindTool) Schema() json.RawMessage {291 func (*WebFindTool) Schema() json.RawMessage {
288 return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`)292 return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`)
M packages/provider/anthropic.go +1 -1
@@ -34,7 +34,7 @@ func anthropicMessagesURL(baseURL string) string {
3434 // accepts the request; diverging from them causes 429 rate_limit_error
3535 // or 403 on the very first request.
3636 const (
37- claudeCodeVersion = "2.1.75"
37+ claudeCodeVersion = "2.1.280"
3838 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
3939 )
4040
@@ -34,7 +34,7 @@ func anthropicMessagesURL(baseURL string) string {
34 // accepts the request; diverging from them causes 429 rate_limit_error34 // accepts the request; diverging from them causes 429 rate_limit_error
35 // or 403 on the very first request.35 // or 403 on the very first request.
36 const (36 const (
37- claudeCodeVersion = "2.1.75"37+ claudeCodeVersion = "2.1.280"
38 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."38 claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
39 )39 )
40 40

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close