diff --git a/internal/lua/adapters/opencode.lua b/internal/lua/adapters/opencode.lua index 63c07df..72e2e06 100644 --- a/internal/lua/adapters/opencode.lua +++ b/internal/lua/adapters/opencode.lua @@ -34,8 +34,10 @@ end -- OpenAI /chat/completions format (pass-through, strip provider-specific fields) -- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part --- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的 --- 消息整条丢弃,避免上游 "unknown variant `image_url`, expected `text`"。 +-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 +-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 +-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, +-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 -- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: -- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 -- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 @@ -72,7 +74,24 @@ function adapter.transform_request(raw_body) end end if #parts == 0 then - drop = true + -- Content collapsed to nothing after stripping unsupported + -- parts. A message that still carries a tool call must + -- NEVER be dropped: the very next message is its tool + -- result, and dropping the call orphans that result. The + -- model then sees a result for a call it never made and + -- re-issues the same tool call on every turn (observed as + -- an infinite "repeated tool call" loop). + -- + -- This is the common shape for agent clients: an assistant + -- turn whose content is only [thinking, toolCall] serialises + -- to content:[] with tool_calls — exactly the case that used + -- to vanish here. Emit "" instead, which zen accepts. + if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) + or msg.tool_call_id ~= nil then + msg.content = "" + else + drop = true + end else msg.content = parts end diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index bf17cc4..eac3fce 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -221,6 +221,70 @@ func TestOpenCodeStripsMultiModalParts(t *testing.T) { } } +// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite +// "repeated tool call" loop: an assistant turn whose content serialises to an +// empty ARRAY while carrying tool_calls must survive the multimodal strip. +// Dropping it orphans the following tool result, so the model never sees the +// call it already made and re-issues it on every turn. +func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + body := `{"model":"x","messages":[ + {"role":"user","content":"read /tmp/x"}, + {"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]}, + {"role":"tool","tool_call_id":"call_abc","content":"hello"}, + {"role":"user","content":"now summarize"} + ]}` + out, err := vm.Transform("opencode", "transform_request", body) + if err != nil { + t.Fatalf("transform_request: %v", err) + } + + var req struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatalf("unmarshal: %v (%s)", err, out) + } + if len(req.Messages) != 4 { + t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out) + } + asst := req.Messages[1] + if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" { + t.Fatalf("assistant tool_calls lost: %s", out) + } + if string(asst.Content) != `""` { + t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content) + } + if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" { + t.Fatalf("tool result must stay paired with its call: %s", out) + } + + // Negative control: a message with neither text nor tool calls is still dropped. + only := `{"model":"x","messages":[ + {"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]}, + {"role":"user","content":"keep me"} + ]}` + out2, err := vm.Transform("opencode", "transform_request", only) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(out2, `"role"`); got != 1 { + t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2) + } +} + func TestBuildHeadersCustomHook(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil {