Skip to content

Commit 8d2516e

Browse files
joocursoragent
andcommitted
feat: preserve tool calls across protocol bridges and download desktop CLI online
Add portable InputSlots for tool calls/results, Claude request-field extensions, and Responses SSE tool streaming so Codex↔Claude transcoding keeps tool_use semantics. Desktop packaged builds now fetch the core CLI at runtime instead of bundling Go binaries. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a38b583 commit 8d2516e

28 files changed

Lines changed: 2098 additions & 214 deletions

.github/workflows/release-desktop.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,6 @@ jobs:
6868
RELEASE_VERSION: ${{ github.event.inputs.version }}
6969
run: node scripts/sync-desktop-version.mjs
7070

71-
- name: Setup Go
72-
uses: actions/setup-go@v5
73-
with:
74-
go-version-file: core/go.mod
75-
7671
- name: Setup Node
7772
uses: actions/setup-node@v4
7873
with:
@@ -128,11 +123,6 @@ jobs:
128123
RELEASE_VERSION: ${{ github.event.inputs.version }}
129124
run: node scripts/sync-desktop-version.mjs
130125

131-
- name: Setup Go
132-
uses: actions/setup-go@v5
133-
with:
134-
go-version-file: core/go.mod
135-
136126
- name: Setup Node
137127
uses: actions/setup-node@v4
138128
with:

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.23"
7+
Version = "dev0.1.28"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/cliswitch/switch.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,8 @@ func VendorCompatibleWithCLI(kind agentkind.Kind, vendor profile.Profile) bool {
8080
if providerID == "" {
8181
return false
8282
}
83-
switch kind {
84-
case agentkind.ClaudeCode, agentkind.KimiCode:
85-
if providerID == provider.ClaudeCodeProviderID {
86-
return true
87-
}
88-
case agentkind.Codex:
89-
if providerID == provider.CodexProviderID || providerID == provider.ClaudeCodeProviderID {
90-
return true
91-
}
92-
default:
93-
if providerID == provider.ClaudeCodeProviderID || providerID == provider.CodexProviderID {
94-
return true
95-
}
83+
if providerID == provider.ClaudeCodeProviderID || providerID == provider.CodexProviderID {
84+
return len(apply.SupportedStyles(kind)) > 0
9685
}
9786
if providerID == provider.OllamaProviderID || providerID == provider.CustomAPIProviderID {
9887
return len(apply.SupportedStyles(kind)) > 0

core/internal/cliswitch/switch_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ func TestCodexCanResolveClaudeSubscriptionSelection(t *testing.T) {
3434
}
3535
}
3636

37+
func TestKimiCanResolveCodexSubscriptionSelection(t *testing.T) {
38+
s := &profile.Store{
39+
Version: profile.StoreVersion,
40+
List: []profile.Profile{{
41+
Name: provider.CodexVendorName,
42+
Kind: "subscription",
43+
SubscriptionProviderID: provider.CodexProviderID,
44+
APIStyle: apistyle.OpenAIResponses,
45+
Models: []profile.Model{{
46+
ID: "gpt-5.4",
47+
Model: "gpt-5.4",
48+
APIStyle: apistyle.OpenAIResponses,
49+
}},
50+
}},
51+
}
52+
53+
selection, err := ResolveSelection(s, agentkind.KimiCode, provider.CodexVendorName, "gpt-5.4")
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
if selection.ProviderID != provider.CodexProviderID || selection.ModelID != "gpt-5.4" {
58+
t.Fatalf("selection = %+v", selection)
59+
}
60+
}
61+
3762
func TestMultiStyleCLIsUsePreferredIngressForCodexSubscription(t *testing.T) {
3863
hit := profile.VendorModelHit{
3964
Vendor: profile.Profile{

core/internal/protocol/capability.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func ValidateRequestExtensionsForEgress(egress apistyle.Style, r Request) error
4545
}
4646
}
4747
for _, ext := range r.Extensions {
48-
if ext.Kind == ExtOpenAIResponsesInputString || ext.Kind == ExtOpenAIResponsesRequestField {
48+
if ext.Kind == ExtOpenAIResponsesInputString || ext.Kind == ExtOpenAIResponsesRequestField || ext.Kind == ExtAnthropicRequestField {
4949
continue
5050
}
5151
if !extensionSupportedByStyle(style, ext.Kind) {

core/internal/protocol/decode.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func DecodeRequestClaude(body []byte) (Request, error) {
6969
}
7070
msgsAny, _ := raw["messages"].([]any)
7171
msgList, sys := PartitionSystemMessages(msgsAny, raw["system"])
72+
inputSlots := decodeClaudeInputSlots(msgsAny)
7273
tools, _ := mapClaudeTools(raw["tools"])
7374
var meta *Metadata
7475
if sys != "" {
@@ -87,7 +88,11 @@ func DecodeRequestClaude(body []byte) (Request, error) {
8788
}
8889
}
8990
req := NewRequest(jsonStringField(raw, "model"), msgList, streamDefault(streamPtr), maxTok, tempPtr, meta)
91+
req.InputSlots = inputSlots
9092
req.Tools = tools
93+
if err := appendClaudeRequestFieldExtensions(&req, raw); err != nil {
94+
return Request{}, err
95+
}
9196
return req, nil
9297
}
9398

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package protocol
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
var claudeKnownRequestKeys = map[string]struct{}{
9+
"model": {},
10+
"messages": {},
11+
"stream": {},
12+
"max_tokens": {},
13+
"temperature": {},
14+
"system": {},
15+
"tools": {},
16+
}
17+
18+
func appendClaudeRequestFieldExtensions(req *Request, raw map[string]any) error {
19+
if req == nil || raw == nil {
20+
return nil
21+
}
22+
for key, value := range raw {
23+
if !claudeRequestFieldNeedsPreservation(key, value) {
24+
continue
25+
}
26+
ext, err := requestFieldExtensionForKind(ExtAnthropicRequestField, key, value)
27+
if err != nil {
28+
return fmt.Errorf("preserve request field %q: %w", key, err)
29+
}
30+
req.Extensions = append(req.Extensions, ext)
31+
}
32+
return nil
33+
}
34+
35+
func claudeRequestFieldNeedsPreservation(key string, value any) bool {
36+
if _, known := claudeKnownRequestKeys[key]; !known {
37+
return true
38+
}
39+
switch key {
40+
case "system":
41+
return !isSimpleClaudeSystemWire(value)
42+
case "messages":
43+
return !isSimpleClaudeMessagesWire(value)
44+
case "tools":
45+
return !isSimpleClaudeToolsWire(value)
46+
default:
47+
return false
48+
}
49+
}
50+
51+
func isSimpleClaudeSystemWire(value any) bool {
52+
if value == nil {
53+
return true
54+
}
55+
switch v := value.(type) {
56+
case string:
57+
return true
58+
case []any:
59+
for _, item := range v {
60+
block, ok := item.(map[string]any)
61+
if !ok || !isSimpleClaudeTextBlock(block) {
62+
return false
63+
}
64+
}
65+
return len(v) > 0
66+
default:
67+
return false
68+
}
69+
}
70+
71+
func isSimpleClaudeMessagesWire(value any) bool {
72+
arr, ok := value.([]any)
73+
if !ok {
74+
return value == nil
75+
}
76+
for _, item := range arr {
77+
msg, ok := item.(map[string]any)
78+
if !ok {
79+
return false
80+
}
81+
role := strings.ToLower(strings.TrimSpace(fmt.Sprint(msg["role"])))
82+
if role != string(RoleUser) && role != string(RoleAssistant) {
83+
return false
84+
}
85+
if !isSimpleClaudeMessageContent(msg["content"]) {
86+
return false
87+
}
88+
for field := range msg {
89+
if field != "role" && field != "content" {
90+
return false
91+
}
92+
}
93+
}
94+
return true
95+
}
96+
97+
func isSimpleClaudeMessageContent(content any) bool {
98+
if content == nil {
99+
return true
100+
}
101+
switch v := content.(type) {
102+
case string:
103+
return true
104+
case []any:
105+
for _, item := range v {
106+
block, ok := item.(map[string]any)
107+
if !ok || !isSimpleClaudeTextBlock(block) {
108+
return false
109+
}
110+
}
111+
return true
112+
default:
113+
return false
114+
}
115+
}
116+
117+
func isSimpleClaudeTextBlock(block map[string]any) bool {
118+
if block == nil {
119+
return false
120+
}
121+
if strings.TrimSpace(fmt.Sprint(block["type"])) != "text" {
122+
return false
123+
}
124+
if _, ok := block["text"]; !ok {
125+
return false
126+
}
127+
for field := range block {
128+
if field != "type" && field != "text" {
129+
return false
130+
}
131+
}
132+
return true
133+
}
134+
135+
func isSimpleClaudeToolsWire(value any) bool {
136+
arr, ok := value.([]any)
137+
if !ok {
138+
return value == nil
139+
}
140+
allowedToolKeys := map[string]struct{}{
141+
"name": {}, "description": {}, "input_schema": {}, "parameters": {}, "type": {}, "function": {},
142+
}
143+
for _, item := range arr {
144+
entry, ok := item.(map[string]any)
145+
if !ok {
146+
return false
147+
}
148+
if fn, ok := entry["function"].(map[string]any); ok && fn != nil {
149+
entry = fn
150+
}
151+
for field := range entry {
152+
if _, ok := allowedToolKeys[field]; !ok {
153+
return false
154+
}
155+
}
156+
}
157+
return true
158+
}
159+
160+
func decodeClaudeInputSlots(messages []any) []InputSlot {
161+
slots := make([]InputSlot, 0)
162+
for _, raw := range messages {
163+
msg, ok := raw.(map[string]any)
164+
if !ok {
165+
continue
166+
}
167+
role := strings.ToLower(strings.TrimSpace(fmt.Sprint(msg["role"])))
168+
if role == "" {
169+
role = string(RoleUser)
170+
}
171+
content := msg["content"]
172+
blocks, ok := content.([]any)
173+
if !ok {
174+
text := strings.TrimSpace(TextContent(content))
175+
if text != "" {
176+
m := Message{Role: Role(role), Content: text}
177+
slots = append(slots, InputSlot{Message: &m})
178+
}
179+
continue
180+
}
181+
var textParts []string
182+
flushText := func() {
183+
text := strings.TrimSpace(strings.Join(filterNonEmpty(textParts), "\n"))
184+
textParts = nil
185+
if text == "" {
186+
return
187+
}
188+
m := Message{Role: Role(role), Content: text}
189+
slots = append(slots, InputSlot{Message: &m})
190+
}
191+
for _, rawBlock := range blocks {
192+
block, ok := rawBlock.(map[string]any)
193+
if !ok {
194+
continue
195+
}
196+
switch strings.TrimSpace(fmt.Sprint(block["type"])) {
197+
case "text":
198+
if text := strings.TrimSpace(TextContent(block["text"])); text != "" {
199+
textParts = append(textParts, text)
200+
}
201+
case "tool_use":
202+
flushText()
203+
if tc := toolCallFromClaudeBlock(block); tc != nil {
204+
slots = append(slots, InputSlot{ToolCall: tc})
205+
}
206+
case "tool_result":
207+
flushText()
208+
if tr := toolResultFromClaudeBlock(block); tr != nil {
209+
slots = append(slots, InputSlot{ToolResult: tr})
210+
}
211+
default:
212+
if text := strings.TrimSpace(TextContent(block)); text != "" {
213+
textParts = append(textParts, text)
214+
}
215+
}
216+
}
217+
flushText()
218+
}
219+
return slots
220+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package protocol
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestAppendClaudeRequestFieldExtensionsPreservesComplexWire(t *testing.T) {
9+
raw, err := jsonDecodeMap([]byte(`{
10+
"model":"claude-opus-4-7",
11+
"max_tokens":1024,
12+
"stream":true,
13+
"thinking":{"type":"enabled","budget_tokens":2048},
14+
"system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],
15+
"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]
16+
}`))
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
req := Request{Model: "claude-opus-4-7"}
21+
if err := appendClaudeRequestFieldExtensions(&req, raw); err != nil {
22+
t.Fatal(err)
23+
}
24+
if len(req.Extensions) < 3 {
25+
t.Fatalf("extensions = %#v, want thinking/system/messages preserved", req.Extensions)
26+
}
27+
28+
upstream, err := EncodeRequestClaude(req)
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
text := string(upstream)
33+
for _, want := range []string{`"thinking"`, `"cache_control"`, `"budget_tokens":2048`} {
34+
if !strings.Contains(text, want) {
35+
t.Fatalf("encoded upstream missing %s: %s", want, upstream)
36+
}
37+
}
38+
}
39+
40+
func TestAppendClaudeRequestFieldExtensionsSkipsSimpleWire(t *testing.T) {
41+
raw, err := jsonDecodeMap([]byte(`{
42+
"model":"claude-opus-4-7",
43+
"max_tokens":1024,
44+
"stream":true,
45+
"messages":[{"role":"user","content":"hello"}]
46+
}`))
47+
if err != nil {
48+
t.Fatal(err)
49+
}
50+
req := Request{Model: "claude-opus-4-7", Messages: []Message{{Role: RoleUser, Content: "hello"}}}
51+
if err := appendClaudeRequestFieldExtensions(&req, raw); err != nil {
52+
t.Fatal(err)
53+
}
54+
if len(req.Extensions) != 0 {
55+
t.Fatalf("extensions = %#v, want none for simple wire", req.Extensions)
56+
}
57+
}

0 commit comments

Comments
 (0)