package loop import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // nativeDatetimeCall 是一个 provider 原生工具调用响应:适配器把 // 调用放在 Response.ToolCalls(而非 Content)中,这正是 // 思考模型在 datetime 工具被广告时对「现在几点」返回的 // 形态。Content 为空,因此忽略 ToolCalls 的循环会产出 // 静默的空回复。 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 检查 req 携带 assistant tool_use 轮以及 // 与 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 覆盖真实聊天路径(设置 OnStream 时 // 循环驱动 ChatStream):Content 为空的 provider 原生工具调用 // 必须被执行并以原生 tool_use + tool_result 回灌,且 // run 必须以模型的后续文本结束,而不是空回复。 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 覆盖静音/内部路径,其中 // 循环委托给 agent.RunStep(adapter.Chat),后者必须解码同一个 // 原生工具调用。 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 钉住解码器契约:provider 原生 // ToolCalls 优先于 Content 信封并保留 provider id,使 // 回灌能关联该结果。 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) } }