Files
Cyrene_v2/internal/runtime/loop/native_toolcall_test.go
T
AskaEth f347681034
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(loop): 原生工具调用从 Response.ToolCalls 解码 + 逐次回填工具结果 — 修复工具消息无回复
- 根因: loop/agent.decodeToolCalls 只读 resp.Message.ToolCallName/resp.Content JSON信封, 从不看 api 原生解析出的 resp.ToolCalls(Anthropic tool_use / OpenAI tool_calls)
  → 模型返回 thinking+tool_use(无text) 被判定'无工具调用' → 空 Content 当最终 text → res.Text="" → no reply produced; 纯聊天消息不调工具直接出 text, 故表现为'部分消息无回复'
- agent/step.go: ToolCall 增 ID; ExtractToolCalls 导出并优先 resp.ToolCalls(保留原生id+归一化nil Args), 回落两旧约定
- loop/request.go: decodeToolCalls 委托 agent.ExtractToolCalls(流式/非流式共用)
- loop/tool_history.go(新): 原生批次回填 assistant tool_use + 带tool_use_id的tool_result; 旧JSON信封批次保持 RoleTool 纯文本(向后兼容)
- openai.go: tool_call parts 编成 tool_calls + tool_call_id
- 新增 native_toolcall_test + native_toolcall_http_test(httptest假Anthropic SSE: round1 tool_use(无text)→round2含tool_use_id/tool_result→res.Text非空)
- build/vet/test绿+交叉编译
2026-09-08 22:37:05 +08:00

132 lines
4.9 KiB
Go

package loop
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// nativeDatetimeCall is a provider-native tool call response: the adapters put
// the call in Response.ToolCalls (not in Content), which is exactly the shape a
// thinking model returns for "what time is it" when the datetime tool is
// advertised. Content is empty, so a loop that ignores ToolCalls produces a
// silent empty reply.
func nativeDatetimeCall() model.Response {
return model.Response{
Model: "m",
Content: "",
ToolCalls: []model.ToolCall{{
ID: "call_native_1",
Name: "datetime",
Args: map[string]any{"timezone": "Asia/Shanghai"},
}},
}
}
// assertNativeFeedBack checks that req carries the assistant tool_use turn and a
// correlated tool result for toolCallID.
func assertNativeFeedBack(t *testing.T, req model.Request, toolCallID string) {
t.Helper()
for i := 0; i < len(req.Messages)-1; i++ {
m := req.Messages[i]
if m.Role != model.RoleAssistant || len(m.Content) == 0 {
continue
}
if m.Content[0].Type != model.ContentTypeToolCall || m.Content[0].ToolCallID != toolCallID {
continue
}
next := req.Messages[i+1]
if next.Role != model.RoleTool {
t.Fatalf("message after assistant tool_use has role %q, want tool", next.Role)
}
if next.ToolCallID != toolCallID {
t.Fatalf("tool result ToolCallID = %q, want %q", next.ToolCallID, toolCallID)
}
if len(next.Content) == 0 || next.Content[0].Type != model.ContentTypeToolResult {
t.Fatalf("tool result content = %+v, want a tool_result part", next.Content)
}
return
}
t.Fatalf("no native assistant tool_use + tool_result pair for %q in %+v", toolCallID, req.Messages)
}
// TestRun_NativeToolCall_Streaming covers the real chat path (the loop drives
// ChatStream when OnStream is set): a native provider tool call with empty
// Content must be executed and fed back as native tool_use + tool_result, and
// the run must finish with the model's follow-up text instead of an empty reply.
func TestRun_NativeToolCall_Streaming(t *testing.T) {
t.Parallel()
ad := &mockAdapter{stream: []mgResp{
{resp: nativeDatetimeCall()},
{resp: model.Response{Model: "m", Content: "现在是 10:34。"}},
}}
td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "瞅瞅当前系统时间是多少")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "现在是 10:34。" {
t.Fatalf("Text = %q, want the follow-up answer (empty means the native call was dropped)", res.Text)
}
if res.Steps != 2 {
t.Fatalf("Steps = %d, want 2", res.Steps)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
if len(ad.streamReqs) < 2 {
t.Fatalf("stream requests = %d, want >= 2", len(ad.streamReqs))
}
assertNativeFeedBack(t, ad.streamReqs[1], "call_native_1")
}
// TestRun_NativeToolCall_NonStreaming covers the muted/internal path where the
// loop delegates to agent.RunStep (adapter.Chat), which must decode the same
// native tool call.
func TestRun_NativeToolCall_NonStreaming(t *testing.T) {
t.Parallel()
ad := &mockAdapter{chat: []mgResp{
{resp: nativeDatetimeCall()},
{resp: model.Response{Model: "m", Content: "现在是 10:34。"}},
}}
td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}})
a := newTestAgent(t, ad, td)
l := New(Options{Agent: a, MaxRounds: 8})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "几点了")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "现在是 10:34。" || res.ToolCallsExecuted != 1 {
t.Fatalf("Text = %q, ToolCallsExecuted = %d; want follow-up text + 1 call", res.Text, res.ToolCallsExecuted)
}
if len(ad.reqs) < 2 {
t.Fatalf("chat requests = %d, want >= 2", len(ad.reqs))
}
assertNativeFeedBack(t, ad.reqs[1], "call_native_1")
}
// TestExtractToolCalls_PrefersNative pins the decoder contract: provider-native
// ToolCalls win over a Content envelope and preserve the provider id, so the
// feed-back can correlate the result.
func TestExtractToolCalls_PrefersNative(t *testing.T) {
t.Parallel()
resp := nativeDatetimeCall()
resp.Content = `{"tool_calls":[{"name":"legacy","args":{}}]}`
calls, ok := agent.ExtractToolCalls(resp)
if !ok || len(calls) != 1 {
t.Fatalf("ExtractToolCalls = (%+v, %v), want 1 native call", calls, ok)
}
if calls[0].ID != "call_native_1" || calls[0].Name != "datetime" {
t.Fatalf("native call = %+v, want id=call_native_1 name=datetime", calls[0])
}
if calls[0].Args["timezone"] != "Asia/Shanghai" {
t.Fatalf("native args = %+v, want timezone=Asia/Shanghai", calls[0].Args)
}
}