package lua import ( "encoding/json" "strings" "testing" ) // TestAdaptersPreserveToolCalls is the fleet-wide guard for the // "orphaned tool result" class of bug: when an agent client replays a turn // whose assistant message carries tool_calls, EVERY adapter must forward both // the call and the attribution of its result. Dropping the call (or the // tool_call_id / tool_name that ties the result to it) makes the model see a // result for a call it never made and re-issue the same call forever. // // The input deliberately uses content:[] — the shape most agent clients emit // for a tool-calling assistant turn with no text. func TestAdaptersPreserveToolCalls(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { t.Fatal(err) } defer vm.Stop() body := `{"model":"m","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 world"}, {"role":"user","content":"summarize"} ]}` const ( callID = "call_abc" fnName = "read" toolOut = "hello world" lastUser = "summarize" ) // Adapters that identify a tool result by the call id. byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", "sensenova", "trae", "agentrouter", "opencode", "anthropic"} for _, a := range byID { out, err := vm.Transform(a, "transform_request", body) if err != nil { t.Errorf("%s: transform_request: %v", a, err) continue } if !strings.Contains(out, callID) { t.Errorf("%s dropped the tool call id %q: %s", a, callID, out) } if !strings.Contains(out, fnName) { t.Errorf("%s dropped the function name %q: %s", a, fnName, out) } if !strings.Contains(out, toolOut) { t.Errorf("%s dropped the tool result: %s", a, out) } if !strings.Contains(out, lastUser) { t.Errorf("%s dropped the final user turn: %s", a, out) } } // Ollama identifies the result with tool_name (its API has no tool_call_id // on assistant turns) and takes arguments as an object, not a JSON string. out, err := vm.Transform("ollama", "transform_request", body) if err != nil { t.Fatalf("ollama: %v", err) } for _, want := range []string{`"tool_calls"`, fnName, `"tool_name"`, toolOut} { if !strings.Contains(out, want) { t.Errorf("ollama missing %s: %s", want, out) } } // Gemini identifies the result by function NAME and has no call id in its // wire format; both directions must be present as functionCall / // functionResponse parts, and the system role must move to // systemInstruction rather than sitting in contents. gBody := `{"model":"m","messages":[ {"role":"system","content":"be brief"}, {"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 world"} ]}` gout, err := vm.Transform("gemini", "transform_request", gBody) if err != nil { t.Fatalf("gemini: %v", err) } for _, want := range []string{`"functionCall"`, `"functionResponse"`, fnName, toolOut, `"systemInstruction"`} { if !strings.Contains(gout, want) { t.Errorf("gemini missing %s: %s", want, gout) } } // No message may keep the OpenAI-only roles inside contents. var greq struct { Contents []struct { Role string `json:"role"` } `json:"contents"` } if err := json.Unmarshal([]byte(gout), &greq); err != nil { t.Fatalf("gemini unmarshal: %v (%s)", err, gout) } for _, c := range greq.Contents { if c.Role != "user" && c.Role != "model" { t.Errorf("gemini contents carry an invalid role %q: %s", c.Role, gout) } } // Negative control: a text-only turn must still be forwarded verbatim. plain := `{"model":"m","messages":[{"role":"user","content":"just text"}]}` for _, a := range append(byID, "ollama", "gemini") { pout, err := vm.Transform(a, "transform_request", plain) if err != nil { t.Errorf("%s plain: %v", a, err) continue } if !strings.Contains(pout, "just text") { t.Errorf("%s dropped plain text: %s", a, pout) } } } // TestOpenCodeStreamOptionsOnlyWhenStreaming pins the fix for // "stream_options should be set along with stream": OpenCode Go (and other // strict OpenAI-compatible upstreams) reject a non-streaming request that // carries stream_options, which broke every non-stream call through the // opencode adapter. func TestOpenCodeStreamOptionsOnlyWhenStreaming(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { t.Fatal(err) } defer vm.Stop() // non-streaming: stream_options must be absent entirely out, err := vm.Transform("opencode", "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) if err != nil { t.Fatal(err) } if strings.Contains(out, "stream_options") { t.Fatalf("non-streaming request must not carry stream_options: %s", out) } // streaming: stream_options.include_usage must be set sout, err := vm.Transform("opencode", "transform_request", `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`) if err != nil { t.Fatal(err) } if !strings.Contains(sout, "stream_options") || !strings.Contains(sout, "include_usage") { t.Fatalf("streaming request must carry stream_options.include_usage: %s", sout) } } // TestOpenCodeGoVsZenReasoning pins the one behavioural difference that made a // shared adapter wrong: OpenCode Go's thinking mode REQUIRES the assistant // turn's reasoning_content to be echoed back ("The `reasoning_content` in the // thinking mode must be passed back to the API"), while the Zen free pool must // not receive it. Hence two purpose-built adapters. func TestOpenCodeGoVsZenReasoning(t *testing.T) { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { t.Fatal(err) } defer vm.Stop() body := `{"model":"m","messages":[ {"role":"user","content":"hi"}, {"role":"assistant","content":"","reasoning_content":"I should read the file.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"/x\"}"}}]}, {"role":"tool","tool_call_id":"call_1","content":"r"} ]}` // Go: reasoning_content must survive, and the tool call must stay paired. gout, err := vm.Transform("opencodego", "transform_request", body) if err != nil { t.Fatalf("opencodego: %v", err) } if !strings.Contains(gout, "I should read the file.") { t.Errorf("opencodego must pass reasoning_content back (thinking mode requires it): %s", gout) } if !strings.Contains(gout, "call_1") || !strings.Contains(gout, "tool_call_id") { t.Errorf("opencodego must keep the tool call paired with its result: %s", gout) } // Zen: reasoning_content is stripped. zout, err := vm.Transform("opencodezen", "transform_request", body) if err != nil { t.Fatalf("opencodezen: %v", err) } if strings.Contains(zout, "I should read the file.") { t.Errorf("opencodezen must strip reasoning_content: %s", zout) } if !strings.Contains(zout, "call_1") { t.Errorf("opencodezen must still keep the tool call: %s", zout) } // An assistant turn that carries tool_calls but no reasoning_content must // get an empty one injected: Go validates that field on tool-calling turns // and 400s when it is absent, while most clients never send it. // Verified against the live endpoint that an empty string passes. bare := `{"model":"m","messages":[ {"role":"user","content":"go"}, {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{}"}}]}, {"role":"tool","tool_call_id":"c1","content":"r"} ]}` bout, err := vm.Transform("opencodego", "transform_request", bare) if err != nil { t.Fatalf("opencodego bare: %v", err) } if !strings.Contains(bout, `"reasoning_content":""`) { t.Errorf("opencodego must inject an empty reasoning_content on a tool-calling turn: %s", bout) } // A plain assistant turn (no tool calls) must NOT gain the field. plainAsst := `{"model":"m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}` pout, err := vm.Transform("opencodego", "transform_request", plainAsst) if err != nil { t.Fatal(err) } if strings.Contains(pout, "reasoning_content") { t.Errorf("opencodego must not inject reasoning_content on a plain assistant turn: %s", pout) } // Both must still omit stream_options on non-streaming requests. for _, a := range []string{"opencodego", "opencodezen"} { out, err := vm.Transform(a, "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`) if err != nil { t.Fatalf("%s: %v", a, err) } if strings.Contains(out, "stream_options") { t.Errorf("%s must not send stream_options when not streaming: %s", a, out) } } } // TestOpenCodeSessionIsStableForCache pins the session-derivation contract that // makes the upstream prefix cache usable. // // Measured against the live OpenCode Go endpoint: the prefix cache is // SESSION-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens // when the session id is fixed, and 0 when it changes per request. Deriving the // session from meta.timestamp (the previous behaviour) meant every request was a // new session, so the cache could never hit. // // The session must be stable WITHIN one conversation and differ BETWEEN // conversations. Since generic OpenAI clients send no session id at all // (verified by capturing real agent traffic: no user / session_id / // conversation_id / metadata in the body, no session header), the conversation // is fingerprinted by its FIRST user message — history is replayed every turn, // so that message is invariant for the life of the conversation. func TestOpenCodeSessionIsStableForCache(t *testing.T) { for _, name := range []string{"opencodego", "opencodezen"} { vm := NewVM(freshAdapterDir(t)) if err := vm.Start(); err != nil { t.Fatal(err) } meta := func(ts int, body string) map[string]interface{} { return map[string]interface{}{ "timestamp": ts, "body": body, "source": map[string]interface{}{"name": "somesource", "meta": nil}, } } // Same conversation, later turn: history grew, first user msg unchanged. h1, err := vm.BuildHeaders(name, meta(1000, `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"}]}`)) if err != nil { t.Fatalf("%s: %v", name, err) } h2, err := vm.BuildHeaders(name, meta(2000, `{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"},{"role":"user","content":"and tests"}]}`)) if err != nil { t.Fatalf("%s: %v", name, err) } // Different conversation. h3, err := vm.BuildHeaders(name, meta(3000, `{"messages":[{"role":"user","content":"an unrelated task"}]}`)) if err != nil { t.Fatalf("%s: %v", name, err) } if h1["x-opencode-session"] == "" { t.Fatalf("%s: session header must be set", name) } if h1["x-opencode-session"] != h2["x-opencode-session"] { t.Errorf("%s: x-opencode-session must be STABLE across turns of one conversation "+ "(%q vs %q) — the prefix cache is session-scoped", name, h1["x-opencode-session"], h2["x-opencode-session"]) } if h1["x-opencode-session"] == h3["x-opencode-session"] { t.Errorf("%s: a different conversation must get a different session", name) } if h1["x-opencode-request"] == h2["x-opencode-request"] { t.Errorf("%s: x-opencode-request must differ per request", name) } // No user message at all falls back to per-source stability. h4, _ := vm.BuildHeaders(name, meta(4000, `{"messages":[{"role":"system","content":"s"}]}`)) h5, _ := vm.BuildHeaders(name, meta(5000, `{"messages":[{"role":"system","content":"s"}]}`)) if h4["x-opencode-session"] != h5["x-opencode-session"] { t.Errorf("%s: sessionless requests must still be stable per source", name) } vm.Stop() } }